From b5ceaf812bd8938823c319fa35c5601c1fdab091 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 25 Jun 2026 22:10:30 +0000 Subject: [PATCH 1/4] RTC: Accumulated fixes from fuzz testing - Preserve persisted CRDT support for post entities so RTC-enabled posts suppress the legacy post-lock modal when multiple users collaborate. - Preserve remote CRDT edits from stale local snapshots and reject stale persisted documents. - Prevent stale save responses from overwriting CRDT-backed title/content state after collaborative edits. - Stabilize table block synchronization, including duplicate row identity, stale table snapshots, and query array identity. - Anchor collaborator selections to keyed block roots and cover nested cursor awareness regressions, including fuzz regressions around #77673 and follow-up RTC fixes from #77924. - Harden collaborative editing behavior accumulated from RTC fix PRs #77723, #77775, #77866, #77874, #77876, #77887, #77890, and #78251. - Harden HTTP polling and large-post sync paths with bounded responses and compaction fallbacks. --- docs/reference-guides/data/data-core.md | 1 + .../class-wp-http-polling-sync-server.php | 63 +- .../class-wp-sync-post-meta-storage.php | 72 +- lib/compat/wordpress-7.1/collaboration.php | 285 ++- .../interface-wp-sync-storage.php | 7 +- .../provider/test/use-block-sync.js | 75 + .../src/components/provider/use-block-sync.js | 8 + packages/block-library/src/table/state.js | 3 + .../block-library/src/table/test/state.js | 119 + packages/core-data/README.md | 1 + packages/core-data/src/actions.js | 653 +++++- .../core-data/src/awareness/block-lookup.ts | 31 +- .../src/awareness/post-editor-awareness.ts | 34 +- .../src/awareness/test/block-lookup.ts | 22 + .../awareness/test/post-editor-awareness.ts | 83 +- packages/core-data/src/entities.js | 692 +++++- packages/core-data/src/test/actions.js | 1765 ++++++++++++++- packages/core-data/src/test/entities.js | 1135 +++++++++- packages/core-data/src/utils/crdt-blocks.ts | 1939 ++++++++++++++--- .../core-data/src/utils/crdt-selection.ts | 23 +- .../src/utils/crdt-user-selections.ts | 6 +- packages/core-data/src/utils/crdt-utils.ts | 77 + packages/core-data/src/utils/crdt.ts | 279 ++- .../core-data/src/utils/test/crdt-blocks.ts | 570 +++++ ...-object-query-stale-snapshot-repro.test.ts | 234 ++ .../test/crdt-stale-query-array-post.test.ts | 260 +++ .../utils/test/crdt-stale-query-array.test.ts | 238 ++ .../test/crdt-stale-top-level-blocks.test.ts | 704 ++++++ .../test/crdt-table-duplicates-repro.test.ts | 124 ++ .../test/crdt-table-query-identity.test.ts | 211 ++ .../core-data/src/utils/test/crdt-utils.ts | 48 + packages/core-data/src/utils/test/crdt.ts | 898 ++++++-- ...table-duplicate-body-revision-loss.test.ts | 346 +++ .../src/editor/publish-post.ts | 33 +- .../rtc-websocket-provider/src/index.js | 37 + .../compute-selection.ts | 162 +- .../test/compute-selection.ts | 239 ++ packages/editor/src/store/actions.js | 17 + packages/editor/src/store/private-actions.js | 7 +- packages/editor/src/store/test/actions.js | 146 +- packages/sync/CODE.md | 5 +- packages/sync/src/manager.ts | 760 ++++++- .../providers/http-polling/polling-manager.ts | 173 +- .../http-polling/test/polling-manager.test.ts | 276 ++- packages/sync/src/test/manager.ts | 1150 +++++++++- packages/sync/src/test/utils.ts | 31 + packages/sync/src/types.ts | 52 +- packages/sync/src/utils.ts | 132 +- .../persistedCrdtDocumentMeta.php | 115 + .../collaboration/wpHttpPollingSyncServer.php | 132 ++ .../collaboration/wpSyncPostMetaStorage.php | 53 + test/e2e/config/global-setup.ts | 23 +- test/e2e/config/rtc-websocket-setup.ts | 22 +- test/e2e/playwright.rtc-websocket.config.ts | 13 +- .../collaboration-draft-reopens-blank.spec.ts | 206 ++ ...oration-nested-awareness-selection.spec.ts | 377 +++- ...-same-user-stale-content-overwrite.spec.ts | 656 ++++++ .../collaboration-stress.spec.ts | 72 +- .../collaboration-table-duplicates.spec.ts | 292 +++ ...collaboration-table-stale-snapshot.spec.ts | 164 ++ .../fixtures/collaboration-utils.ts | 368 +++- ...ration-same-user-title-reload-loss.spec.ts | 90 + .../collaboration-table-followups.spec.ts | 160 ++ 63 files changed, 15816 insertions(+), 1153 deletions(-) create mode 100644 packages/core-data/src/utils/test/crdt-object-query-stale-snapshot-repro.test.ts create mode 100644 packages/core-data/src/utils/test/crdt-stale-query-array-post.test.ts create mode 100644 packages/core-data/src/utils/test/crdt-stale-query-array.test.ts create mode 100644 packages/core-data/src/utils/test/crdt-stale-top-level-blocks.test.ts create mode 100644 packages/core-data/src/utils/test/crdt-table-duplicates-repro.test.ts create mode 100644 packages/core-data/src/utils/test/crdt-table-query-identity.test.ts create mode 100644 packages/core-data/src/utils/test/rtc-table-duplicate-body-revision-loss.test.ts create mode 100644 packages/editor/src/components/collaborators-overlay/test/compute-selection.ts create mode 100644 phpunit/tests/collaboration/persistedCrdtDocumentMeta.php create mode 100644 test/e2e/specs/editor/collaboration/collaboration-draft-reopens-blank.spec.ts create mode 100644 test/e2e/specs/editor/collaboration/collaboration-same-user-stale-content-overwrite.spec.ts create mode 100644 test/e2e/specs/editor/collaboration/collaboration-table-duplicates.spec.ts create mode 100644 test/e2e/specs/editor/collaboration/collaboration-table-stale-snapshot.spec.ts create mode 100644 test/e2e/specs/editor/collaboration/websocket-only/collaboration-same-user-title-reload-loss.spec.ts create mode 100644 test/e2e/specs/editor/collaboration/websocket-only/collaboration-table-followups.spec.ts diff --git a/docs/reference-guides/data/data-core.md b/docs/reference-guides/data/data-core.md index bd69bebabdd3c2..ea6c01a665c72e 100644 --- a/docs/reference-guides/data/data-core.md +++ b/docs/reference-guides/data/data-core.md @@ -954,6 +954,7 @@ _Parameters_ - _options_ `Object`: Saving options. - _options.isAutosave_ `[boolean]`: Whether this is an autosave. - _options.\_\_unstableFetch_ `[Function]`: Internal use only. Function to call instead of `apiFetch()`. Must return a promise. +- _options.\_\_unstableSkipSyncUpdate_ `[boolean]`: Whether to skip applying the full save response to synced entities. - _options.throwOnError_ `[boolean]`: If false, this action suppresses all the exceptions. Defaults to false. ### undo diff --git a/lib/compat/wordpress-7.1/class-wp-http-polling-sync-server.php b/lib/compat/wordpress-7.1/class-wp-http-polling-sync-server.php index c14a36624c5664..c25a193609e5c9 100644 --- a/lib/compat/wordpress-7.1/class-wp-http-polling-sync-server.php +++ b/lib/compat/wordpress-7.1/class-wp-http-polling-sync-server.php @@ -52,11 +52,27 @@ class WP_HTTP_Polling_Sync_Server { const MAX_BODY_SIZE = 16 * MB_IN_BYTES; /** - * Maximum number of rooms allowed per request. + * Maximum target size (in bytes) of the response body. * * @since 7.0.0 * @var int */ + const MAX_RESPONSE_BODY_SIZE = 16 * MB_IN_BYTES; + + /** + * Per-room headroom for response metadata outside returned update rows. + * + * @since 7.1.0 + * @var int + */ + const RESPONSE_BODY_ROOM_HEADROOM = 8 * 1024; + + /** + * Maximum number of rooms allowed per request. + * + * @since 7.1.0 + * @var int + */ const MAX_ROOMS_PER_REQUEST = 50; /** @@ -320,8 +336,18 @@ public function handle_request( WP_REST_Request $request ) { } } - // Get updates for this client. - $room_response = $this->get_updates( $room, $client_id, $cursor, $is_compactor ); + // Get updates for this client without allowing one bloated + // room to make the whole multi-room response too large. + $empty_room_response = array( + 'awareness' => $merged_awareness, + 'end_cursor' => $cursor, + 'room' => $room, + 'should_compact' => false, + 'total_updates' => 0, + 'updates' => array(), + ); + $max_update_bytes = $this->get_remaining_response_update_bytes( $response, $empty_room_response ); + $room_response = $this->get_updates( $room, $client_id, $cursor, $is_compactor, $max_update_bytes ); $room_response['awareness'] = $merged_awareness; $response['rooms'][] = $room_response; @@ -406,7 +432,7 @@ private function process_sync_update( string $room, int $client_id, int $cursor, * 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 ); + $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor, self::MAX_RESPONSE_BODY_SIZE ); $has_newer_compaction = false; foreach ( $updates_after_cursor as $existing ) { @@ -488,6 +514,28 @@ private function add_update( string $room, int $client_id, string $type, string return true; } + /** + * Calculates the remaining serialized update budget for a room response. + * + * @since 7.1.0 + * + * @param array $response Response built so far. + * @param array $empty_room_response Room response without updates. + * @return int Remaining bytes available for serialized update rows. + */ + private function get_remaining_response_update_bytes( array $response, array $empty_room_response ): int { + $candidate_response = $response; + $candidate_response['rooms'][] = $empty_room_response; + $encoded_response = wp_json_encode( $candidate_response ); + + if ( ! is_string( $encoded_response ) ) { + return 0; + } + + $remaining_bytes = self::MAX_RESPONSE_BODY_SIZE - strlen( $encoded_response ) - self::RESPONSE_BODY_ROOM_HEADROOM; + return max( 0, $remaining_bytes ); + } + /** * Gets sync updates for a specific client from a room after a given cursor. * @@ -499,7 +547,8 @@ private function add_update( string $room, int $client_id, string $type, string * @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. + * @param bool $is_compactor True if this client is nominated to perform compaction. + * @param int $max_update_bytes Maximum serialized update bytes to include. * @return array{ * end_cursor: int, * should_compact: bool, @@ -508,8 +557,8 @@ private function add_update( string $room, int $client_id, string $type, string * updates: array, * } Response data for this room. */ - private function get_updates( string $room, int $client_id, int $cursor, bool $is_compactor ): array { - $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor ); + private function get_updates( string $room, int $client_id, int $cursor, bool $is_compactor, int $max_update_bytes ): array { + $updates_after_cursor = $this->storage->get_updates_after_cursor( $room, $cursor, $max_update_bytes ); $total_updates = $this->storage->get_update_count( $room ); // Filter out this client's updates, except compaction updates. diff --git a/lib/compat/wordpress-7.1/class-wp-sync-post-meta-storage.php b/lib/compat/wordpress-7.1/class-wp-sync-post-meta-storage.php index 26aa912e448b13..7eb5c405d01d6f 100644 --- a/lib/compat/wordpress-7.1/class-wp-sync-post-meta-storage.php +++ b/lib/compat/wordpress-7.1/class-wp-sync-post-meta-storage.php @@ -407,11 +407,12 @@ public function get_update_count( string $room ): int { * * @global wpdb $wpdb WordPress database abstraction object. * - * @param string $room Room identifier. - * @param int $cursor Return updates after this cursor (meta_id). + * @param string $room Room identifier. + * @param int $cursor Return updates after this cursor (meta_id). + * @param int|null $max_update_bytes Optional maximum serialized update bytes to return. * @return array Sync updates. */ - public function get_updates_after_cursor( string $room, int $cursor ): array { + public function get_updates_after_cursor( string $room, int $cursor, ?int $max_update_bytes = null ): array { global $wpdb; $post_id = $this->get_storage_post_id( $room ); @@ -434,34 +435,61 @@ public function get_updates_after_cursor( string $room, int $cursor ): array { $max_meta_id = $stats ? (int) $stats->max_meta_id : 0; $this->room_update_counts[ $room ] = $total_updates; - $this->room_cursors[ $room ] = $max_meta_id; + $this->room_cursors[ $room ] = $cursor; - if ( $max_meta_id <= $cursor ) { + if ( $max_meta_id <= $cursor || 0 === $max_update_bytes ) { return array(); } - $rows = $wpdb->get_results( - $wpdb->prepare( - "SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s AND meta_id > %d AND meta_id <= %d ORDER BY meta_id ASC", - $post_id, - self::SYNC_UPDATE_META_KEY, - $cursor, - $max_meta_id - ) - ); + $updates = array(); + $returned_update_bytes = 0; + $last_scanned_update_meta_id = $cursor; + $storage_update_fetch_page_size = 100; + + while ( $last_scanned_update_meta_id < $max_meta_id ) { + $rows = $wpdb->get_results( + $wpdb->prepare( + "SELECT meta_id, meta_value FROM {$wpdb->postmeta} + WHERE post_id = %d + AND meta_key = %s + AND meta_id > %d + AND meta_id <= %d + ORDER BY meta_id ASC + LIMIT %d", + $post_id, + self::SYNC_UPDATE_META_KEY, + $last_scanned_update_meta_id, + $max_meta_id, + $storage_update_fetch_page_size + ) + ); - if ( ! $rows ) { - return array(); - } + if ( ! $rows ) { + break; + } - $updates = array(); - foreach ( $rows as $row ) { - $decoded = json_decode( $row->meta_value, true ); - if ( null !== $decoded ) { - $updates[] = $decoded; + foreach ( $rows as $row ) { + $update_bytes = strlen( $row->meta_value ); + if ( + null !== $max_update_bytes && + $returned_update_bytes + $update_bytes > $max_update_bytes + ) { + $this->room_cursors[ $room ] = $last_scanned_update_meta_id; + return $updates; + } + + $last_scanned_update_meta_id = (int) $row->meta_id; + $returned_update_bytes += $update_bytes; + + $decoded = json_decode( $row->meta_value, true ); + if ( null !== $decoded ) { + $updates[] = $decoded; + } } } + $this->room_cursors[ $room ] = $last_scanned_update_meta_id; + return $updates; } diff --git a/lib/compat/wordpress-7.1/collaboration.php b/lib/compat/wordpress-7.1/collaboration.php index 6f7337b561e3aa..07356cea482209 100644 --- a/lib/compat/wordpress-7.1/collaboration.php +++ b/lib/compat/wordpress-7.1/collaboration.php @@ -84,11 +84,11 @@ function gutenberg_rest_api_crdt_post_meta() { 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. + * Revisions must be disabled because persisted CRDT documents are + * collaboration snapshots rather than revision fields. Restoring a + * revision invalidates the snapshot below, so the next collaboration + * load rebuilds from the restored raw post fields instead of applying + * a CRDT document from newer content. * * If we want to persist CRDT documents alongside revisions in the * future, we should do so in a separate meta key. @@ -108,6 +108,281 @@ function gutenberg_rest_api_crdt_post_meta() { add_action( 'init', 'gutenberg_rest_api_crdt_post_meta' ); } +if ( ! function_exists( 'gutenberg_delete_crdt_document_meta_on_revision_restore' ) ) { + /** + * Deletes persisted CRDT document meta after restoring an older post revision. + * + * The persisted CRDT document is a snapshot of collaborative state for the + * current post content. If an older revision is restored while a newer CRDT + * snapshot remains in post meta, the next collaborative load can apply that + * newer snapshot and resurrect content that the restore just removed. + * + * @param int $post_id Post ID. + * @param int $_revision_id Revision ID. + */ + function gutenberg_delete_crdt_document_meta_on_revision_restore( int $post_id, int $_revision_id ): void { + unset( $_revision_id ); + delete_post_meta( $post_id, '_crdt_document' ); + } + add_action( 'wp_restore_post_revision', 'gutenberg_delete_crdt_document_meta_on_revision_restore', 10, 2 ); +} + +if ( ! function_exists( 'gutenberg_get_persisted_crdt_document_checksum' ) ) { + /** + * Returns a deterministic checksum for a persisted CRDT document payload. + * + * This checksum mirrors @wordpress/sync and intentionally versions the + * serialized Yjs document payload itself, not the surrounding debugging + * metadata. + * + * @param string $document Base64-encoded Yjs document update. + * @return string Document checksum. + */ + function gutenberg_get_persisted_crdt_document_checksum( string $document ): string { + $hash_a = 0x811c9dc5; + $hash_b = $hash_a ^ 0x9e3779b9; + $prime = 0x01000193; + $length = strlen( $document ); + + for ( $i = 0; $i < $length; $i++ ) { + $char_code = ord( $document[ $i ] ); + $hash_a = ( ( $hash_a ^ $char_code ) * $prime ) & 0xffffffff; + $hash_b = ( ( $hash_b ^ $char_code ^ ( $i & 0xff ) ) * $prime ) & 0xffffffff; + } + + return $length . ':' . sprintf( '%08x%08x', $hash_a, $hash_b ); + } +} + +if ( ! function_exists( 'gutenberg_parse_persisted_crdt_document' ) ) { + /** + * Parses a persisted CRDT document post meta value. + * + * @param mixed $value Post meta value. + * @return array|null Parsed CRDT document metadata, or null when invalid. + */ + function gutenberg_parse_persisted_crdt_document( $value ): ?array { + if ( ! is_string( $value ) || '' === $value ) { + return null; + } + + $decoded = json_decode( $value, true ); + if ( ! is_array( $decoded ) || ! isset( $decoded['document'] ) || ! is_string( $decoded['document'] ) ) { + return null; + } + + return $decoded; + } +} + +if ( ! function_exists( 'gutenberg_get_persisted_crdt_document_version' ) ) { + /** + * Returns the server version represented by a persisted CRDT document value. + * + * @param mixed $value Post meta value. + * @return string|null Version string, or null when the value is invalid. + */ + function gutenberg_get_persisted_crdt_document_version( $value ): ?string { + $decoded = gutenberg_parse_persisted_crdt_document( $value ); + if ( null === $decoded ) { + return null; + } + + return 'document:' . gutenberg_get_persisted_crdt_document_checksum( $decoded['document'] ); + } +} + +if ( ! function_exists( 'gutenberg_get_persisted_crdt_document_base_version' ) ) { + /** + * Returns the base version submitted with a persisted CRDT document value. + * + * @param mixed $value Post meta value. + * @return string|null Base version, or null when missing. + */ + function gutenberg_get_persisted_crdt_document_base_version( $value ): ?string { + $decoded = gutenberg_parse_persisted_crdt_document( $value ); + if ( null === $decoded || empty( $decoded['baseVersion'] ) || ! is_string( $decoded['baseVersion'] ) ) { + return null; + } + + return $decoded['baseVersion']; + } +} + +if ( ! function_exists( 'gutenberg_validate_persisted_crdt_document_base_version' ) ) { + /** + * Validates that an incoming persisted CRDT document is based on the latest + * server copy. + * + * @param int $post_id Post ID. + * @param mixed $meta_value Incoming post meta value. + * @return true|WP_Error True when valid, otherwise an error. + */ + function gutenberg_validate_persisted_crdt_document_base_version( int $post_id, $meta_value ) { + $current_value = get_metadata_raw( + 'post', + $post_id, + '_crdt_document', + true + ); + + if ( ! is_string( $current_value ) || '' === $current_value ) { + return true; + } + + $current_version = gutenberg_get_persisted_crdt_document_version( $current_value ); + if ( null === $current_version ) { + return true; + } + + $incoming_version = gutenberg_get_persisted_crdt_document_version( $meta_value ); + if ( is_string( $incoming_version ) && hash_equals( $current_version, $incoming_version ) ) { + return true; + } + + $base_version = gutenberg_get_persisted_crdt_document_base_version( $meta_value ); + if ( is_string( $base_version ) && hash_equals( $current_version, $base_version ) ) { + return true; + } + + return new WP_Error( + 'rest_crdt_document_stale', + __( 'Could not update the persisted CRDT document because it is stale.', 'gutenberg' ), + array( + 'currentVersion' => $current_version, + 'status' => 409, + ) + ); + } +} + +if ( ! function_exists( 'gutenberg_prevent_stale_crdt_document_meta_update' ) ) { + /** + * Rejects stale persisted CRDT document post meta updates. + * + * @param null|bool $check Whether to short-circuit the update. + * @param int $object_id Post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + * @param mixed $prev_value Previous meta value. + * @return null|bool Whether to short-circuit the update. + */ + function gutenberg_prevent_stale_crdt_document_meta_update( $check, int $object_id, string $meta_key, $meta_value, $prev_value ) { + if ( null !== $check || '_crdt_document' !== $meta_key ) { + return $check; + } + + $result = gutenberg_validate_persisted_crdt_document_base_version( $object_id, $meta_value ); + if ( is_wp_error( $result ) ) { + return false; + } + + return $check; + } + add_filter( 'update_post_metadata', 'gutenberg_prevent_stale_crdt_document_meta_update', 10, 5 ); +} + +if ( ! function_exists( 'gutenberg_prevent_stale_crdt_document_meta_add' ) ) { + /** + * Rejects stale persisted CRDT document post meta additions. + * + * This covers the race where update_metadata() observed no existing meta row, + * but another request added one before add_metadata() runs. + * + * @param null|bool $check Whether to short-circuit the add. + * @param int $object_id Post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + * @param bool $unique Whether only one value may exist. + * @return null|bool Whether to short-circuit the add. + */ + function gutenberg_prevent_stale_crdt_document_meta_add( $check, int $object_id, string $meta_key, $meta_value, bool $unique ) { + if ( null !== $check || '_crdt_document' !== $meta_key ) { + return $check; + } + + $current_value = get_metadata_raw( + 'post', + $object_id, + '_crdt_document', + true + ); + + if ( is_string( $current_value ) && '' !== $current_value ) { + return false; + } + + return $check; + } + add_filter( 'add_post_metadata', 'gutenberg_prevent_stale_crdt_document_meta_add', 10, 5 ); +} + +if ( ! function_exists( 'gutenberg_reject_stale_crdt_document_rest_update' ) ) { + /** + * Rejects stale persisted CRDT document updates before REST post mutations. + * + * @param stdClass $prepared_post Prepared post object. + * @param WP_REST_Request $request Request object. + * @return stdClass|WP_Error Prepared post object or conflict error. + */ + function gutenberg_reject_stale_crdt_document_rest_update( $prepared_post, WP_REST_Request $request ) { + $meta = $request->get_param( 'meta' ); + $meta_key = '_crdt_document'; + + if ( ! is_array( $meta ) || ! array_key_exists( $meta_key, $meta ) ) { + return $prepared_post; + } + + $post_id = isset( $request['id'] ) ? (int) $request['id'] : 0; + if ( ! $post_id && isset( $prepared_post->ID ) ) { + $post_id = (int) $prepared_post->ID; + } + + if ( ! $post_id ) { + return $prepared_post; + } + + $result = gutenberg_validate_persisted_crdt_document_base_version( $post_id, $meta[ $meta_key ] ); + return is_wp_error( $result ) ? $result : $prepared_post; + } +} + +if ( ! function_exists( 'gutenberg_register_crdt_document_rest_conflict_filter' ) ) { + /** + * Registers the REST stale-CRDT guard for a post type. + * + * @param string $post_type Post type name. + * @param WP_Post_Type $post_type_object Post type object. + */ + function gutenberg_register_crdt_document_rest_conflict_filter( string $post_type, $post_type_object = null ): void { + static $registered = array(); + + if ( isset( $registered[ $post_type ] ) ) { + return; + } + + if ( $post_type_object instanceof WP_Post_Type && ! $post_type_object->show_in_rest ) { + return; + } + + $registered[ $post_type ] = true; + add_filter( "rest_pre_insert_{$post_type}", 'gutenberg_reject_stale_crdt_document_rest_update', 10, 2 ); + } + add_action( 'registered_post_type', 'gutenberg_register_crdt_document_rest_conflict_filter', 10, 2 ); +} + +if ( ! function_exists( 'gutenberg_register_crdt_document_rest_conflict_filters' ) ) { + /** + * Registers REST stale-CRDT guards for post types already registered. + */ + function gutenberg_register_crdt_document_rest_conflict_filters(): void { + foreach ( get_post_types( array( 'show_in_rest' => true ), 'objects' ) as $post_type => $post_type_object ) { + gutenberg_register_crdt_document_rest_conflict_filter( $post_type, $post_type_object ); + } + } + add_action( 'init', 'gutenberg_register_crdt_document_rest_conflict_filters', 100 ); +} + if ( ! function_exists( 'wp_collaboration_inject_setting' ) ) { /** * Registers the real-time collaboration setting. diff --git a/lib/compat/wordpress-7.1/interface-wp-sync-storage.php b/lib/compat/wordpress-7.1/interface-wp-sync-storage.php index 9cff51043f9281..3107e3cd918d2b 100644 --- a/lib/compat/wordpress-7.1/interface-wp-sync-storage.php +++ b/lib/compat/wordpress-7.1/interface-wp-sync-storage.php @@ -58,11 +58,12 @@ public function get_update_count( string $room ): int; * * @since 7.0.0 * - * @param string $room Room identifier. - * @param int $cursor Return updates after this cursor. + * @param string $room Room identifier. + * @param int $cursor Return updates after this cursor. + * @param int|null $max_update_bytes Optional maximum serialized update bytes to return. * @return array Sync updates. */ - public function get_updates_after_cursor( string $room, int $cursor ): array; + public function get_updates_after_cursor( string $room, int $cursor, ?int $max_update_bytes = null ): array; /** * Removes updates from a room that are older than the provided cursor. diff --git a/packages/block-editor/src/components/provider/test/use-block-sync.js b/packages/block-editor/src/components/provider/test/use-block-sync.js index 00260264a286d3..6afedc9dfa1261 100644 --- a/packages/block-editor/src/components/provider/test/use-block-sync.js +++ b/packages/block-editor/src/components/provider/test/use-block-sync.js @@ -351,6 +351,81 @@ describe( 'useBlockSync hook', () => { expect( onInput ).not.toHaveBeenCalled(); } ); + it( 'does not treat a local inner block insertion as incoming after a no-op controlled reset', async () => { + const onChange = jest.fn(); + const onInput = jest.fn(); + const replaceInnerBlocks = jest.spyOn( + blockEditorActions, + 'replaceInnerBlocks' + ); + + const value1 = []; + let registry; + const setRegistry = ( reg ) => { + registry = reg; + }; + const { rerender } = render( + + ); + + registry.dispatch( blockEditorStore ).resetBlocks( [ + { + name: 'test/test-block', + clientId: 'test', + innerBlocks: [], + attributes: { foo: 1 }, + }, + ] ); + + onChange.mockClear(); + onInput.mockClear(); + replaceInnerBlocks.mockClear(); + + const value2 = []; + + rerender( + + ); + + expect( replaceInnerBlocks ).toHaveBeenCalledWith( 'test', [] ); + expect( onChange ).not.toHaveBeenCalled(); + expect( onInput ).not.toHaveBeenCalled(); + + registry.dispatch( blockEditorStore ).replaceInnerBlocks( 'test', [ + { + name: 'test/test-block', + clientId: 'inner-a', + innerBlocks: [], + attributes: { foo: 3 }, + }, + ] ); + + expect( onChange ).toHaveBeenCalledWith( + [ + { + name: 'test/test-block', + clientId: 'inner-a', + innerBlocks: [], + attributes: { foo: 3 }, + }, + ], + expect.objectContaining( { selection: expect.any( Object ) } ) + ); + expect( onInput ).not.toHaveBeenCalled(); + } ); + it( 'avoids updating the parent if there is a pending incoming change', async () => { const replaceInnerBlocks = jest.spyOn( blockEditorActions, diff --git a/packages/block-editor/src/components/provider/use-block-sync.js b/packages/block-editor/src/components/provider/use-block-sync.js index e3cd69cac81df5..d1b29f6ae0356c 100644 --- a/packages/block-editor/src/components/provider/use-block-sync.js +++ b/packages/block-editor/src/components/provider/use-block-sync.js @@ -164,6 +164,12 @@ export default function useBlockSync( { const pendingChangesRef = useRef( { incoming: null, outgoing: [] } ); const subscribedRef = useRef( false ); + const clearUnconsumedIncomingChange = ( incomingBlocks ) => { + if ( pendingChangesRef.current.incoming === incomingBlocks ) { + pendingChangesRef.current.incoming = null; + } + }; + // Mapping between external (original) and internal (cloned) client IDs. // This allows stable external IDs while using unique internal IDs. const idMappingRef = useRef( { @@ -260,6 +266,7 @@ export default function useBlockSync( { } __unstableMarkNextChangeAsNotPersistent(); replaceInnerBlocks( clientId, storeBlocks ); + clearUnconsumedIncomingChange( storeBlocks ); // Invalidate the applied-selection ref so that // restoreSelection() at the end of the @@ -273,6 +280,7 @@ export default function useBlockSync( { } __unstableMarkNextChangeAsNotPersistent(); resetBlocks( controlledBlocks ); + clearUnconsumedIncomingChange( controlledBlocks ); } }; diff --git a/packages/block-library/src/table/state.js b/packages/block-library/src/table/state.js index 961e0bd8415a46..7b9f5ba0317610 100644 --- a/packages/block-library/src/table/state.js +++ b/packages/block-library/src/table/state.js @@ -94,6 +94,7 @@ export function updateSelectedCell( state, selection, updateCell ) { } return { + ...row, cells: row.cells.map( ( cellAttributes, columnIndex ) => { const cellLocation = { @@ -248,6 +249,7 @@ export function insertColumn( state, { columnIndex } ) { } return { + ...row, cells: [ ...row.cells.slice( 0, columnIndex ), { @@ -290,6 +292,7 @@ export function deleteColumn( state, { columnIndex } ) { sectionName, section .map( ( row ) => ( { + ...row, cells: row.cells.length >= columnIndex ? row.cells.filter( diff --git a/packages/block-library/src/table/test/state.js b/packages/block-library/src/table/test/state.js index 1daa8e06919606..e62407d7a3d972 100644 --- a/packages/block-library/src/table/test/state.js +++ b/packages/block-library/src/table/test/state.js @@ -466,6 +466,30 @@ describe( 'insertColumn', () => { expect( state ).toEqual( expected ); } ); + it( 'preserves symbol row and cell properties when inserting a column', () => { + const syncId = Symbol( 'syncId' ); + const tableWithSymbolIdentity = deepFreeze( { + body: [ + { + [ syncId ]: 'row-1', + cells: [ + { + [ syncId ]: 'cell-1', + content: 'test', + tag: 'td', + }, + ], + }, + ], + } ); + const state = insertColumn( tableWithSymbolIdentity, { + columnIndex: 0, + } ); + + expect( state.body[ 0 ][ syncId ] ).toBe( 'row-1' ); + expect( state.body[ 0 ].cells[ 1 ][ syncId ] ).toBe( 'cell-1' ); + } ); + it( 'adds `th` cells to the head', () => { const state = insertColumn( tableWithHead, { columnIndex: 1, @@ -774,6 +798,34 @@ describe( 'deleteColumn', () => { expect( state ).toEqual( expected ); } ); + it( 'preserves symbol row and cell properties when deleting a column', () => { + const syncId = Symbol( 'syncId' ); + const tableWithSymbolIdentity = deepFreeze( { + body: [ + { + [ syncId ]: 'row-1', + cells: [ + { + content: 'remove', + tag: 'td', + }, + { + [ syncId ]: 'cell-2', + content: 'keep', + tag: 'td', + }, + ], + }, + ], + } ); + const state = deleteColumn( tableWithSymbolIdentity, { + columnIndex: 0, + } ); + + expect( state.body[ 0 ][ syncId ] ).toBe( 'row-1' ); + expect( state.body[ 0 ].cells[ 0 ][ syncId ] ).toBe( 'cell-2' ); + } ); + it( 'should delete all rows when only one column present', () => { const tableWithOneColumn = { body: [ @@ -1306,6 +1358,73 @@ describe( 'updateSelectedCell', () => { } ); } ); + it( 'preserves unknown row properties when updating a cell', () => { + const tableWithRowIdentity = deepFreeze( { + body: [ + { + __unstableSyncId: 'row-1', + cells: [ + { + content: '', + tag: 'td', + }, + ], + }, + ], + } ); + const cellSelection = { + type: 'cell', + sectionName: 'body', + rowIndex: 0, + columnIndex: 0, + }; + const updated = updateSelectedCell( + tableWithRowIdentity, + cellSelection, + ( cell ) => ( { + ...cell, + content: 'test', + } ) + ); + + expect( updated.body[ 0 ].__unstableSyncId ).toBe( 'row-1' ); + } ); + + it( 'preserves symbol row and cell properties when updating a cell', () => { + const syncId = Symbol( 'syncId' ); + const tableWithSymbolIdentity = deepFreeze( { + body: [ + { + [ syncId ]: 'row-1', + cells: [ + { + [ syncId ]: 'cell-1', + content: '', + tag: 'td', + }, + ], + }, + ], + } ); + const cellSelection = { + type: 'cell', + sectionName: 'body', + rowIndex: 0, + columnIndex: 0, + }; + const updated = updateSelectedCell( + tableWithSymbolIdentity, + cellSelection, + ( cell ) => ( { + ...cell, + content: 'test', + } ) + ); + + expect( updated.body[ 0 ][ syncId ] ).toBe( 'row-1' ); + expect( updated.body[ 0 ].cells[ 0 ][ syncId ] ).toBe( 'cell-1' ); + } ); + it( 'updates every cell in the column when the selection type is `column`', () => { const cellSelection = { type: 'column', columnIndex: 1 }; const updated = updateSelectedCell( diff --git a/packages/core-data/README.md b/packages/core-data/README.md index 3feb3f2f97b996..1002c146687711 100644 --- a/packages/core-data/README.md +++ b/packages/core-data/README.md @@ -327,6 +327,7 @@ _Parameters_ - _options_ `Object`: Saving options. - _options.isAutosave_ `[boolean]`: Whether this is an autosave. - _options.\_\_unstableFetch_ `[Function]`: Internal use only. Function to call instead of `apiFetch()`. Must return a promise. +- _options.\_\_unstableSkipSyncUpdate_ `[boolean]`: Whether to skip applying the full save response to synced entities. - _options.throwOnError_ `[boolean]`: If false, this action suppresses all the exceptions. Defaults to false. ### undo diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 561bb8cb8c676b..2796eaaf6c77a7 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -1,12 +1,14 @@ /** * External dependencies */ +import fastDeepEqual from 'fast-deep-equal/es6/index.js'; import { v4 as uuid } from 'uuid'; /** * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; +import { __unstableSerializeAndClean, parse } from '@wordpress/blocks'; import { addQueryArgs } from '@wordpress/url'; import deprecated from '@wordpress/deprecated'; @@ -29,6 +31,463 @@ function addTitleToAutoDraft( record ) { return record.status === 'auto-draft' ? { ...record, title: '' } : record; } +function isStaleCRDTDocumentError( error ) { + return ( + error?.code === 'rest_crdt_document_stale' && + error?.data?.status === 409 + ); +} + +function hasOwnProperty( object, key ) { + return Object.prototype.hasOwnProperty.call( object ?? {}, key ); +} + +const GUARDED_SAVE_RESPONSE_RAW_ATTRIBUTES = new Set( [ + 'title', + 'excerpt', + 'content', +] ); + +function getGuardedSaveResponseRawAttributes( entityConfig ) { + return ( entityConfig.rawAttributes ?? [] ).filter( ( key ) => + GUARDED_SAVE_RESPONSE_RAW_ATTRIBUTES.has( key ) + ); +} + +function getRawAttributeValue( entityConfig, key, value ) { + return entityConfig.rawAttributes?.includes( key ) && + value && + typeof value === 'object' && + 'raw' in value + ? value.raw + : value; +} + +function getRawAttributeFieldWithValue( value, rawValue ) { + if ( + value && + typeof value === 'object' && + hasOwnProperty( value, 'raw' ) + ) { + return { + ...value, + raw: rawValue, + ...( hasOwnProperty( value, 'rendered' ) + ? { rendered: rawValue } + : {} ), + }; + } + + return rawValue; +} + +function getSerializedCRDTBlockContent( crdtRecord ) { + return Array.isArray( crdtRecord?.blocks ) + ? __unstableSerializeAndClean( crdtRecord.blocks ).trim() + : undefined; +} + +function hasCRDTRawAttributeValue( crdtRecord, key ) { + return key === 'content' + ? hasOwnProperty( crdtRecord, key ) || + Array.isArray( crdtRecord?.blocks ) + : hasOwnProperty( crdtRecord, key ); +} + +function getCRDTRawAttributeValue( entityConfig, key, crdtRecord ) { + if ( key === 'content' ) { + return ( + getSerializedCRDTBlockContent( crdtRecord ) ?? + getRawAttributeValue( entityConfig, key, crdtRecord?.content ) + ); + } + + return getRawAttributeValue( entityConfig, key, crdtRecord?.[ key ] ); +} + +function getPersistedCRDTDocument( record ) { + return record?.meta?._crdt_document; +} + +function parsePersistedCRDTDocumentMetadata( serialized ) { + if ( typeof serialized !== 'string' ) { + return null; + } + + try { + const parsed = JSON.parse( serialized ); + const recordSnapshot = + 'object' === typeof parsed?.recordSnapshot && + null !== parsed.recordSnapshot && + ! Array.isArray( parsed.recordSnapshot ) + ? parsed.recordSnapshot + : null; + + return { + baseVersion: + typeof parsed?.baseVersion === 'string' + ? parsed.baseVersion + : null, + recordSnapshot, + version: + typeof parsed?.version === 'string' ? parsed.version : null, + }; + } catch { + return null; + } +} + +function isSaveResponseForPersistedCRDTDocument( edits, updatedRecord ) { + const editCRDTDocument = getPersistedCRDTDocument( edits ); + + if ( editCRDTDocument === undefined ) { + return false; + } + + const responseCRDTDocument = getPersistedCRDTDocument( updatedRecord ); + + if ( fastDeepEqual( responseCRDTDocument, editCRDTDocument ) ) { + return true; + } + + const editMetadata = parsePersistedCRDTDocumentMetadata( editCRDTDocument ); + const responseMetadata = + parsePersistedCRDTDocumentMetadata( responseCRDTDocument ); + + return !! ( + editMetadata?.version && + responseMetadata?.baseVersion === editMetadata.version + ); +} + +function getRecordWithoutKey( record, key ) { + const nextRecord = { ...record }; + delete nextRecord[ key ]; + return nextRecord; +} + +function getCanonicalSerializedBlockContent( value ) { + if ( typeof value !== 'string' ) { + return; + } + + const blocks = parse( value ); + + if ( ! blocks.length ) { + return; + } + + return __unstableSerializeAndClean( blocks ).trim(); +} + +function areRawAttributeValuesEqual( key, valueA, valueB ) { + if ( fastDeepEqual( valueA, valueB ) ) { + return true; + } + + if ( key !== 'content' ) { + return false; + } + + const canonicalA = getCanonicalSerializedBlockContent( valueA ); + const canonicalB = getCanonicalSerializedBlockContent( valueB ); + const comparableA = + canonicalA ?? + ( typeof valueA === 'string' ? valueA.trim() : undefined ); + const comparableB = + canonicalB ?? + ( typeof valueB === 'string' ? valueB.trim() : undefined ); + + return ( + comparableA !== undefined && + comparableB !== undefined && + comparableA === comparableB + ); +} + +function getComparableBlockTree( blocks ) { + return blocks.map( ( block ) => ( { + attributes: block.attributes ?? {}, + innerBlocks: getComparableBlockTree( block.innerBlocks ?? [] ), + name: block.name, + } ) ); +} + +function doesCRDTBlockContentMatchValue( crdtRecord, value ) { + if ( ! Array.isArray( crdtRecord?.blocks ) || typeof value !== 'string' ) { + return false; + } + + const valueBlocks = parse( value ); + + return ( + valueBlocks.length > 0 && + fastDeepEqual( + getComparableBlockTree( crdtRecord.blocks ), + getComparableBlockTree( valueBlocks ) + ) + ); +} + +function getRecordWithRawAttributeValue( record, key, value ) { + return { + ...record, + [ key ]: getRawAttributeFieldWithValue( record[ key ], value ), + }; +} + +function getRecordWithPersistedCRDTDocument( record, crdtDocument ) { + if ( crdtDocument === undefined ) { + return record; + } + + return { + ...record, + meta: { + ...record.meta, + _crdt_document: crdtDocument, + }, + }; +} + +function getPersistedCRDTDocumentRecordSnapshot( record ) { + return parsePersistedCRDTDocumentMetadata( + getPersistedCRDTDocument( record ) + )?.recordSnapshot; +} + +function getRecordWithoutPersistedCRDTDocumentSnapshotRawAttributes( + entityConfig, + record +) { + const recordSnapshot = getPersistedCRDTDocumentRecordSnapshot( record ); + + if ( ! recordSnapshot ) { + return record; + } + + return getGuardedSaveResponseRawAttributes( entityConfig ).reduce( + ( nextRecord, key ) => + hasOwnProperty( recordSnapshot, key ) + ? getRecordWithoutKey( nextRecord, key ) + : nextRecord, + record + ); +} + +function getGuardedSaveResponseRecords( + entityConfig, + baseRecord, + edits, + updatedRecord, + syncManager, + objectType, + objectId +) { + const defaultRecords = { + receiveRecord: updatedRecord, + syncRecord: updatedRecord, + persistedEdits: edits, + }; + const rawAttributes = getGuardedSaveResponseRawAttributes( entityConfig ); + const crdtRecord = syncManager?.getCRDTRecordData?.( objectType, objectId ); + const isPersistedCRDTDocumentSaveResponse = + isSaveResponseForPersistedCRDTDocument( edits, updatedRecord ); + const responseRecordSnapshot = isPersistedCRDTDocumentSaveResponse + ? getPersistedCRDTDocumentRecordSnapshot( updatedRecord ) + : null; + + if ( + ! rawAttributes.length || + ! updatedRecord || + ( ! crdtRecord && ! responseRecordSnapshot ) + ) { + return defaultRecords; + } + + let receiveRecord = updatedRecord; + let syncRecord = updatedRecord; + let persistedEdits = edits; + const omitPersistedEdit = ( key ) => { + if ( hasOwnProperty( persistedEdits, key ) ) { + persistedEdits = getRecordWithoutKey( persistedEdits, key ); + } + }; + + for ( const key of rawAttributes ) { + const responseSnapshotValue = + responseRecordSnapshot && + hasOwnProperty( responseRecordSnapshot, key ) + ? getRawAttributeValue( + entityConfig, + key, + responseRecordSnapshot[ key ] + ) + : undefined; + const hasResponseSnapshotValue = responseSnapshotValue !== undefined; + const hasCRDTValue = hasCRDTRawAttributeValue( crdtRecord, key ); + + if ( + ! hasOwnProperty( updatedRecord, key ) || + ( ! hasCRDTValue && ! hasResponseSnapshotValue ) + ) { + continue; + } + + const responseValue = getRawAttributeValue( + entityConfig, + key, + updatedRecord[ key ] + ); + const baseValue = getRawAttributeValue( + entityConfig, + key, + baseRecord?.[ key ] + ); + const hasSavedEdit = hasOwnProperty( edits, key ); + const editValue = hasSavedEdit + ? getRawAttributeValue( entityConfig, key, edits[ key ] ) + : undefined; + const persistedValue = hasResponseSnapshotValue + ? responseSnapshotValue + : editValue; + const hasPersistedValue = hasSavedEdit && persistedValue !== undefined; + const crdtValue = getCRDTRawAttributeValue( + entityConfig, + key, + crdtRecord + ); + + const responseIsStaleBaseValue = + areRawAttributeValuesEqual( key, responseValue, baseValue ) && + ( ! hasPersistedValue || + ! areRawAttributeValuesEqual( + key, + persistedValue, + baseValue + ) ); + const responseIsStaleSavedEditValue = + isPersistedCRDTDocumentSaveResponse && + hasSavedEdit && + ! hasResponseSnapshotValue && + areRawAttributeValuesEqual( key, responseValue, editValue ) && + ! areRawAttributeValuesEqual( key, crdtValue, responseValue ); + + if ( ! responseIsStaleBaseValue && ! responseIsStaleSavedEditValue ) { + continue; + } + + if ( responseIsStaleSavedEditValue ) { + receiveRecord = + receiveRecord === updatedRecord + ? getRecordWithoutKey( updatedRecord, key ) + : getRecordWithoutKey( receiveRecord, key ); + syncRecord = + syncRecord === updatedRecord + ? getRecordWithoutKey( updatedRecord, key ) + : getRecordWithoutKey( syncRecord, key ); + omitPersistedEdit( key ); + continue; + } + + const crdtMatchesSavedEdit = + hasPersistedValue && + hasCRDTValue && + ( ( key === 'content' && + doesCRDTBlockContentMatchValue( + crdtRecord, + persistedValue + ) ) || + areRawAttributeValuesEqual( key, crdtValue, persistedValue ) ); + const crdtMatchesStaleResponse = + hasCRDTValue && + areRawAttributeValuesEqual( key, crdtValue, responseValue ); + if ( isPersistedCRDTDocumentSaveResponse && hasPersistedValue ) { + if ( + crdtMatchesSavedEdit || + crdtMatchesStaleResponse || + hasResponseSnapshotValue + ) { + receiveRecord = + receiveRecord === updatedRecord + ? getRecordWithRawAttributeValue( + updatedRecord, + key, + persistedValue + ) + : getRecordWithRawAttributeValue( + receiveRecord, + key, + persistedValue + ); + syncRecord = + syncRecord === updatedRecord + ? getRecordWithRawAttributeValue( + updatedRecord, + key, + persistedValue + ) + : getRecordWithRawAttributeValue( + syncRecord, + key, + persistedValue + ); + if ( hasResponseSnapshotValue ) { + persistedEdits = getRecordWithRawAttributeValue( + persistedEdits, + key, + persistedValue + ); + } + } else { + receiveRecord = + receiveRecord === updatedRecord + ? getRecordWithoutKey( updatedRecord, key ) + : getRecordWithoutKey( receiveRecord, key ); + syncRecord = + syncRecord === updatedRecord + ? getRecordWithoutKey( updatedRecord, key ) + : getRecordWithoutKey( syncRecord, key ); + omitPersistedEdit( key ); + } + receiveRecord = getRecordWithPersistedCRDTDocument( + receiveRecord, + getPersistedCRDTDocument( edits ) + ); + syncRecord = getRecordWithPersistedCRDTDocument( + syncRecord, + getPersistedCRDTDocument( edits ) + ); + } else if ( isPersistedCRDTDocumentSaveResponse && ! hasSavedEdit ) { + receiveRecord = + receiveRecord === updatedRecord + ? getRecordWithoutKey( updatedRecord, key ) + : getRecordWithoutKey( receiveRecord, key ); + syncRecord = + syncRecord === updatedRecord + ? getRecordWithoutKey( updatedRecord, key ) + : getRecordWithoutKey( syncRecord, key ); + receiveRecord = getRecordWithPersistedCRDTDocument( + receiveRecord, + getPersistedCRDTDocument( edits ) + ); + syncRecord = getRecordWithPersistedCRDTDocument( + syncRecord, + getPersistedCRDTDocument( edits ) + ); + } else if ( + ! areRawAttributeValuesEqual( key, crdtValue, responseValue ) + ) { + syncRecord = + syncRecord === updatedRecord + ? getRecordWithoutKey( updatedRecord, key ) + : getRecordWithoutKey( syncRecord, key ); + } + } + + return { receiveRecord, syncRecord, persistedEdits }; +} + /** * Returns an action object used in signalling that authors have been received. * Ignored from documentation as it's internal to the data store. @@ -454,7 +913,7 @@ export const editEntityRecord = objectId, editsWithMerges, origin, - { isNewUndoLevel } + { baseRecord: editedRecord, isNewUndoLevel } ); } if ( ! options.undoIgnore ) { @@ -577,16 +1036,20 @@ export const __unstableCreateUndoLevel = /** * Action triggered to save an entity record. * - * @param {string} kind Kind of the received entity. - * @param {string} name Name of the received entity. - * @param {Object} record Record to be saved. - * @param {Object} options Saving options. - * @param {boolean} [options.isAutosave=false] Whether this is an autosave. - * @param {Function} [options.__unstableFetch] Internal use only. Function to - * call instead of `apiFetch()`. - * Must return a promise. - * @param {boolean} [options.throwOnError=false] If false, this action suppresses all - * the exceptions. Defaults to false. + * @param {string} kind Kind of the received entity. + * @param {string} name Name of the received entity. + * @param {Object} record Record to be saved. + * @param {Object} options Saving options. + * @param {boolean} [options.isAutosave=false] Whether this is an autosave. + * @param {Function} [options.__unstableFetch] Internal use only. Function to + * call instead of `apiFetch()`. + * Must return a promise. + * @param {boolean} [options.__unstableSkipSyncUpdate=false] Whether to skip + * applying the full + * save response to + * synced entities. + * @param {boolean} [options.throwOnError=false] If false, this action suppresses all + * the exceptions. Defaults to false. */ export const saveEntityRecord = ( kind, name, record, options = {} ) => @@ -759,36 +1222,164 @@ export const saveEntityRecord = ); } } else { - let edits = record; - if ( entityConfig.__unstablePrePersist ) { - edits = { - ...edits, - ...( await entityConfig.__unstablePrePersist( - persistedRecord, - edits - ) ), - }; + const prepareEdits = async ( + baseRecord, + recordToPersist + ) => { + let edits = recordToPersist; + if ( entityConfig.__unstablePrePersist ) { + edits = { + ...edits, + ...( await entityConfig.__unstablePrePersist( + baseRecord, + edits, + options + ) ), + }; + } + return edits; + }; + + let edits = await prepareEdits( persistedRecord, record ); + let saveResponseBaseRecord = persistedRecord; + try { + updatedRecord = await __unstableFetch( { + path, + method: recordId ? 'PUT' : 'POST', + data: edits, + } ); + } catch ( _error ) { + const syncManager = getSyncManager(); + if ( + ! recordId || + ! entityConfig.syncConfig || + ! isStaleCRDTDocumentError( _error ) || + ! syncManager?.applyPersistedCRDTDoc + ) { + throw _error; + } + + const latestRecordPath = entityConfig.baseURLParams + ? addQueryArgs( path, entityConfig.baseURLParams ) + : path; + const latestRecord = await __unstableFetch( { + path: latestRecordPath, + } ); + dispatch.receiveEntityRecords( + kind, + name, + latestRecord, + undefined, + true + ); + + await syncManager.applyPersistedCRDTDoc( + `${ kind }/${ name }`, + recordId, + latestRecord + ); + + const mergedRecord = + select.getEditedEntityRecord?.( + kind, + name, + recordId + ) || record; + edits = await prepareEdits( + latestRecord, + mergedRecord + ); + saveResponseBaseRecord = latestRecord; + updatedRecord = await __unstableFetch( { + path, + method: 'PUT', + data: edits, + } ); } - updatedRecord = await __unstableFetch( { - path, - method: recordId ? 'PUT' : 'POST', - data: edits, - } ); + let receiveRecord = updatedRecord; + let syncRecord = updatedRecord; + let persistedEdits = edits; + let syncManager; + const objectType = `${ kind }/${ name }`; + if ( entityConfig.syncConfig ) { + syncManager = getSyncManager(); + ( { receiveRecord, syncRecord, persistedEdits } = + getGuardedSaveResponseRecords( + entityConfig, + saveResponseBaseRecord, + edits, + updatedRecord, + syncManager, + objectType, + recordId + ) ); + } + // CRDT meta persistence saves a partial record, but REST returns + // a full post that can carry stale title/content fields. + if ( __unstableSkipSyncUpdate ) { + receiveRecord = Object.keys( edits ).reduce( + ( acc, key ) => { + if ( key in receiveRecord ) { + acc[ key ] = receiveRecord[ key ]; + } else if ( + receiveRecord === updatedRecord || + ! ( key in updatedRecord ) + ) { + acc[ key ] = edits[ key ]; + } + return acc; + }, + recordId ? { [ entityIdKey ]: recordId } : {} + ); + } + const shouldHydrateFromSavedCRDTDocument = + entityConfig.syncConfig && + ! __unstableSkipSyncUpdate && + recordId && + syncManager?.hydrateRecordFromPersistedCRDTDoc && + isSaveResponseForPersistedCRDTDocument( + edits, + receiveRecord + ); + dispatch.receiveEntityRecords( kind, name, - updatedRecord, + receiveRecord, undefined, true, - edits + persistedEdits ); - if ( entityConfig.syncConfig ) { + if ( shouldHydrateFromSavedCRDTDocument ) { + try { + await syncManager.hydrateRecordFromPersistedCRDTDoc( + objectType, + recordId, + receiveRecord + ); + } catch { + // The save already succeeded; a hydration failure should not + // turn it into an editor-visible save error. + } + } + if ( + entityConfig.syncConfig && + ! __unstableSkipSyncUpdate + ) { + const syncUpdateRecord = + shouldHydrateFromSavedCRDTDocument + ? getRecordWithoutPersistedCRDTDocumentSnapshotRawAttributes( + entityConfig, + syncRecord + ) + : syncRecord; + // Use an untracked origin so that the save // response does not create undo levels. - getSyncManager()?.update( - `${ kind }/${ name }`, + syncManager?.update( + objectType, recordId, - __unstableSkipSyncUpdate ? {} : updatedRecord, + syncUpdateRecord, LOCAL_UNDO_IGNORED_ORIGIN, { isSave: true } ); diff --git a/packages/core-data/src/awareness/block-lookup.ts b/packages/core-data/src/awareness/block-lookup.ts index 01c2fdc5d2fdf0..ea2b2852724224 100644 --- a/packages/core-data/src/awareness/block-lookup.ts +++ b/packages/core-data/src/awareness/block-lookup.ts @@ -41,12 +41,7 @@ export function getContainingBlockYMap( while ( current ) { const parent = current.parent; - if ( - parent instanceof Y.Map && - parent.parent instanceof Y.Array && - parent.get( 'clientId' ) !== undefined && - parent.get( 'innerBlocks' ) instanceof Y.Array - ) { + if ( parent instanceof Y.Map && getBlockPathInYdoc( parent ) ) { return parent; } @@ -93,19 +88,27 @@ export function getBlockPathInYdoc( path.unshift( index ); - // Walk up: is the parent array's parent a block Y.Map or the root? - const grandparent = parentArray.parent; + const owner = parentArray.parent; + if ( ! ( owner instanceof Y.Map ) ) { + return null; + } + + if ( ! owner.parent && owner.get( 'blocks' ) === parentArray ) { + return path; + } + if ( - grandparent instanceof Y.Map && - grandparent.get( 'clientId' ) !== undefined + owner.get( 'innerBlocks' ) === parentArray && + owner.get( 'clientId' ) !== undefined ) { - current = grandparent; // It's a block, keep going. - } else { - break; // It's the root map, done. + current = owner; + continue; } + + return null; } - return path; + return null; } /** diff --git a/packages/core-data/src/awareness/post-editor-awareness.ts b/packages/core-data/src/awareness/post-editor-awareness.ts index af111554eb819b..d567efa710ba68 100644 --- a/packages/core-data/src/awareness/post-editor-awareness.ts +++ b/packages/core-data/src/awareness/post-editor-awareness.ts @@ -22,6 +22,8 @@ import { import { STORE_NAME as coreStore } from '../name'; import { asHtmlStringIndex, + getAttributeKeyForYText, + getYTextByAttributeKey, htmlIndexToRichTextOffset, } from '../utils/crdt-utils'; import { @@ -312,6 +314,36 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > { const localClientId = path ? resolveBlockClientIdByPath( path, blocks ) : null; + const attributes = yType?.get( 'attributes' ); + let attributeKey: string | null = null; + + if ( + attributes instanceof Y.Map && + absolutePosition.type instanceof Y.Text + ) { + attributeKey = getAttributeKeyForYText( + attributes, + absolutePosition.type + ); + + const senderAttributeKey = cursorPos.attributeKey; + if ( + ! attributeKey && + senderAttributeKey && + getYTextByAttributeKey( attributes, senderAttributeKey ) === + absolutePosition.type + ) { + attributeKey = senderAttributeKey; + } + } + + if ( ! localClientId || ! attributeKey ) { + return { + richTextOffset: null, + localClientId: null, + attributeKey: null, + }; + } return { richTextOffset: htmlIndexToRichTextOffset( @@ -319,7 +351,7 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > { asHtmlStringIndex( absolutePosition.index ) ), localClientId, - attributeKey: cursorPos.attributeKey ?? null, + attributeKey, }; } diff --git a/packages/core-data/src/awareness/test/block-lookup.ts b/packages/core-data/src/awareness/test/block-lookup.ts index dce3588b8f00b8..d5140f76f9109b 100644 --- a/packages/core-data/src/awareness/test/block-lookup.ts +++ b/packages/core-data/src/awareness/test/block-lookup.ts @@ -298,6 +298,28 @@ describe( 'getContainingBlockYMap', () => { expect( getContainingBlockYMap( text ) ).toBe( block ); } ); + + it( 'should skip block-shaped nested array items that look like blocks', () => { + const block = createTestYBlock( 'block' ); + const attributes = new Y.Map< any >(); + const cards = new Y.Array< Y.Map< any > >(); + const blockLikeCard = new Y.Map< any >(); + const text = new Y.Text( 'Nested card text' ); + blockLikeCard.set( 'clientId', 'attribute-card-client-id' ); + blockLikeCard.set( 'innerBlocks', new Y.Array() ); + blockLikeCard.set( 'content', text ); + cards.push( [ blockLikeCard ] ); + attributes.set( 'cards', cards ); + block.set( 'attributes', attributes ); + + const ydoc = new Y.Doc(); + const rootMap = ydoc.getMap( 'test' ); + const blocks = new Y.Array< Y.Map< any > >(); + rootMap.set( 'blocks', blocks ); + blocks.push( [ block ] ); + + expect( getContainingBlockYMap( text ) ).toBe( block ); + } ); } ); describe( 'resolveBlockClientIdByPath', () => { diff --git a/packages/core-data/src/awareness/test/post-editor-awareness.ts b/packages/core-data/src/awareness/test/post-editor-awareness.ts index 3ee59cbfe465b6..37f969767c70be 100644 --- a/packages/core-data/src/awareness/test/post-editor-awareness.ts +++ b/packages/core-data/src/awareness/test/post-editor-awareness.ts @@ -722,7 +722,7 @@ describe( 'PostEditorAwareness', () => { expect( result.attributeKey ).toBeNull(); } ); - test( 'should pass through nested attributeKey for a cursor selection', () => { + test( 'should derive the current attributeKey for a cursor selection', () => { const awareness = new PostEditorAwareness( doc, 'postType', @@ -757,7 +757,7 @@ describe( 'PostEditorAwareness', () => { defaultEditorBlocks ); - expect( result.attributeKey ).toBe( 'body.0.cells.0.content' ); + expect( result.attributeKey ).toBe( 'content' ); } ); } ); @@ -1294,6 +1294,85 @@ describe( 'PostEditorAwareness', () => { nestedDoc.destroy(); } ); + + test( 'resolves block-shaped nested array item rich text to the containing block', () => { + const paragraph = createYBlock( 'yjs-paragraph', 'core/paragraph', { + textContent: 'Root paragraph before card block', + } ); + const cardBlock = new Y.Map(); + cardBlock.set( 'clientId', 'yjs-card-list' ); + cardBlock.set( 'name', 'test/card-list' ); + + const attrs = new Y.Map(); + const cards = new Y.Array(); + const card = new Y.Map(); + const cardContent = new Y.Text( 'Nested card cursor target' ); + card.set( 'clientId', 'attribute-card-0' ); + card.set( 'innerBlocks', new Y.Array() ); + card.set( 'content', cardContent ); + cards.push( [ card ] ); + attrs.set( 'cards', cards ); + cardBlock.set( 'attributes', attrs ); + cardBlock.set( 'innerBlocks', new Y.Array() ); + + const nestedDoc = createTestDocWithBlocks( [ + paragraph, + cardBlock, + ] ); + + mockBlockEditorStore( { + blocks: [ + { + clientId: 'local-paragraph', + innerBlocks: [], + }, + { + clientId: 'local-card-list', + innerBlocks: [], + }, + ], + } ); + + const initialOffset = 6; + const relativePosition = Y.createRelativePositionFromTypeIndex( + cardContent, + initialOffset + ); + const awareness = new PostEditorAwareness( + nestedDoc, + 'postType', + 'post', + 123 + ); + const selection: SelectionCursor = { + type: SelectionType.Cursor, + cursorPosition: { + relativePosition, + absoluteOffset: initialOffset, + attributeKey: 'cards.0.content', + }, + }; + + const result = awareness.convertSelectionStateToAbsolute( + selection, + [ + { + clientId: 'local-paragraph', + innerBlocks: [], + }, + { + clientId: 'local-card-list', + innerBlocks: [], + }, + ] + ); + + expect( result.richTextOffset ).toBe( initialOffset ); + expect( result.localClientId ).toBe( 'local-card-list' ); + expect( result.attributeKey ).toBe( 'cards.0.content' ); + + nestedDoc.destroy(); + } ); } ); describe( 'post content blocks resolution', () => { diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index f32e87ece79e42..d9cec08822a1ff 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -9,12 +9,13 @@ import { capitalCase, pascalCase } from 'change-case'; import apiFetch from '@wordpress/api-fetch'; import { __unstableSerializeAndClean, parse } from '@wordpress/blocks'; import { __ } from '@wordpress/i18n'; +import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies */ import { PostEditorAwareness } from './awareness/post-editor-awareness'; -import { getSyncManager } from './sync'; +import { getSyncManager, LOCAL_UNDO_IGNORED_ORIGIN } from './sync'; import { applyPostChangesToCRDTDoc, defaultCollectionSyncConfig, @@ -25,6 +26,270 @@ import { export const DEFAULT_ENTITY_KEY = 'id'; const POST_RAW_ATTRIBUTES = [ 'title', 'excerpt', 'content' ]; +const POST_SNAPSHOT_RAW_ATTRIBUTES = [ 'title', 'excerpt', 'content' ]; +const POST_TYPES_WITH_STALE_SAVE_PROTECTION = new Set( [ 'post', 'page' ] ); + +function getRawPostValue( value ) { + return value && typeof value === 'object' && 'raw' in value + ? value.raw + : value; +} + +function getRawPostSnapshot( record ) { + if ( ! record ) { + return {}; + } + + return Object.fromEntries( + POST_SNAPSHOT_RAW_ATTRIBUTES.filter( ( key ) => key in record ).map( + ( key ) => [ key, getRawPostValue( record[ key ] ) ] + ) + ); +} + +function getRawPostSnapshotForPersistence( baseRecord, ...records ) { + const recordSnapshot = Object.assign( + {}, + ...records.map( getRawPostSnapshot ) + ); + + if ( ! ( 'content' in recordSnapshot ) ) { + return recordSnapshot; + } + + return { + ...getRawPostSnapshot( baseRecord ), + ...recordSnapshot, + }; +} + +function getSerializedBlockValue( block ) { + return __unstableSerializeAndClean( [ block ] ).trim(); +} + +function getSerializedBlockShellValue( block ) { + return getSerializedBlockValue( { + ...block, + innerBlocks: [], + } ); +} + +function areCRDTSnapshotBlocksCompatible( snapshotBlock, crdtBlock ) { + if ( + ! snapshotBlock || + ! crdtBlock || + snapshotBlock.name !== crdtBlock.name + ) { + return false; + } + + try { + if ( + getSerializedBlockShellValue( snapshotBlock ) === + getSerializedBlockShellValue( crdtBlock ) + ) { + return true; + } + } catch {} + + return true; +} + +function reuseCRDTBlockClientIds( snapshotBlocks, crdtBlocks = [] ) { + let searchStart = 0; + + return snapshotBlocks.map( ( snapshotBlock ) => { + let crdtBlock; + for ( let i = searchStart; i < crdtBlocks.length; i++ ) { + if ( + areCRDTSnapshotBlocksCompatible( + snapshotBlock, + crdtBlocks[ i ] + ) + ) { + crdtBlock = crdtBlocks[ i ]; + searchStart = i + 1; + break; + } + } + + return { + ...snapshotBlock, + ...( crdtBlock?.clientId ? { clientId: crdtBlock.clientId } : {} ), + innerBlocks: reuseCRDTBlockClientIds( + snapshotBlock.innerBlocks ?? [], + crdtBlock?.innerBlocks ?? [] + ), + }; + } ); +} + +function getSerializedCRDTBlockContent( crdtRecord ) { + return Array.isArray( crdtRecord?.blocks ) + ? __unstableSerializeAndClean( crdtRecord.blocks ).trim() + : undefined; +} + +function getCRDTRawPostValue( crdtRecord, key ) { + if ( key === 'content' ) { + return ( + getSerializedCRDTBlockContent( crdtRecord ) ?? + getRawPostValue( crdtRecord?.content ) + ); + } + + return getRawPostValue( crdtRecord?.[ key ] ); +} + +function getCRDTSnapshotChangesFromPostEdits( edits, crdtRecord ) { + const changes = {}; + + for ( const key of POST_RAW_ATTRIBUTES ) { + if ( ! ( key in edits ) ) { + continue; + } + + const rawValue = getRawPostValue( edits[ key ] ); + if ( rawValue === undefined ) { + continue; + } + + changes[ key ] = rawValue; + + if ( key === 'content' ) { + const blocks = parse( rawValue ); + changes.blocks = Array.isArray( crdtRecord?.blocks ) + ? reuseCRDTBlockClientIds( blocks, crdtRecord.blocks ) + : blocks; + } + } + + return changes; +} + +function getCRDTSnapshotBaseRecord( record ) { + const content = getRawPostValue( record?.content ); + + if ( typeof content !== 'string' ) { + return record; + } + + return { + ...record, + blocks: parse( content ), + }; +} + +function areSerializedBlocksEqualAt( blocksA, blocksB, index ) { + return ( + blocksA[ index ]?.name === blocksB[ index ]?.name && + getSerializedBlockValue( blocksA[ index ] ) === + getSerializedBlockValue( blocksB[ index ] ) + ); +} + +function mergeStaleSerializedBlockContent( + baseContent, + latestContent, + localContent +) { + if ( + typeof baseContent !== 'string' || + typeof latestContent !== 'string' || + typeof localContent !== 'string' + ) { + return; + } + + const baseBlocks = parse( baseContent ); + const latestBlocks = parse( latestContent ); + const localBlocks = parse( localContent ); + + if ( + ! baseBlocks.length || + ! latestBlocks.length || + ! localBlocks.length + ) { + return; + } + + if ( + latestBlocks.length > localBlocks.length && + baseBlocks.length === latestBlocks.length + ) { + for ( let index = 0; index < localBlocks.length; index++ ) { + if ( localBlocks[ index ].name !== latestBlocks[ index ].name ) { + return; + } + } + + return __unstableSerializeAndClean( [ + ...localBlocks, + ...latestBlocks.slice( localBlocks.length ), + ] ); + } + + if ( + baseBlocks.length < latestBlocks.length && + baseBlocks.length < localBlocks.length + ) { + for ( let index = 0; index < baseBlocks.length; index++ ) { + if ( + ! areSerializedBlocksEqualAt( + baseBlocks, + latestBlocks, + index + ) || + ! areSerializedBlocksEqualAt( baseBlocks, localBlocks, index ) + ) { + return; + } + } + + return __unstableSerializeAndClean( [ + ...localBlocks, + ...latestBlocks.slice( baseBlocks.length ), + ] ); + } + + if ( + baseBlocks.length !== latestBlocks.length || + baseBlocks.length !== localBlocks.length + ) { + return; + } + + const mergedBlocks = []; + + for ( let index = 0; index < baseBlocks.length; index++ ) { + const baseBlock = baseBlocks[ index ]; + const latestBlock = latestBlocks[ index ]; + const localBlock = localBlocks[ index ]; + + if ( + baseBlock.name !== latestBlock.name || + baseBlock.name !== localBlock.name + ) { + return; + } + + const baseValue = getSerializedBlockValue( baseBlock ); + const latestValue = getSerializedBlockValue( latestBlock ); + const localValue = getSerializedBlockValue( localBlock ); + + if ( localValue === latestValue ) { + mergedBlocks.push( localBlock ); + } else if ( localValue === baseValue ) { + mergedBlocks.push( latestBlock ); + } else if ( latestValue === baseValue ) { + mergedBlocks.push( localBlock ); + } else { + return; + } + } + + return __unstableSerializeAndClean( mergedBlocks ); +} const blocksTransientEdits = { blocks: { @@ -44,7 +309,7 @@ export const rootEntitiesConfig = [ baseURL: '/', baseURLParams: { // Please also change the preload path when changing this. - // @see lib/compat/wordpress-7.1/preload.php + // @see lib/compat/wordpress-7.0/preload.php _fields: [ 'description', 'gmt_offset', @@ -275,19 +540,69 @@ export const additionalEntityConfigLoaders = [ /** * Apply extra edits before persisting a post type. * - * @param {Object} persistedRecord Already persisted Post - * @param {Object} edits Edits. - * @param {string} name Post type name. - * @param {boolean} isTemplate Whether the post type is a template. + * @param {Object} persistedRecord Already persisted Post + * @param {Object} edits Edits. + * @param {string} name Post type name. + * @param {boolean} isTemplate Whether the post type is a template. + * @param {string} baseURL REST base URL for the post type. + * @param {Object} options Pre-persist options. + * @param {Object} options.recordSnapshot Current record snapshot to store + * with the CRDT document. + * @param {boolean} options.__unstableIsRevisionRestore Revision restore save. * @return {Promise< Object >} Updated edits. */ export const prePersistPostType = async ( persistedRecord, edits, name, - isTemplate + isTemplate, + baseURL, + options = {} ) => { const newEdits = {}; + const objectType = `postType/${ name }`; + const objectId = persistedRecord?.id; + let syncManager; + let serializedDoc; + let hasSerializedDoc = false; + let latestRecordForCRDTSnapshot; + let latestPersistedCRDTDoc; + const createPersistedCRDTDocOptions = ( + basePersistedCRDTDoc, + baseRecordSnapshot = persistedRecord + ) => { + const baseRawRecordSnapshot = getRawPostSnapshot( baseRecordSnapshot ); + const recordSnapshot = getRawPostSnapshotForPersistence( + baseRecordSnapshot, + options.recordSnapshot, + edits, + newEdits + ); + + return { + basePersistedCRDTDoc, + ...( Object.keys( recordSnapshot ).length + ? { + baseRecordSnapshot: baseRawRecordSnapshot, + recordSnapshot, + } + : {} ), + }; + }; + const editedSavedFields = POST_RAW_ATTRIBUTES.filter( + ( key ) => key in edits + ); + const locallyChangedSavedFields = editedSavedFields.filter( + ( key ) => + getRawPostValue( edits[ key ] ) !== + getRawPostValue( persistedRecord?.[ key ] ) + ); + const locallyChangedSavedFieldSet = new Set( locallyChangedSavedFields ); + const shouldPreserveRevisionRestoreSavedField = ( key ) => + options.__unstableIsRevisionRestore && + locallyChangedSavedFieldSet.has( key ); + const shouldPreserveRevisionRestoreContent = + shouldPreserveRevisionRestoreSavedField( 'content' ); if ( ! isTemplate && persistedRecord?.status === 'auto-draft' ) { // Saving an auto-draft should create a draft by default. @@ -306,17 +621,313 @@ export const prePersistPostType = async ( } } - // Add meta for the persisted CRDT document during real post saves so the - // saved post and CRDT snapshot are committed in the same request. We don't - // want a post save to fail but a CRDT update to succeed or vice versa. - // CRDT repair uses /wp-sync/v1/save to avoid post-save side effects. + if ( + window._wpCollaborationEnabled && + POST_TYPES_WITH_STALE_SAVE_PROTECTION.has( name ) && + baseURL && + objectId && + editedSavedFields.length + ) { + try { + syncManager = getSyncManager(); + serializedDoc = await syncManager?.createPersistedCRDTDoc( + objectType, + objectId, + createPersistedCRDTDocOptions( + persistedRecord?.meta?.[ + POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE + ] || null + ) + ); + hasSerializedDoc = !! serializedDoc; + const latestRecord = await apiFetch( { + path: addQueryArgs( `${ baseURL }/${ objectId }`, { + context: 'edit', + } ), + } ); + latestRecordForCRDTSnapshot = latestRecord; + const serverChangedSavedFields = editedSavedFields.filter( + ( key ) => + getRawPostValue( latestRecord?.[ key ] ) !== + getRawPostValue( persistedRecord?.[ key ] ) + ); + const serverChangedSavedFieldSet = new Set( + serverChangedSavedFields + ); + for ( const key of serverChangedSavedFields ) { + if ( + ! locallyChangedSavedFieldSet.has( key ) && + key in ( latestRecord ?? {} ) + ) { + newEdits[ key ] = getRawPostValue( latestRecord[ key ] ); + } + } + + const hasLatestPersistedCRDTDoc = Boolean( + latestRecord?.meta?.[ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ] + ); + latestPersistedCRDTDoc = + latestRecord?.meta?.[ + POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE + ] || null; + const shouldApplyLatestCRDTDoc = + hasLatestPersistedCRDTDoc || locallyChangedSavedFields.length; + const didApplyLatestCRDTDoc = shouldApplyLatestCRDTDoc + ? ( await syncManager?.applyPersistedCRDTDoc?.( + objectType, + objectId, + latestRecord + ) ) ?? false + : false; + + if ( + didApplyLatestCRDTDoc || + ( hasLatestPersistedCRDTDoc && serverChangedSavedFields.length ) + ) { + serializedDoc = await syncManager?.createPersistedCRDTDoc( + objectType, + objectId, + createPersistedCRDTDocOptions( + latestRecord?.meta?.[ + POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE + ] || null, + latestRecord + ) + ); + hasSerializedDoc = !! serializedDoc; + + if ( + hasLatestPersistedCRDTDoc && + locallyChangedSavedFields.length + ) { + const crdtRecord = syncManager?.getCRDTRecordData?.( + objectType, + objectId + ); + + for ( const key of locallyChangedSavedFields ) { + if ( shouldPreserveRevisionRestoreSavedField( key ) ) { + continue; + } + + const hasCRDTValue = + key === 'content' + ? key in ( crdtRecord ?? {} ) || + Array.isArray( crdtRecord?.blocks ) + : key in ( crdtRecord ?? {} ); + + if ( hasCRDTValue ) { + const crdtValue = getCRDTRawPostValue( + crdtRecord, + key + ); + + const editValue = getRawPostValue( edits[ key ] ); + + if ( + key !== 'content' && + crdtValue !== editValue + ) { + continue; + } + + if ( + crdtValue !== + getRawPostValue( latestRecord?.[ key ] ) + ) { + newEdits[ key ] = crdtValue; + } + } + } + } + } + + if ( + locallyChangedSavedFieldSet.has( 'content' ) && + ! shouldPreserveRevisionRestoreContent && + ! ( 'content' in newEdits ) + ) { + const mergedContent = mergeStaleSerializedBlockContent( + getRawPostValue( persistedRecord?.content ), + getRawPostValue( latestRecord?.content ), + getRawPostValue( edits.content ) + ); + + if ( + mergedContent !== undefined && + mergedContent !== getRawPostValue( edits.content ) + ) { + newEdits.content = mergedContent; + } + } + + const repairableSavedFields = editedSavedFields.filter( ( key ) => { + const shouldRepairSavedFieldFromCRDT = + didApplyLatestCRDTDoc || + serverChangedSavedFieldSet.has( key ); + + return ( + ! ( key in newEdits ) && + shouldRepairSavedFieldFromCRDT && + ( key !== 'content' || + ! serverChangedSavedFieldSet.has( key ) ) + ); + } ); + + if ( hasLatestPersistedCRDTDoc && repairableSavedFields.length ) { + const crdtRecord = syncManager?.getCRDTRecordData?.( + objectType, + objectId + ); + + for ( const key of repairableSavedFields ) { + if ( shouldPreserveRevisionRestoreSavedField( key ) ) { + continue; + } + + const hasCRDTValue = + key === 'content' + ? key in ( crdtRecord ?? {} ) || + Array.isArray( crdtRecord?.blocks ) + : key in ( crdtRecord ?? {} ); + + if ( ! hasCRDTValue ) { + continue; + } + + const crdtValue = getCRDTRawPostValue( crdtRecord, key ); + const editValue = getRawPostValue( edits[ key ] ); + const latestValue = getRawPostValue( + latestRecord?.[ key ] + ); + + if ( + key === 'content' && + crdtValue === '' && + editValue !== '' + ) { + continue; + } + + if ( key !== 'content' && crdtValue !== editValue ) { + continue; + } + + if ( + serverChangedSavedFieldSet.has( key ) && + crdtValue !== latestValue + ) { + continue; + } + + if ( crdtValue !== editValue ) { + newEdits[ key ] = crdtValue; + } + } + } + } catch { + // A failed freshness check should not block saving. The request itself + // will still surface any real save errors to the editor. + } + } + + if ( + window._wpCollaborationEnabled && + POST_TYPES_WITH_STALE_SAVE_PROTECTION.has( name ) && + objectId && + locallyChangedSavedFieldSet.has( 'content' ) && + ! shouldPreserveRevisionRestoreContent && + getRawPostValue( edits.content ) === '' && + ! ( 'content' in newEdits ) + ) { + const crdtRecord = ( + syncManager ?? getSyncManager() + )?.getCRDTRecordData?.( objectType, objectId ); + const crdtContent = getSerializedCRDTBlockContent( crdtRecord ); + + if ( crdtContent ) { + newEdits.content = crdtContent; + } + } + + // Add meta for persisted CRDT document. if ( persistedRecord ) { - const objectType = `postType/${ name }`; - const objectId = persistedRecord.id; - const serializedDoc = getSyncManager()?.createPersistedCRDTDoc( - objectType, - objectId + const snapshotEdits = getRawPostSnapshotForPersistence( + latestRecordForCRDTSnapshot ?? persistedRecord, + options.recordSnapshot, + edits, + newEdits + ); + const snapshotSyncManager = syncManager ?? getSyncManager(); + const hasBasePersistedCRDTDoc = Boolean( + latestPersistedCRDTDoc || + persistedRecord?.meta?.[ + POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE + ] ); + const shouldReuseCRDTBlockClientIds = + snapshotSyncManager?.update && + hasBasePersistedCRDTDoc && + 'content' in snapshotEdits && + getRawPostValue( snapshotEdits.content ) !== undefined; + const currentCRDTRecord = shouldReuseCRDTBlockClientIds + ? snapshotSyncManager?.getCRDTRecordData?.( objectType, objectId ) + : undefined; + const crdtSnapshotChanges = getCRDTSnapshotChangesFromPostEdits( + snapshotEdits, + currentCRDTRecord + ); + if ( Object.keys( crdtSnapshotChanges ).length ) { + const snapshotBaseRecord = Array.isArray( + currentCRDTRecord?.blocks + ) + ? currentCRDTRecord + : getCRDTSnapshotBaseRecord( + latestRecordForCRDTSnapshot ?? persistedRecord + ); + const snapshotUpdateOptions = { + baseRecord: snapshotBaseRecord, + }; + snapshotSyncManager?.update?.( + objectType, + objectId, + crdtSnapshotChanges, + LOCAL_UNDO_IGNORED_ORIGIN, + crdtSnapshotChanges.blocks + ? snapshotUpdateOptions + : { + ...snapshotUpdateOptions, + isSave: true, + } + ); + if ( crdtSnapshotChanges.blocks ) { + snapshotSyncManager?.update?.( + objectType, + objectId, + {}, + LOCAL_UNDO_IGNORED_ORIGIN, + { isSave: true } + ); + } + hasSerializedDoc = false; + } + + if ( ! hasSerializedDoc ) { + serializedDoc = await ( + syncManager ?? getSyncManager() + )?.createPersistedCRDTDoc( + objectType, + objectId, + createPersistedCRDTDocOptions( + latestPersistedCRDTDoc || + persistedRecord?.meta?.[ + POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE + ] || + null, + latestRecordForCRDTSnapshot ?? persistedRecord + ) + ); + } if ( serializedDoc ) { newEdits.meta = { @@ -389,8 +1000,15 @@ async function loadPostTypeEntities() { ( isTemplate ? capitalCase( record.slug ?? '' ) : String( record.id ) ), - __unstablePrePersist: ( persistedRecord, edits ) => - prePersistPostType( persistedRecord, edits, name, isTemplate ), + __unstablePrePersist: ( persistedRecord, edits, options ) => + prePersistPostType( + persistedRecord, + edits, + name, + isTemplate, + `/${ namespace }/${ postType.rest_base }`, + options + ), __unstable_rest_base: postType.rest_base, supportsPagination: true, getRevisionsUrl: ( parentId, revisionId ) => @@ -405,12 +1023,15 @@ async function loadPostTypeEntities() { : DEFAULT_ENTITY_KEY, }; - /** - * @type {import('@wordpress/sync').SyncConfig} - */ - entity.syncConfig = { - // Save a CRDT document with this entity - supportsPersistence: true, + /** + * @type {import('@wordpress/sync').SyncConfig} + */ + entity.syncConfig = { + // Save a CRDT document with this entity. + supportsPersistence: true, + + shouldSync: () => + ! window._wpCollaborationDisabledPostTypes?.includes( name ), /** * Apply changes from the local editor to the local CRDT document so @@ -418,10 +1039,24 @@ async function loadPostTypeEntities() { * * @param {import('@wordpress/sync').CRDTDoc} crdtDoc * @param {Partial< import('@wordpress/sync').ObjectData >} changes + * @param {Object} options * @return {void} */ - applyChangesToCRDTDoc: ( crdtDoc, changes ) => - applyPostChangesToCRDTDoc( crdtDoc, changes, syncedProperties ), + applyChangesToCRDTDoc: ( crdtDoc, changes, options ) => { + if ( options ) { + return applyPostChangesToCRDTDoc( + crdtDoc, + changes, + syncedProperties, + options + ); + } + return applyPostChangesToCRDTDoc( + crdtDoc, + changes, + syncedProperties + ); + }, /** * Create the awareness instance for the entity's CRDT document. @@ -464,11 +1099,6 @@ async function loadPostTypeEntities() { null ); }, - shouldSync: () => - ! ( - Array.isArray( window._wpCollaborationDisabledPostTypes ) && - window._wpCollaborationDisabledPostTypes.includes( name ) - ), }; return entity; diff --git a/packages/core-data/src/test/actions.js b/packages/core-data/src/test/actions.js index 3e63dfa13dee02..0aaf8b5b8cdb9d 100644 --- a/packages/core-data/src/test/actions.js +++ b/packages/core-data/src/test/actions.js @@ -2,6 +2,11 @@ * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; +import { + parse, + registerBlockType, + unregisterBlockType, +} from '@wordpress/blocks'; jest.mock( '@wordpress/api-fetch' ); @@ -33,9 +38,17 @@ jest.mock( '../batch', () => { jest.mock( '../sync', () => ( { getSyncManager: jest.fn(), LOCAL_EDITOR_ORIGIN: 'local-editor', - LOCAL_UNDO_IGNORED_ORIGIN: 'local-undo-ignored', + LOCAL_UNDO_IGNORED_ORIGIN: 'gutenberg-undo-ignored', } ) ); +const TEST_BLOCK_NAME = 'test/save-response-content-block'; + +function blockContent( content ) { + return ``; +} + describe( 'editEntityRecord', () => { it( 'throws when the edited entity does not have a loaded config.', async () => { const entityConfig = { @@ -319,7 +332,16 @@ describe( 'editEntityRecord', () => { }, }, 'local-editor', - { isNewUndoLevel: true } + { + baseRecord: { + id: 1, + meta: { + existingKey: 'existingValue', + editedKey: 'editedValue', + }, + }, + isNewUndoLevel: true, + } ); } ); @@ -361,7 +383,13 @@ describe( 'editEntityRecord', () => { }, }, 'local-editor', - { isNewUndoLevel: true } + { + baseRecord: { + id: 1, + meta: { key1: 'value1' }, + }, + isNewUndoLevel: true, + } ); // But the local store dispatch should still receive undefined for the cleaned edit @@ -417,7 +445,14 @@ describe( 'editEntityRecord', () => { }, }, 'local-editor', - { isNewUndoLevel: true } + { + baseRecord: { + id: 1, + title: 'Original Title', + meta: { existingKey: 'existingValue' }, + }, + isNewUndoLevel: true, + } ); } ); @@ -740,8 +775,27 @@ describe( 'saveEditedEntityRecord', () => { describe( 'saveEntityRecord', () => { let dispatch; + beforeAll( () => { + registerBlockType( TEST_BLOCK_NAME, { + apiVersion: 3, + title: 'Save response content test block', + category: 'text', + attributes: { + content: { + type: 'string', + }, + }, + save: () => null, + } ); + } ); + + afterAll( () => { + unregisterBlockType( TEST_BLOCK_NAME ); + } ); + beforeEach( async () => { apiFetch.mockReset(); + getSyncManager.mockReset(); dispatch = Object.assign( jest.fn(), { receiveEntityRecords: jest.fn(), __unstableAcquireStoreLock: jest.fn(), @@ -923,12 +977,21 @@ describe( 'saveEntityRecord', () => { expect( result ).toBe( updatedRecord ); } ); - it( 'preserves the live sync title when a CRDT persistence save returns stale post fields', async () => { + it( 'receives only saved meta when a CRDT persistence save returns stale post fields', async () => { const liveSyncState = { isSaved: false, title: 'synced title', }; - const post = { id: 10, title: 'synced title' }; + const post = { + id: 10, + title: 'synced title', + content: 'synced content', + meta: { _crdt_document: 'base-doc' }, + }; + const metaSave = { + id: 10, + meta: { _crdt_document: 'next-doc' }, + }; const configs = [ { name: 'post', @@ -956,95 +1019,1685 @@ describe( 'saveEntityRecord', () => { }; const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; - const staleSaveResponse = { ...post, title: 'initial title' }; + const staleSaveResponse = { + ...post, + title: 'initial title', + content: 'initial content', + meta: { _crdt_document: 'next-doc' }, + }; apiFetch.mockImplementation( () => { return staleSaveResponse; } ); getSyncManager.mockReturnValue( syncManager ); + const result = await saveEntityRecord( 'postType', 'post', metaSave, { + __unstableSkipSyncUpdate: true, + } )( { select, dispatch, resolveSelect } ); + + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + { id: 10, meta: { _crdt_document: 'next-doc' } }, + undefined, + true, + metaSave + ); + expect( syncManager.update ).not.toHaveBeenCalled(); + expect( liveSyncState ).toEqual( { + isSaved: false, + title: 'synced title', + } ); + expect( result ).toBe( staleSaveResponse ); + } ); + + it( 'receives guarded CRDT meta when a skipped sync save response is based on the saved document', async () => { + const savedCRDTDocument = JSON.stringify( { + document: 'saved-document', + version: 'document:saved', + } ); + const staleResponseCRDTDocument = JSON.stringify( { + document: 'stale-document', + version: 'document:stale', + baseVersion: 'document:saved', + } ); + const baseContent = blockContent( 'base' ); + const savedContent = blockContent( 'checkpoint content 9' ); + const persistedRecord = { + id: 10, + title: 'checkpoint title 8', + content: { raw: baseContent }, + meta: {}, + }; + const post = { + id: 10, + title: 'checkpoint title 9', + meta: { _crdt_document: savedCRDTDocument }, + }; + const staleSaveResponse = { + id: 10, + title: { + raw: 'checkpoint title 9', + rendered: 'checkpoint title 9', + }, + content: { + raw: baseContent, + rendered: '

base

', + }, + meta: { _crdt_document: staleResponseCRDTDocument }, + }; + const guardedReceiveRecord = { + id: 10, + title: { + raw: 'checkpoint title 9', + rendered: 'checkpoint title 9', + }, + meta: { _crdt_document: savedCRDTDocument }, + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + rawAttributes: [ 'title', 'excerpt', 'content' ], + syncConfig: {}, + }, + ]; + const syncManager = { + getCRDTRecordData: jest.fn( () => ( { + title: 'checkpoint title 9', + blocks: parse( savedContent ), + } ) ), + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + apiFetch.mockImplementation( () => staleSaveResponse ); + getSyncManager.mockReturnValue( syncManager ); + const result = await saveEntityRecord( 'postType', 'post', post, { __unstableSkipSyncUpdate: true, } )( { select, dispatch, resolveSelect } ); + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + guardedReceiveRecord, + undefined, + true, + post + ); + expect( syncManager.update ).not.toHaveBeenCalled(); + expect( result ).toBe( staleSaveResponse ); + } ); + + it( 'preserves the live sync title when a normal save response returns stale post fields', async () => { + const persistedRecord = { + id: 10, + title: 'checkpoint title 8', + content: { raw: 'checkpoint content 8' }, + meta: {}, + }; + const post = { + id: 10, + title: 'checkpoint title 9', + content: 'checkpoint content 9', + meta: { _crdt_document: 'fresh-crdt-doc' }, + }; + const staleSaveResponse = { + id: 10, + title: { + raw: 'checkpoint title 8', + rendered: 'checkpoint title 8', + }, + content: { raw: 'checkpoint content 9' }, + meta: { _crdt_document: 'fresh-crdt-doc' }, + }; + const guardedSaveResponse = { + ...staleSaveResponse, + title: { + raw: 'checkpoint title 9', + rendered: 'checkpoint title 9', + }, + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + rawAttributes: [ 'title', 'excerpt', 'content' ], + syncConfig: {}, + }, + ]; + const syncManager = { + getCRDTRecordData: jest.fn( () => ( { + title: 'checkpoint title 9', + content: 'checkpoint content 9', + } ) ), + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + apiFetch.mockImplementation( () => staleSaveResponse ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + post + )( { select, dispatch, resolveSelect } ); + + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + guardedSaveResponse, + undefined, + true, + post + ); expect( syncManager.update ).toHaveBeenCalledWith( 'postType/post', 10, - {}, - 'local-undo-ignored', + guardedSaveResponse, + 'gutenberg-undo-ignored', { isSave: true } ); - expect( liveSyncState ).toEqual( { - isSaved: true, - title: 'synced title', - } ); expect( result ).toBe( staleSaveResponse ); } ); - it( 'triggers a PUT request for an existing record with a custom key', async () => { - const postType = { slug: 'page', title: 'Pages' }; + it( 'preserves the live sync content when a normal save response returns stale post fields', async () => { + const persistedRecord = { + id: 10, + title: 'checkpoint title 8', + content: { raw: 'checkpoint content 8' }, + meta: {}, + }; + const post = { + id: 10, + title: 'checkpoint title 9', + content: 'checkpoint content 9', + meta: { _crdt_document: 'fresh-crdt-doc' }, + }; + const staleSaveResponse = { + id: 10, + title: { + raw: 'checkpoint title 9', + rendered: 'checkpoint title 9', + }, + content: { + raw: 'checkpoint content 8', + rendered: 'checkpoint content 8', + }, + meta: { _crdt_document: 'fresh-crdt-doc' }, + }; + const guardedSaveResponse = { + ...staleSaveResponse, + content: { + raw: 'checkpoint content 9', + rendered: 'checkpoint content 9', + }, + }; const configs = [ { - name: 'postType', - kind: 'root', - baseURL: '/wp/v2/types', - key: 'slug', + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + rawAttributes: [ 'title', 'excerpt', 'content' ], + syncConfig: {}, }, ]; + const syncManager = { + getCRDTRecordData: jest.fn( () => ( { + title: 'checkpoint title 9', + content: 'checkpoint content 9', + } ) ), + update: jest.fn(), + }; const select = { - getRawEntityRecord: () => ( {} ), + getRawEntityRecord: () => persistedRecord, }; const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; - // Provide response - apiFetch.mockImplementation( () => postType ); + apiFetch.mockImplementation( () => staleSaveResponse ); + getSyncManager.mockReturnValue( syncManager ); const result = await saveEntityRecord( - 'root', 'postType', - postType + 'post', + post )( { select, dispatch, resolveSelect } ); - expect( apiFetch ).toHaveBeenCalledTimes( 1 ); - expect( apiFetch ).toHaveBeenCalledWith( { - path: '/wp/v2/types/page', - method: 'PUT', - data: postType, - } ); - - expect( dispatch ).toHaveBeenCalledTimes( 2 ); - expect( dispatch ).toHaveBeenCalledWith( { - type: 'SAVE_ENTITY_RECORD_START', - kind: 'root', - name: 'postType', - recordId: 'page', - isAutosave: false, - } ); - expect( dispatch.__unstableAcquireStoreLock ).toHaveBeenCalledTimes( - 1 + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + guardedSaveResponse, + undefined, + true, + post ); - expect( dispatch ).toHaveBeenCalledWith( { - type: 'SAVE_ENTITY_RECORD_FINISH', - kind: 'root', - name: 'postType', - recordId: 'page', - error: undefined, - isAutosave: false, - } ); - expect( dispatch.__unstableReleaseStoreLock ).toHaveBeenCalledTimes( - 1 + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + guardedSaveResponse, + 'gutenberg-undo-ignored', + { isSave: true } ); + expect( result ).toBe( staleSaveResponse ); + } ); + + it( 'guards stale normal save response content when CRDT blocks are current', async () => { + const persistedRecord = { + id: 10, + title: 'checkpoint title 8', + content: { raw: blockContent( 'base' ) }, + meta: {}, + }; + const post = { + id: 10, + title: 'checkpoint title 9', + content: blockContent( 'checkpoint content 9' ), + meta: { _crdt_document: 'fresh-crdt-doc' }, + }; + const staleSaveResponse = { + id: 10, + title: { + raw: 'checkpoint title 9', + rendered: 'checkpoint title 9', + }, + content: { + raw: blockContent( 'base' ), + rendered: '

base

', + }, + meta: { _crdt_document: 'fresh-crdt-doc' }, + }; + const guardedSaveResponse = { + ...staleSaveResponse, + title: { + raw: 'checkpoint title 9', + rendered: 'checkpoint title 9', + }, + content: { + raw: blockContent( 'checkpoint content 9' ), + rendered: blockContent( 'checkpoint content 9' ), + }, + meta: { _crdt_document: 'fresh-crdt-doc' }, + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + rawAttributes: [ 'title', 'excerpt', 'content' ], + syncConfig: {}, + }, + ]; + const syncManager = { + getCRDTRecordData: jest.fn( () => ( { + title: 'checkpoint title 9', + blocks: parse( post.content ), + } ) ), + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + apiFetch.mockImplementation( () => staleSaveResponse ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + post + )( { select, dispatch, resolveSelect } ); - expect( dispatch.receiveEntityRecords ).toHaveBeenCalledTimes( 1 ); expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( - 'root', 'postType', - postType, + 'post', + guardedSaveResponse, undefined, true, - { slug: 'page', title: 'Pages' } + post + ); + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + guardedSaveResponse, + 'gutenberg-undo-ignored', + { isSave: true } ); + expect( result ).toBe( staleSaveResponse ); + } ); - expect( result ).toBe( postType ); + it( 'guards stale save response content when the response CRDT document is based on the saved document', async () => { + const savedCRDTDocument = JSON.stringify( { + document: 'saved-document', + version: 'document:saved', + } ); + const staleResponseCRDTDocument = JSON.stringify( { + document: 'stale-document', + version: 'document:stale', + baseVersion: 'document:saved', + } ); + const persistedRecord = { + id: 10, + content: { + raw: blockContent( 'base' ), + }, + meta: {}, + }; + const post = { + id: 10, + content: blockContent( 'checkpoint content 9' ), + meta: { _crdt_document: savedCRDTDocument }, + }; + const staleSaveResponse = { + id: 10, + content: { + raw: blockContent( 'base' ), + rendered: '

base

', + }, + meta: { _crdt_document: staleResponseCRDTDocument }, + }; + const guardedSaveResponse = { + ...staleSaveResponse, + content: { + raw: blockContent( 'checkpoint content 9' ), + rendered: blockContent( 'checkpoint content 9' ), + }, + meta: { _crdt_document: savedCRDTDocument }, + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + rawAttributes: [ 'title', 'excerpt', 'content' ], + syncConfig: {}, + }, + ]; + const syncManager = { + getCRDTRecordData: jest.fn( () => ( { + blocks: parse( post.content ), + } ) ), + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + apiFetch.mockImplementation( () => staleSaveResponse ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + post + )( { select, dispatch, resolveSelect } ); + + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + guardedSaveResponse, + undefined, + true, + post + ); + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + guardedSaveResponse, + 'gutenberg-undo-ignored', + { isSave: true } + ); + expect( result ).toBe( staleSaveResponse ); + } ); + + it( 'guards stale base-version save response content even when live CRDT blocks are stale', async () => { + const savedCRDTDocument = JSON.stringify( { + document: 'saved-document', + version: 'document:saved', + } ); + const staleResponseCRDTDocument = JSON.stringify( { + document: 'stale-document', + version: 'document:stale', + baseVersion: 'document:saved', + } ); + const baseContent = blockContent( 'base' ); + const savedContent = blockContent( 'checkpoint content 9' ); + const persistedRecord = { + id: 10, + content: { + raw: baseContent, + }, + meta: {}, + }; + const post = { + id: 10, + content: savedContent, + meta: { _crdt_document: savedCRDTDocument }, + }; + const staleSaveResponse = { + id: 10, + content: { + raw: baseContent, + rendered: '

base

', + }, + meta: { _crdt_document: staleResponseCRDTDocument }, + }; + const guardedSaveResponse = { + ...staleSaveResponse, + content: { + raw: savedContent, + rendered: savedContent, + }, + meta: { _crdt_document: savedCRDTDocument }, + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + rawAttributes: [ 'title', 'excerpt', 'content' ], + syncConfig: {}, + }, + ]; + const syncManager = { + getCRDTRecordData: jest.fn( () => ( { + blocks: parse( baseContent ), + } ) ), + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + apiFetch.mockImplementation( () => staleSaveResponse ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + post + )( { select, dispatch, resolveSelect } ); + + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + guardedSaveResponse, + undefined, + true, + post + ); + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + guardedSaveResponse, + 'gutenberg-undo-ignored', + { isSave: true } + ); + expect( result ).toBe( staleSaveResponse ); + } ); + + it( 'guards stale base-version save response content using the response record snapshot', async () => { + const baseContent = blockContent( 'base' ); + const savedContent = blockContent( 'checkpoint content 9' ); + const savedCRDTDocument = JSON.stringify( { + document: 'saved-document', + version: 'document:saved', + baseVersion: 'document:base', + recordSnapshot: { + content: savedContent, + }, + } ); + const persistedRecord = { + id: 10, + content: { + raw: baseContent, + }, + meta: {}, + }; + const post = { + id: 10, + content: baseContent, + meta: { _crdt_document: savedCRDTDocument }, + }; + const persistedEdits = { + ...post, + content: savedContent, + }; + const staleSaveResponse = { + id: 10, + content: { + raw: baseContent, + rendered: '

base

', + }, + meta: { _crdt_document: savedCRDTDocument }, + }; + const guardedSaveResponse = { + ...staleSaveResponse, + content: { + raw: savedContent, + rendered: savedContent, + }, + meta: { _crdt_document: savedCRDTDocument }, + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + rawAttributes: [ 'title', 'excerpt', 'content' ], + syncConfig: {}, + }, + ]; + const syncManager = { + getCRDTRecordData: jest.fn( () => undefined ), + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + apiFetch.mockImplementation( () => staleSaveResponse ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + post + )( { select, dispatch, resolveSelect } ); + + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + guardedSaveResponse, + undefined, + true, + persistedEdits + ); + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + guardedSaveResponse, + 'gutenberg-undo-ignored', + { isSave: true } + ); + expect( result ).toBe( staleSaveResponse ); + } ); + + it( 'does not write snapshot raw fields to sync after hydrating a CRDT document save response', async () => { + const baseContent = blockContent( 'base' ); + const savedContent = blockContent( 'checkpoint content 9' ); + const savedCRDTDocument = JSON.stringify( { + document: 'saved-document', + version: 'document:saved', + recordSnapshot: { + content: savedContent, + }, + } ); + const persistedRecord = { + id: 10, + content: { + raw: baseContent, + }, + meta: {}, + }; + const post = { + id: 10, + content: savedContent, + meta: { _crdt_document: savedCRDTDocument }, + }; + const staleSaveResponse = { + id: 10, + content: { + raw: baseContent, + rendered: '

base

', + }, + meta: { _crdt_document: savedCRDTDocument }, + }; + const guardedSaveResponse = { + ...staleSaveResponse, + content: { + raw: savedContent, + rendered: savedContent, + }, + meta: { _crdt_document: savedCRDTDocument }, + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + rawAttributes: [ 'title', 'excerpt', 'content' ], + syncConfig: {}, + }, + ]; + const syncManager = { + hydrateRecordFromPersistedCRDTDoc: jest + .fn() + .mockResolvedValue( true ), + getCRDTRecordData: jest.fn( () => undefined ), + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + apiFetch.mockImplementation( () => staleSaveResponse ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + post + )( { select, dispatch, resolveSelect } ); + + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + guardedSaveResponse, + undefined, + true, + { + ...post, + content: savedContent, + } + ); + expect( + syncManager.hydrateRecordFromPersistedCRDTDoc + ).toHaveBeenCalledWith( 'postType/post', 10, guardedSaveResponse ); + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + { + id: 10, + meta: { _crdt_document: savedCRDTDocument }, + }, + 'gutenberg-undo-ignored', + { isSave: true } + ); + expect( result ).toBe( staleSaveResponse ); + } ); + + it( 'strips stale raw fields absent from a CRDT base-version save response', async () => { + const savedCRDTDocument = JSON.stringify( { + document: 'saved-document', + version: 'document:saved', + } ); + const staleResponseCRDTDocument = JSON.stringify( { + document: 'stale-document', + version: 'document:stale', + baseVersion: 'document:saved', + } ); + const persistedRecord = { + id: 10, + title: 'checkpoint title 8', + content: { raw: blockContent( 'base' ) }, + meta: {}, + }; + const post = { + id: 10, + title: 'checkpoint title 9', + meta: { _crdt_document: savedCRDTDocument }, + }; + const staleSaveResponse = { + id: 10, + title: { + raw: 'checkpoint title 9', + rendered: 'checkpoint title 9', + }, + content: { + raw: blockContent( 'base' ), + rendered: '

base

', + }, + meta: { _crdt_document: staleResponseCRDTDocument }, + }; + const guardedSaveResponse = { + id: 10, + title: { + raw: 'checkpoint title 9', + rendered: 'checkpoint title 9', + }, + meta: { _crdt_document: savedCRDTDocument }, + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + rawAttributes: [ 'title', 'excerpt', 'content' ], + syncConfig: {}, + }, + ]; + const syncManager = { + getCRDTRecordData: jest.fn( () => ( { + title: 'checkpoint title 9', + blocks: parse( blockContent( 'checkpoint content 9' ) ), + } ) ), + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + apiFetch.mockImplementation( () => staleSaveResponse ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + post + )( { select, dispatch, resolveSelect } ); + + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + guardedSaveResponse, + undefined, + true, + post + ); + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + guardedSaveResponse, + 'gutenberg-undo-ignored', + { isSave: true } + ); + expect( result ).toBe( staleSaveResponse ); + } ); + + it( 'does not mark stripped stale raw fields as persisted edits', async () => { + const savedCRDTDocument = JSON.stringify( { + document: 'saved-document', + version: 'document:saved', + } ); + const staleResponseCRDTDocument = JSON.stringify( { + document: 'stale-document', + version: 'document:stale', + baseVersion: 'document:saved', + } ); + const baseContent = blockContent( 'base' ); + const savedContent = blockContent( 'checkpoint content 9' ); + const unrelatedContent = blockContent( 'unrelated live content' ); + const persistedRecord = { + id: 10, + title: 'checkpoint title 8', + content: { raw: baseContent }, + meta: {}, + }; + const post = { + id: 10, + title: 'checkpoint title 9', + content: savedContent, + meta: { _crdt_document: savedCRDTDocument }, + }; + const persistedEdits = { + id: 10, + title: 'checkpoint title 9', + meta: { _crdt_document: savedCRDTDocument }, + }; + const staleSaveResponse = { + id: 10, + title: { + raw: 'checkpoint title 9', + rendered: 'checkpoint title 9', + }, + content: { + raw: baseContent, + rendered: '

base

', + }, + meta: { _crdt_document: staleResponseCRDTDocument }, + }; + const guardedSaveResponse = { + id: 10, + title: { + raw: 'checkpoint title 9', + rendered: 'checkpoint title 9', + }, + meta: { _crdt_document: savedCRDTDocument }, + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + rawAttributes: [ 'title', 'excerpt', 'content' ], + syncConfig: {}, + }, + ]; + const syncManager = { + getCRDTRecordData: jest.fn( () => ( { + title: 'checkpoint title 9', + blocks: parse( unrelatedContent ), + } ) ), + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + apiFetch.mockImplementation( () => staleSaveResponse ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + post + )( { select, dispatch, resolveSelect } ); + + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + guardedSaveResponse, + undefined, + true, + persistedEdits + ); + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + guardedSaveResponse, + 'gutenberg-undo-ignored', + { isSave: true } + ); + expect( result ).toBe( staleSaveResponse ); + } ); + + it( 'keeps raw fields absent from edits when the save response CRDT document is unrelated', async () => { + const persistedRecord = { + id: 10, + content: { raw: blockContent( 'base' ) }, + meta: {}, + }; + const post = { + id: 10, + meta: { _crdt_document: 'saved-crdt-doc' }, + }; + const staleSaveResponse = { + id: 10, + content: { raw: blockContent( 'base' ) }, + meta: { _crdt_document: 'server-different-crdt-doc' }, + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + rawAttributes: [ 'title', 'excerpt', 'content' ], + syncConfig: {}, + }, + ]; + const syncManager = { + getCRDTRecordData: jest.fn( () => ( { + blocks: parse( blockContent( 'base' ) ), + } ) ), + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + apiFetch.mockImplementation( () => staleSaveResponse ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + post + )( { select, dispatch, resolveSelect } ); + + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + staleSaveResponse, + undefined, + true, + post + ); + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + staleSaveResponse, + 'gutenberg-undo-ignored', + { isSave: true } + ); + expect( result ).toBe( staleSaveResponse ); + } ); + + it( 'hydrates after receiving a guarded CRDT document save response', async () => { + const savedCRDTDocument = JSON.stringify( { + document: 'saved-document', + version: 'document:saved', + } ); + const persistedRecord = { + id: 10, + content: { raw: 'checkpoint content 8' }, + meta: {}, + }; + const post = { + id: 10, + content: 'checkpoint content 9', + meta: { _crdt_document: savedCRDTDocument }, + }; + const staleSaveResponse = { + id: 10, + content: { + raw: 'checkpoint content 8', + rendered: 'checkpoint content 8', + }, + meta: { _crdt_document: savedCRDTDocument }, + }; + const guardedSaveResponse = { + id: 10, + meta: { _crdt_document: savedCRDTDocument }, + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + rawAttributes: [ 'title', 'excerpt', 'content' ], + syncConfig: {}, + }, + ]; + const syncManager = { + hydrateRecordFromPersistedCRDTDoc: jest + .fn() + .mockResolvedValue( true ), + getCRDTRecordData: jest.fn( () => ( { + content: 'checkpoint content 10', + } ) ), + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + apiFetch.mockImplementation( () => staleSaveResponse ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + post + )( { select, dispatch, resolveSelect } ); + + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + guardedSaveResponse, + undefined, + true, + { + id: 10, + meta: { _crdt_document: savedCRDTDocument }, + } + ); + expect( + syncManager.hydrateRecordFromPersistedCRDTDoc + ).toHaveBeenCalledWith( 'postType/post', 10, guardedSaveResponse ); + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + guardedSaveResponse, + 'gutenberg-undo-ignored', + { isSave: true } + ); + expect( result ).toBe( staleSaveResponse ); + } ); + + it( 'does not write a stale normal save response title to sync after the live title advances', async () => { + const persistedRecord = { + id: 10, + title: 'checkpoint title 8', + content: { raw: 'checkpoint content 8' }, + meta: {}, + }; + const post = { + id: 10, + title: 'checkpoint title 9', + content: 'checkpoint content 9', + meta: { _crdt_document: 'save-title-9-crdt-doc' }, + }; + const staleSaveResponse = { + id: 10, + title: { + raw: 'checkpoint title 8', + rendered: 'checkpoint title 8', + }, + content: { raw: 'checkpoint content 9' }, + meta: { _crdt_document: 'save-title-9-crdt-doc' }, + }; + const syncSaveResponse = { + id: 10, + content: { raw: 'checkpoint content 9' }, + meta: { _crdt_document: 'save-title-9-crdt-doc' }, + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + rawAttributes: [ 'title', 'excerpt', 'content' ], + syncConfig: {}, + }, + ]; + const syncManager = { + getCRDTRecordData: jest.fn( () => ( { + title: 'checkpoint title 10', + content: 'checkpoint content 9', + } ) ), + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + apiFetch.mockImplementation( () => staleSaveResponse ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + post + )( { select, dispatch, resolveSelect } ); + + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + syncSaveResponse, + undefined, + true, + { + id: 10, + content: 'checkpoint content 9', + meta: { _crdt_document: 'save-title-9-crdt-doc' }, + } + ); + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + syncSaveResponse, + 'gutenberg-undo-ignored', + { isSave: true } + ); + expect( result ).toBe( staleSaveResponse ); + } ); + + it( 'does not write a stale normal save response content to sync after the live content advances', async () => { + const persistedRecord = { + id: 10, + title: 'checkpoint title 8', + content: { raw: 'checkpoint content 8' }, + meta: {}, + }; + const post = { + id: 10, + title: 'checkpoint title 9', + content: 'checkpoint content 9', + meta: { _crdt_document: 'save-content-9-crdt-doc' }, + }; + const staleSaveResponse = { + id: 10, + title: { + raw: 'checkpoint title 9', + rendered: 'checkpoint title 9', + }, + content: { + raw: 'checkpoint content 8', + rendered: 'checkpoint content 8', + }, + meta: { _crdt_document: 'save-content-9-crdt-doc' }, + }; + const syncSaveResponse = { + id: 10, + title: { + raw: 'checkpoint title 9', + rendered: 'checkpoint title 9', + }, + meta: { _crdt_document: 'save-content-9-crdt-doc' }, + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + rawAttributes: [ 'title', 'excerpt', 'content' ], + syncConfig: {}, + }, + ]; + const syncManager = { + getCRDTRecordData: jest.fn( () => ( { + title: 'checkpoint title 9', + content: 'checkpoint content 10', + } ) ), + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + apiFetch.mockImplementation( () => staleSaveResponse ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + post + )( { select, dispatch, resolveSelect } ); + + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + syncSaveResponse, + undefined, + true, + { + id: 10, + title: 'checkpoint title 9', + meta: { _crdt_document: 'save-content-9-crdt-doc' }, + } + ); + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + syncSaveResponse, + 'gutenberg-undo-ignored', + { isSave: true } + ); + expect( result ).toBe( staleSaveResponse ); + } ); + + it( 'drops a stale normal save response saved edit content after the live CRDT advances', async () => { + const persistedRecord = { + id: 10, + title: 'checkpoint title 8', + content: { raw: 'checkpoint content 8' }, + meta: {}, + }; + const post = { + id: 10, + title: 'checkpoint title 9', + content: 'checkpoint content 9', + meta: { _crdt_document: 'save-content-9-crdt-doc' }, + }; + const staleSaveResponse = { + id: 10, + title: { + raw: 'checkpoint title 9', + rendered: 'checkpoint title 9', + }, + content: { + raw: 'checkpoint content 9', + rendered: 'checkpoint content 9', + }, + meta: { _crdt_document: 'save-content-9-crdt-doc' }, + }; + const guardedSaveResponse = { + id: 10, + title: { + raw: 'checkpoint title 9', + rendered: 'checkpoint title 9', + }, + meta: { _crdt_document: 'save-content-9-crdt-doc' }, + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + rawAttributes: [ 'title', 'excerpt', 'content' ], + syncConfig: {}, + }, + ]; + const syncManager = { + getCRDTRecordData: jest.fn( () => ( { + title: 'checkpoint title 9', + content: 'checkpoint content 10', + } ) ), + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + apiFetch.mockImplementation( () => staleSaveResponse ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + post + )( { select, dispatch, resolveSelect } ); + + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + guardedSaveResponse, + undefined, + true, + { + id: 10, + title: 'checkpoint title 9', + meta: { _crdt_document: 'save-content-9-crdt-doc' }, + } + ); + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + guardedSaveResponse, + 'gutenberg-undo-ignored', + { isSave: true } + ); + expect( result ).toBe( staleSaveResponse ); + } ); + + it( 'does not rewrite a stale normal save response title without outgoing CRDT document evidence', async () => { + const persistedRecord = { + id: 10, + title: 'checkpoint title 8', + meta: {}, + }; + const post = { + id: 10, + title: 'checkpoint title 9', + meta: {}, + }; + const staleSaveResponse = { + id: 10, + title: { + raw: 'checkpoint title 8', + rendered: 'checkpoint title 8', + }, + meta: {}, + }; + const syncSaveResponse = { + id: 10, + meta: {}, + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + rawAttributes: [ 'title', 'excerpt', 'content' ], + syncConfig: {}, + }, + ]; + const syncManager = { + getCRDTRecordData: jest.fn( () => ( { + title: 'checkpoint title 9', + } ) ), + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + apiFetch.mockImplementation( () => staleSaveResponse ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + post + )( { select, dispatch, resolveSelect } ); + + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + staleSaveResponse, + undefined, + true, + post + ); + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + syncSaveResponse, + 'gutenberg-undo-ignored', + { isSave: true } + ); + expect( result ).toBe( staleSaveResponse ); + } ); + + it( 'does not rewrite a stale normal save response title for a different CRDT document', async () => { + const persistedRecord = { + id: 10, + title: 'checkpoint title 8', + meta: {}, + }; + const post = { + id: 10, + title: 'checkpoint title 9', + meta: { _crdt_document: 'save-title-9-crdt-doc' }, + }; + const staleSaveResponse = { + id: 10, + title: { + raw: 'checkpoint title 8', + rendered: 'checkpoint title 8', + }, + meta: { _crdt_document: 'server-different-crdt-doc' }, + }; + const syncSaveResponse = { + id: 10, + meta: { _crdt_document: 'server-different-crdt-doc' }, + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + rawAttributes: [ 'title', 'excerpt', 'content' ], + syncConfig: {}, + }, + ]; + const syncManager = { + getCRDTRecordData: jest.fn( () => ( { + title: 'checkpoint title 9', + } ) ), + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + apiFetch.mockImplementation( () => staleSaveResponse ); + getSyncManager.mockReturnValue( syncManager ); + + const result = await saveEntityRecord( + 'postType', + 'post', + post + )( { select, dispatch, resolveSelect } ); + + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + staleSaveResponse, + undefined, + true, + post + ); + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + syncSaveResponse, + 'gutenberg-undo-ignored', + { isSave: true } + ); + expect( result ).toBe( staleSaveResponse ); + } ); + + it( 'keeps a normal save response title that changed from the save base', async () => { + const persistedRecord = { + id: 10, + title: 'draft title', + meta: {}, + }; + const post = { + id: 10, + title: 'local title', + meta: { _crdt_document: 'fresh-crdt-doc' }, + }; + const serverChangedSaveResponse = { + id: 10, + title: 'server title', + meta: { _crdt_document: 'fresh-crdt-doc' }, + }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + rawAttributes: [ 'title', 'excerpt', 'content' ], + syncConfig: {}, + }, + ]; + const syncManager = { + getCRDTRecordData: jest.fn( () => ( { + title: 'local title', + } ) ), + update: jest.fn(), + }; + const select = { + getRawEntityRecord: () => persistedRecord, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + apiFetch.mockImplementation( () => serverChangedSaveResponse ); + getSyncManager.mockReturnValue( syncManager ); + + await saveEntityRecord( + 'postType', + 'post', + post + )( { + select, + dispatch, + resolveSelect, + } ); + + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'postType', + 'post', + serverChangedSaveResponse, + undefined, + true, + post + ); + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + serverChangedSaveResponse, + 'gutenberg-undo-ignored', + { isSave: true } + ); + } ); + + it( 'triggers a PUT request for an existing record with a custom key', async () => { + const postType = { slug: 'page', title: 'Pages' }; + const configs = [ + { + name: 'postType', + kind: 'root', + baseURL: '/wp/v2/types', + key: 'slug', + }, + ]; + const select = { + getRawEntityRecord: () => ( {} ), + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + // Provide response + apiFetch.mockImplementation( () => postType ); + + const result = await saveEntityRecord( + 'root', + 'postType', + postType + )( { select, dispatch, resolveSelect } ); + + expect( apiFetch ).toHaveBeenCalledTimes( 1 ); + expect( apiFetch ).toHaveBeenCalledWith( { + path: '/wp/v2/types/page', + method: 'PUT', + data: postType, + } ); + + expect( dispatch ).toHaveBeenCalledTimes( 2 ); + expect( dispatch ).toHaveBeenCalledWith( { + type: 'SAVE_ENTITY_RECORD_START', + kind: 'root', + name: 'postType', + recordId: 'page', + isAutosave: false, + } ); + expect( dispatch.__unstableAcquireStoreLock ).toHaveBeenCalledTimes( + 1 + ); + expect( dispatch ).toHaveBeenCalledWith( { + type: 'SAVE_ENTITY_RECORD_FINISH', + kind: 'root', + name: 'postType', + recordId: 'page', + error: undefined, + isAutosave: false, + } ); + expect( dispatch.__unstableReleaseStoreLock ).toHaveBeenCalledTimes( + 1 + ); + + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledTimes( 1 ); + expect( dispatch.receiveEntityRecords ).toHaveBeenCalledWith( + 'root', + 'postType', + postType, + undefined, + true, + { slug: 'page', title: 'Pages' } + ); + expect( getSyncManager ).not.toHaveBeenCalled(); + + expect( result ).toBe( postType ); + } ); + + it( 'refetches, merges, and retries when persisted CRDT document meta is stale', async () => { + const staleError = { + code: 'rest_crdt_document_stale', + data: { status: 409 }, + }; + const post = { id: 10, title: 'local title', meta: {} }; + const latestRecord = { + id: 10, + title: 'server title', + meta: { _crdt_document: 'server-crdt-doc' }, + }; + const mergedRecord = { + id: 10, + title: 'merged title', + meta: {}, + }; + const updatedRecord = { + id: 10, + title: 'merged title', + meta: { _crdt_document: 'fresh-crdt-doc' }, + }; + const prePersist = jest + .fn() + .mockResolvedValueOnce( { + meta: { _crdt_document: 'stale-crdt-doc' }, + } ) + .mockResolvedValueOnce( { + meta: { _crdt_document: 'fresh-crdt-doc' }, + } ); + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + baseURLParams: { context: 'edit' }, + syncConfig: {}, + __unstablePrePersist: prePersist, + }, + ]; + const select = { + getRawEntityRecord: jest.fn( () => post ), + getEditedEntityRecord: jest.fn( () => mergedRecord ), + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + const syncManager = { + applyPersistedCRDTDoc: jest.fn(), + update: jest.fn(), + }; + getSyncManager.mockReturnValue( syncManager ); + apiFetch + .mockRejectedValueOnce( staleError ) + .mockResolvedValueOnce( latestRecord ) + .mockResolvedValueOnce( updatedRecord ); + + const result = await saveEntityRecord( + 'postType', + 'post', + post + )( { select, dispatch, resolveSelect } ); + + expect( apiFetch ).toHaveBeenCalledTimes( 3 ); + expect( apiFetch ).toHaveBeenNthCalledWith( 1, { + path: '/wp/v2/posts/10', + method: 'PUT', + data: { + ...post, + meta: { _crdt_document: 'stale-crdt-doc' }, + }, + } ); + expect( apiFetch ).toHaveBeenNthCalledWith( 2, { + path: '/wp/v2/posts/10?context=edit', + } ); + expect( apiFetch ).toHaveBeenNthCalledWith( 3, { + path: '/wp/v2/posts/10', + method: 'PUT', + data: { + ...mergedRecord, + meta: { _crdt_document: 'fresh-crdt-doc' }, + }, + } ); + expect( dispatch.receiveEntityRecords ).toHaveBeenNthCalledWith( + 1, + 'postType', + 'post', + latestRecord, + undefined, + true + ); + expect( syncManager.applyPersistedCRDTDoc ).toHaveBeenCalledWith( + 'postType/post', + 10, + latestRecord + ); + expect( dispatch.receiveEntityRecords ).toHaveBeenNthCalledWith( + 2, + 'postType', + 'post', + updatedRecord, + undefined, + true, + { + ...mergedRecord, + meta: { _crdt_document: 'fresh-crdt-doc' }, + } + ); + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/post', + 10, + updatedRecord, + 'gutenberg-undo-ignored', + { isSave: true } + ); + expect( result ).toBe( updatedRecord ); } ); } ); diff --git a/packages/core-data/src/test/entities.js b/packages/core-data/src/test/entities.js index 0f59bcabda198c..b349a3f30de319 100644 --- a/packages/core-data/src/test/entities.js +++ b/packages/core-data/src/test/entities.js @@ -2,6 +2,11 @@ * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; +import { + parse, + registerBlockType, + unregisterBlockType, +} from '@wordpress/blocks'; jest.mock( '@wordpress/api-fetch' ); jest.mock( '../sync', () => ( { @@ -28,6 +33,18 @@ import { POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE, } from '../utils/crdt'; +const TEST_BLOCK_NAME = 'test/stale-content-block'; + +function paragraphMarkup( content ) { + return ``; +} + +function pageContent( contents ) { + return contents.map( paragraphMarkup ).join( '\n\n' ); +} + describe( 'getMethodName', () => { it( 'should return the right method name for an entity with the root kind', () => { const methodName = getMethodName( 'root', 'postType' ); @@ -58,6 +75,36 @@ describe( 'getMethodName', () => { } ); describe( 'prePersistPostType', () => { + let originalCollaborationEnabled; + + beforeAll( () => { + registerBlockType( TEST_BLOCK_NAME, { + apiVersion: 3, + title: 'Stale content test block', + category: 'text', + attributes: { + content: { + type: 'string', + }, + }, + save: () => null, + } ); + } ); + + afterAll( () => { + unregisterBlockType( TEST_BLOCK_NAME ); + } ); + + beforeEach( () => { + apiFetch.mockReset(); + getSyncManager.mockReset(); + originalCollaborationEnabled = window._wpCollaborationEnabled; + } ); + + afterEach( () => { + window._wpCollaborationEnabled = originalCollaborationEnabled; + } ); + it( 'set the status to draft and empty the title when saving auto-draft posts', async () => { let record = { status: 'auto-draft', @@ -116,7 +163,15 @@ describe( 'prePersistPostType', () => { .mockReturnValue( mockSerializedDoc ), } ); - const record = { id: 123, status: 'publish' }; + const basePersistedCRDTDoc = 'base-crdt-doc-data'; + const record = { + id: 123, + status: 'publish', + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: + basePersistedCRDTDoc, + }, + }; const edits = {}; const result = await prePersistPostType( record, edits, 'post', false ); @@ -127,11 +182,1087 @@ describe( 'prePersistPostType', () => { expect( getSyncManager ).toHaveBeenCalled(); expect( getSyncManager().createPersistedCRDTDoc ).toHaveBeenCalledWith( 'postType/post', - 123 + 123, + { basePersistedCRDTDoc } ); getSyncManager.mockReset(); } ); + + it( 'snapshots saved content into the CRDT before serializing from the latest persisted document', async () => { + const baseContent = pageContent( [ 'Alpha' ] ); + const savedContent = pageContent( [ 'Alpha', 'checkpoint paragraph' ] ); + const latestRecord = { + id: 123, + content: { raw: baseContent }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'latest-doc', + }, + }; + const syncManager = { + applyPersistedCRDTDoc: jest.fn().mockResolvedValue( false ), + createPersistedCRDTDoc: jest + .fn() + .mockResolvedValueOnce( 'stale-before-snapshot-doc' ) + .mockResolvedValueOnce( 'snapshot-doc' ), + getCRDTRecordData: jest.fn( () => ( { + content: baseContent, + } ) ), + update: jest.fn(), + }; + apiFetch.mockResolvedValue( latestRecord ); + getSyncManager.mockReturnValue( syncManager ); + window._wpCollaborationEnabled = true; + + const result = await prePersistPostType( + { + id: 123, + status: 'publish', + content: { raw: baseContent }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'base-doc', + }, + }, + { content: savedContent }, + 'page', + false, + '/wp/v2/pages' + ); + + expect( syncManager.update ).toHaveBeenNthCalledWith( + 1, + 'postType/page', + 123, + expect.objectContaining( { + content: savedContent, + blocks: expect.any( Array ), + } ), + 'gutenberg-undo-ignored', + { + baseRecord: expect.objectContaining( { + ...latestRecord, + blocks: expect.any( Array ), + } ), + } + ); + expect( syncManager.update ).toHaveBeenNthCalledWith( + 2, + 'postType/page', + 123, + {}, + 'gutenberg-undo-ignored', + { isSave: true } + ); + expect( + syncManager.update.mock.calls[ 0 ][ 2 ].blocks.map( + ( block ) => block.attributes.content + ) + ).toEqual( [ 'Alpha', 'checkpoint paragraph' ] ); + expect( + syncManager.update.mock.calls[ 0 ][ 4 ].baseRecord.blocks.map( + ( block ) => block.attributes.content + ) + ).toEqual( [ 'Alpha' ] ); + expect( syncManager.createPersistedCRDTDoc ).toHaveBeenLastCalledWith( + 'postType/page', + 123, + { + basePersistedCRDTDoc: 'latest-doc', + baseRecordSnapshot: { content: baseContent }, + recordSnapshot: { content: savedContent }, + } + ); + expect( result ).toEqual( { + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'snapshot-doc', + }, + } ); + } ); + + it( 'reuses current CRDT block client IDs when snapshotting saved content', async () => { + const baseContent = pageContent( [ 'Alpha' ] ); + const savedContent = pageContent( [ 'Alpha', 'checkpoint paragraph' ] ); + const currentBlocks = parse( savedContent ); + const latestRecord = { + id: 123, + content: { raw: baseContent }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'latest-doc', + }, + }; + const syncManager = { + applyPersistedCRDTDoc: jest.fn().mockResolvedValue( false ), + createPersistedCRDTDoc: jest + .fn() + .mockResolvedValueOnce( 'stale-before-snapshot-doc' ) + .mockResolvedValueOnce( 'snapshot-doc' ), + getCRDTRecordData: jest.fn( () => ( { + blocks: currentBlocks, + content: savedContent, + } ) ), + update: jest.fn(), + }; + apiFetch.mockResolvedValue( latestRecord ); + getSyncManager.mockReturnValue( syncManager ); + window._wpCollaborationEnabled = true; + + await prePersistPostType( + { + id: 123, + status: 'publish', + content: { raw: baseContent }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'base-doc', + }, + }, + { content: savedContent }, + 'page', + false, + '/wp/v2/pages' + ); + + expect( + syncManager.update.mock.calls[ 0 ][ 2 ].blocks.map( + ( block ) => block.clientId + ) + ).toEqual( currentBlocks.map( ( block ) => block.clientId ) ); + expect( syncManager.update.mock.calls[ 0 ][ 4 ].baseRecord ).toEqual( + expect.objectContaining( { + blocks: currentBlocks, + } ) + ); + expect( syncManager.update ).toHaveBeenNthCalledWith( + 2, + 'postType/page', + 123, + {}, + 'gutenberg-undo-ignored', + { isSave: true } + ); + } ); + + it( 'snapshots recordSnapshot content into the CRDT even when content is not part of the save edits', async () => { + const baseContent = pageContent( [ 'Base content' ] ); + const staleContent = pageContent( [ + 'Live content', + 'Duplicate stale content', + ] ); + const savedContent = pageContent( [ 'Live content' ] ); + const staleBlocks = parse( staleContent ); + const syncManager = { + createPersistedCRDTDoc: jest + .fn() + .mockResolvedValue( 'snapshot-doc' ), + getCRDTRecordData: jest.fn( () => ( { + blocks: staleBlocks, + content: staleContent, + } ) ), + update: jest.fn(), + }; + getSyncManager.mockReturnValue( syncManager ); + + await prePersistPostType( + { + id: 123, + status: 'publish', + content: { raw: baseContent }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'base-doc', + }, + }, + { + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'base-doc', + }, + }, + 'page', + false, + '/wp/v2/pages', + { + recordSnapshot: { content: savedContent }, + } + ); + + expect( syncManager.update ).toHaveBeenNthCalledWith( + 1, + 'postType/page', + 123, + expect.objectContaining( { + content: savedContent, + blocks: expect.any( Array ), + } ), + 'gutenberg-undo-ignored', + { baseRecord: expect.objectContaining( { blocks: staleBlocks } ) } + ); + expect( + syncManager.update.mock.calls[ 0 ][ 2 ].blocks.map( + ( block ) => block.attributes.content + ) + ).toEqual( [ 'Live content' ] ); + expect( syncManager.update ).toHaveBeenNthCalledWith( + 2, + 'postType/page', + 123, + {}, + 'gutenberg-undo-ignored', + { isSave: true } + ); + } ); + + it( 'passes title, excerpt, and content snapshots when serializing a persisted CRDT document', async () => { + const syncManager = { + createPersistedCRDTDoc: jest + .fn() + .mockResolvedValue( 'snapshot-doc' ), + }; + getSyncManager.mockReturnValue( syncManager ); + + const result = await prePersistPostType( + { + id: 123, + status: 'publish', + title: { raw: 'Base title' }, + excerpt: { raw: 'Base excerpt' }, + content: { raw: pageContent( [ 'Base content' ] ) }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'base-doc', + }, + }, + { + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'base-doc', + }, + }, + 'post', + false, + '/wp/v2/posts', + { + recordSnapshot: { + title: 'Live title', + excerpt: { raw: 'Live excerpt' }, + content: pageContent( [ 'Live content' ] ), + }, + } + ); + + expect( syncManager.createPersistedCRDTDoc ).toHaveBeenCalledWith( + 'postType/post', + 123, + { + basePersistedCRDTDoc: 'base-doc', + baseRecordSnapshot: { + content: pageContent( [ 'Base content' ] ), + excerpt: 'Base excerpt', + title: 'Base title', + }, + recordSnapshot: { + content: pageContent( [ 'Live content' ] ), + excerpt: 'Live excerpt', + title: 'Live title', + }, + } + ); + expect( result ).toEqual( { + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'snapshot-doc', + }, + } ); + } ); + + it( 'merges save edits into persisted CRDT document metadata when recordSnapshot is partial', async () => { + const syncManager = { + createPersistedCRDTDoc: jest + .fn() + .mockResolvedValue( 'snapshot-doc' ), + }; + getSyncManager.mockReturnValue( syncManager ); + + const savedContent = pageContent( [ 'Live content' ] ); + + await prePersistPostType( + { + id: 123, + status: 'publish', + title: { raw: 'Base title' }, + content: { raw: pageContent( [ 'Base content' ] ) }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'base-doc', + }, + }, + { + title: 'Saved title', + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'base-doc', + }, + }, + 'post', + false, + '/wp/v2/posts', + { + recordSnapshot: { content: savedContent }, + } + ); + + expect( syncManager.createPersistedCRDTDoc ).toHaveBeenCalledWith( + 'postType/post', + 123, + expect.objectContaining( { + recordSnapshot: { + content: savedContent, + title: 'Saved title', + }, + } ) + ); + } ); + + it( 'preserves base record metadata in persisted CRDT document metadata when recordSnapshot is partial', async () => { + const syncManager = { + createPersistedCRDTDoc: jest + .fn() + .mockResolvedValue( 'snapshot-doc' ), + }; + getSyncManager.mockReturnValue( syncManager ); + + const savedContent = pageContent( [ 'Live content' ] ); + + await prePersistPostType( + { + id: 123, + status: 'publish', + title: { raw: 'Base title' }, + excerpt: { raw: 'Base excerpt' }, + content: { raw: pageContent( [ 'Base content' ] ) }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'base-doc', + }, + }, + { + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'base-doc', + }, + }, + 'post', + false, + '/wp/v2/posts', + { + recordSnapshot: { content: savedContent }, + } + ); + + expect( syncManager.createPersistedCRDTDoc ).toHaveBeenCalledWith( + 'postType/post', + 123, + expect.objectContaining( { + recordSnapshot: { + content: savedContent, + excerpt: 'Base excerpt', + title: 'Base title', + }, + } ) + ); + } ); + + it( 'preserves an explicit local title save when the latest persisted CRDT has an older title', async () => { + const latestRecord = { + id: 123, + title: { raw: 'older CRDT title' }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'latest-doc', + }, + }; + const syncManager = { + applyPersistedCRDTDoc: jest.fn().mockResolvedValue( true ), + createPersistedCRDTDoc: jest.fn().mockResolvedValue( 'title-doc' ), + getCRDTRecordData: jest.fn( () => ( { + title: 'older CRDT title', + } ) ), + update: jest.fn(), + }; + apiFetch.mockResolvedValue( latestRecord ); + getSyncManager.mockReturnValue( syncManager ); + window._wpCollaborationEnabled = true; + + const result = await prePersistPostType( + { + id: 123, + status: 'publish', + title: { raw: 'older CRDT title' }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'base-doc', + }, + }, + { title: 'local checkpoint title' }, + 'page', + false, + '/wp/v2/pages' + ); + + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/page', + 123, + { title: 'local checkpoint title' }, + 'gutenberg-undo-ignored', + expect.objectContaining( { isSave: true } ) + ); + expect( result ).toEqual( { + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'title-doc', + }, + } ); + } ); + + it( 'snapshots only saved raw fields before serializing the persisted document', async () => { + const baseContent = pageContent( [ 'Alpha' ] ); + const latestRecord = { + id: 123, + title: { raw: 'Base title' }, + content: { raw: baseContent }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'latest-doc', + }, + }; + const syncManager = { + applyPersistedCRDTDoc: jest.fn().mockResolvedValue( false ), + createPersistedCRDTDoc: jest.fn().mockResolvedValue( 'title-doc' ), + getCRDTRecordData: jest.fn( () => ( { + title: 'Base title', + content: baseContent, + } ) ), + update: jest.fn(), + }; + apiFetch.mockResolvedValue( latestRecord ); + getSyncManager.mockReturnValue( syncManager ); + window._wpCollaborationEnabled = true; + + const result = await prePersistPostType( + { + id: 123, + status: 'publish', + title: { raw: 'Base title' }, + content: { raw: baseContent }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'base-doc', + }, + }, + { title: 'Checkpoint title' }, + 'page', + false, + '/wp/v2/pages' + ); + + expect( syncManager.update ).toHaveBeenCalledWith( + 'postType/page', + 123, + { title: 'Checkpoint title' }, + 'gutenberg-undo-ignored', + expect.objectContaining( { isSave: true } ) + ); + expect( result ).toEqual( { + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'title-doc', + }, + } ); + } ); + + it( 'preserves latest saved content when a full-record save only changes other fields', async () => { + const baseContent = pageContent( [ 'Alpha', 'Beta' ] ); + const latestContent = pageContent( [ 'Alpha', 'current content' ] ); + const latestRecord = { + id: 123, + content: { raw: latestContent }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'latest-doc', + }, + }; + const syncManager = { + applyPersistedCRDTDoc: jest.fn().mockResolvedValue( false ), + createPersistedCRDTDoc: jest.fn().mockResolvedValue( 'local-doc' ), + getCRDTRecordData: jest.fn( () => ( { + content: 'older local crdt content', + } ) ), + }; + apiFetch.mockResolvedValue( latestRecord ); + getSyncManager.mockReturnValue( syncManager ); + window._wpCollaborationEnabled = true; + + const result = await prePersistPostType( + { + id: 123, + status: 'publish', + content: { raw: baseContent }, + meta: { + foo: 'base', + }, + }, + { + content: baseContent, + meta: { + foo: 'changed', + }, + }, + 'page', + false, + '/wp/v2/pages' + ); + + expect( apiFetch ).toHaveBeenCalledWith( { + path: '/wp/v2/pages/123?context=edit', + } ); + expect( syncManager.applyPersistedCRDTDoc ).toHaveBeenCalledWith( + 'postType/page', + 123, + latestRecord + ); + expect( syncManager.getCRDTRecordData ).not.toHaveBeenCalled(); + expect( result ).toEqual( { + content: latestContent, + meta: { + foo: 'changed', + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'local-doc', + }, + } ); + } ); + + it( 'merges the latest persisted CRDT record before saving stale post content', async () => { + const latestRecord = { + id: 123, + content: { raw: 'current content' }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'latest-doc', + }, + }; + const syncManager = { + applyPersistedCRDTDoc: jest.fn(), + createPersistedCRDTDoc: jest.fn().mockResolvedValue( 'merged-doc' ), + getCRDTRecordData: jest.fn( () => ( { + content: 'merged content', + } ) ), + }; + apiFetch.mockResolvedValue( latestRecord ); + getSyncManager.mockReturnValue( syncManager ); + window._wpCollaborationEnabled = true; + + const result = await prePersistPostType( + { + id: 123, + status: 'publish', + content: { raw: 'base content' }, + }, + { content: 'stale local content' }, + 'page', + false, + '/wp/v2/pages' + ); + + expect( apiFetch ).toHaveBeenCalledWith( { + path: '/wp/v2/pages/123?context=edit', + } ); + expect( syncManager.applyPersistedCRDTDoc ).toHaveBeenCalledWith( + 'postType/page', + 123, + latestRecord + ); + expect( syncManager.getCRDTRecordData ).toHaveBeenCalledWith( + 'postType/page', + 123 + ); + expect( result ).toEqual( { + content: 'merged content', + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'merged-doc', + }, + } ); + } ); + + it( 'uses the CRDT record when applying the latest persisted document changes local state', async () => { + const latestRecord = { + id: 123, + content: { raw: 'current content' }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'latest-doc', + }, + }; + const syncManager = { + applyPersistedCRDTDoc: jest.fn().mockResolvedValue( true ), + createPersistedCRDTDoc: jest.fn().mockResolvedValue( 'merged-doc' ), + getCRDTRecordData: jest.fn( () => ( { + content: 'merged content', + } ) ), + }; + apiFetch.mockResolvedValue( latestRecord ); + getSyncManager.mockReturnValue( syncManager ); + window._wpCollaborationEnabled = true; + + const result = await prePersistPostType( + { + id: 123, + status: 'publish', + content: { raw: 'current content' }, + }, + { content: 'stale local content' }, + 'page', + false, + '/wp/v2/pages' + ); + + expect( syncManager.applyPersistedCRDTDoc ).toHaveBeenCalledWith( + 'postType/page', + 123, + latestRecord + ); + expect( syncManager.getCRDTRecordData ).toHaveBeenCalledWith( + 'postType/page', + 123 + ); + expect( result ).toEqual( { + content: 'merged content', + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'merged-doc', + }, + } ); + } ); + + it( 'derives stale saved content from CRDT blocks instead of serialized CRDT content', async () => { + const mergedContent = pageContent( [ + 'Alpha', + 'stale local content', + 'current content', + ] ); + const latestRecord = { + id: 123, + content: { raw: pageContent( [ 'Alpha', 'current content' ] ) }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'latest-doc', + }, + }; + const syncManager = { + applyPersistedCRDTDoc: jest.fn().mockResolvedValue( true ), + createPersistedCRDTDoc: jest.fn().mockResolvedValue( 'merged-doc' ), + getCRDTRecordData: jest.fn( () => ( { + blocks: parse( mergedContent ), + content: 'mangled serialized CRDT content', + } ) ), + }; + apiFetch.mockResolvedValue( latestRecord ); + getSyncManager.mockReturnValue( syncManager ); + window._wpCollaborationEnabled = true; + + const result = await prePersistPostType( + { + id: 123, + status: 'publish', + content: { raw: pageContent( [ 'Alpha' ] ) }, + }, + { content: pageContent( [ 'Alpha', 'stale local content' ] ) }, + 'page', + false, + '/wp/v2/pages' + ); + + expect( result.content ).toBe( mergedContent ); + expect( result.content ).not.toContain( 'mangled' ); + expect( result.meta ).toEqual( { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'merged-doc', + } ); + } ); + + it( 'does not persist empty content while the local CRDT blocks are non-empty', async () => { + const crdtContent = pageContent( [ 'Alpha', 'current content' ] ); + const latestRecord = { + id: 123, + content: { raw: crdtContent }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'latest-doc', + }, + }; + const syncManager = { + applyPersistedCRDTDoc: jest.fn().mockResolvedValue( false ), + createPersistedCRDTDoc: jest.fn().mockResolvedValue( 'local-doc' ), + getCRDTRecordData: jest.fn( () => ( { + blocks: parse( crdtContent ), + content: '', + } ) ), + }; + apiFetch.mockResolvedValue( latestRecord ); + getSyncManager.mockReturnValue( syncManager ); + window._wpCollaborationEnabled = true; + + const result = await prePersistPostType( + { + id: 123, + status: 'publish', + title: { raw: 'Base title' }, + content: { raw: crdtContent }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'base-doc', + }, + }, + { + title: 'Updated title', + content: { raw: '' }, + }, + 'page', + false, + '/wp/v2/pages' + ); + + expect( syncManager.getCRDTRecordData ).toHaveBeenCalledWith( + 'postType/page', + 123 + ); + expect( result ).toEqual( { + content: crdtContent, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'local-doc', + }, + } ); + } ); + + it( 'allows empty content when the local CRDT blocks are empty', async () => { + const latestRecord = { + id: 123, + content: { raw: pageContent( [ 'Alpha' ] ) }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'latest-doc', + }, + }; + const syncManager = { + applyPersistedCRDTDoc: jest.fn().mockResolvedValue( false ), + createPersistedCRDTDoc: jest.fn().mockResolvedValue( 'local-doc' ), + getCRDTRecordData: jest.fn( () => ( { + blocks: [], + content: '', + } ) ), + }; + apiFetch.mockResolvedValue( latestRecord ); + getSyncManager.mockReturnValue( syncManager ); + window._wpCollaborationEnabled = true; + + const result = await prePersistPostType( + { + id: 123, + status: 'publish', + content: { raw: latestRecord.content.raw }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'base-doc', + }, + }, + { content: '' }, + 'page', + false, + '/wp/v2/pages' + ); + + expect( syncManager.getCRDTRecordData ).toHaveBeenCalledWith( + 'postType/page', + 123 + ); + expect( result ).toEqual( { + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'local-doc', + }, + } ); + } ); + + it( 'merges non-conflicting stale serialized content edits with the latest saved content', async () => { + const latestRecord = { + id: 123, + content: { raw: pageContent( [ 'Alpha', 'current content' ] ) }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'latest-doc', + }, + }; + const syncManager = { + applyPersistedCRDTDoc: jest.fn().mockResolvedValue( false ), + createPersistedCRDTDoc: jest.fn().mockResolvedValue( 'merged-doc' ), + getCRDTRecordData: jest.fn( () => ( { + content: latestRecord.content.raw, + } ) ), + }; + apiFetch.mockResolvedValue( latestRecord ); + getSyncManager.mockReturnValue( syncManager ); + window._wpCollaborationEnabled = true; + + const result = await prePersistPostType( + { + id: 123, + status: 'publish', + content: { raw: pageContent( [ 'Alpha', 'Beta' ] ) }, + }, + { content: pageContent( [ 'stale local content', 'Beta' ] ) }, + 'page', + false, + '/wp/v2/pages' + ); + + expect( result.content ).toContain( 'stale local content' ); + expect( result.content ).toContain( 'current content' ); + expect( result.meta ).toEqual( { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'merged-doc', + } ); + } ); + + it( 'preserves latest trailing serialized blocks when a stale content edit submits an older shorter body', async () => { + const latestContent = pageContent( [ + 'Alpha', + 'Beta', + 'current content', + ] ); + const latestRecord = { + id: 123, + content: { raw: latestContent }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'latest-doc', + }, + }; + const syncManager = { + applyPersistedCRDTDoc: jest.fn().mockResolvedValue( false ), + createPersistedCRDTDoc: jest.fn().mockResolvedValue( 'merged-doc' ), + getCRDTRecordData: jest.fn( () => ( { + content: latestContent, + } ) ), + }; + apiFetch.mockResolvedValue( latestRecord ); + getSyncManager.mockReturnValue( syncManager ); + window._wpCollaborationEnabled = true; + + const result = await prePersistPostType( + { + id: 123, + status: 'publish', + content: { raw: latestContent }, + }, + { content: pageContent( [ 'stale local content', 'Beta' ] ) }, + 'page', + false, + '/wp/v2/pages' + ); + + expect( result.content ).toContain( 'stale local content' ); + expect( result.content ).toContain( 'current content' ); + expect( result.meta ).toEqual( { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'merged-doc', + } ); + } ); + + it( 'preserves revision restore fields without merging latest saved blocks back in', async () => { + const restoredContent = pageContent( [ 'Alpha', 'older revision' ] ); + const latestContent = pageContent( [ + 'Alpha', + 'older revision', + 'newer checkpoint', + ] ); + const restoredTitle = 'Older revision title'; + const latestTitle = 'Newer checkpoint title'; + const latestRecord = { + id: 123, + content: { raw: latestContent }, + title: { raw: latestTitle }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'latest-doc', + }, + }; + const syncManager = { + applyPersistedCRDTDoc: jest.fn().mockResolvedValue( false ), + createPersistedCRDTDoc: jest.fn().mockResolvedValue( 'local-doc' ), + getCRDTRecordData: jest.fn( () => ( { + blocks: parse( restoredContent ), + content: restoredContent, + title: latestTitle, + } ) ), + }; + apiFetch.mockResolvedValue( latestRecord ); + getSyncManager.mockReturnValue( syncManager ); + window._wpCollaborationEnabled = true; + + const result = await prePersistPostType( + { + id: 123, + status: 'publish', + content: { raw: latestContent }, + title: { raw: latestTitle }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'base-doc', + }, + }, + { content: restoredContent, title: restoredTitle }, + 'page', + false, + '/wp/v2/pages', + { __unstableIsRevisionRestore: true } + ); + + expect( result ).toEqual( { + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'local-doc', + }, + } ); + } ); + + it( 'merges sibling serialized blocks appended from a shared stale base', async () => { + const latestRecord = { + id: 123, + content: { raw: pageContent( [ 'Alpha', 'current content' ] ) }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'latest-doc', + }, + }; + const syncManager = { + applyPersistedCRDTDoc: jest.fn().mockResolvedValue( false ), + createPersistedCRDTDoc: jest.fn().mockResolvedValue( 'merged-doc' ), + getCRDTRecordData: jest.fn( () => ( { + content: latestRecord.content.raw, + } ) ), + }; + apiFetch.mockResolvedValue( latestRecord ); + getSyncManager.mockReturnValue( syncManager ); + window._wpCollaborationEnabled = true; + + const result = await prePersistPostType( + { + id: 123, + status: 'publish', + content: { raw: pageContent( [ 'Alpha' ] ) }, + }, + { content: pageContent( [ 'Alpha', 'stale local content' ] ) }, + 'page', + false, + '/wp/v2/pages' + ); + + expect( result.content ).toContain( 'stale local content' ); + expect( result.content ).toContain( 'current content' ); + expect( result.meta ).toEqual( { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'merged-doc', + } ); + } ); + + it( 'does not merge stale serialized content edits when the same block changed locally and remotely', async () => { + const latestRecord = { + id: 123, + content: { raw: pageContent( [ 'current content', 'Beta' ] ) }, + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'latest-doc', + }, + }; + const syncManager = { + applyPersistedCRDTDoc: jest.fn().mockResolvedValue( false ), + createPersistedCRDTDoc: jest.fn().mockResolvedValue( 'merged-doc' ), + getCRDTRecordData: jest.fn( () => ( { + content: latestRecord.content.raw, + } ) ), + }; + apiFetch.mockResolvedValue( latestRecord ); + getSyncManager.mockReturnValue( syncManager ); + window._wpCollaborationEnabled = true; + + const result = await prePersistPostType( + { + id: 123, + status: 'publish', + content: { raw: pageContent( [ 'Alpha', 'Beta' ] ) }, + }, + { content: pageContent( [ 'stale local content', 'Beta' ] ) }, + 'page', + false, + '/wp/v2/pages' + ); + + expect( result ).toEqual( { + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'merged-doc', + }, + } ); + } ); + + it( 'does not replace edited content from CRDT when the latest record has no persisted CRDT document', async () => { + const latestRecord = { + id: 123, + content: { raw: 'base content' }, + meta: {}, + }; + const syncManager = { + applyPersistedCRDTDoc: jest.fn().mockResolvedValue( true ), + createPersistedCRDTDoc: jest + .fn() + .mockResolvedValueOnce( 'before-apply-doc' ) + .mockResolvedValueOnce( 'after-apply-doc' ) + .mockResolvedValueOnce( 'after-snapshot-doc' ), + getCRDTRecordData: jest.fn( () => ( { + content: 'partially flushed local crdt content', + } ) ), + update: jest.fn(), + }; + apiFetch.mockResolvedValue( latestRecord ); + getSyncManager.mockReturnValue( syncManager ); + window._wpCollaborationEnabled = true; + + const result = await prePersistPostType( + { + id: 123, + status: 'publish', + content: { raw: 'base content' }, + }, + { content: 'new local content' }, + 'page', + false, + '/wp/v2/pages' + ); + + expect( syncManager.applyPersistedCRDTDoc ).toHaveBeenCalledWith( + 'postType/page', + 123, + latestRecord + ); + expect( syncManager.getCRDTRecordData ).not.toHaveBeenCalled(); + expect( result ).toEqual( { + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: + 'after-snapshot-doc', + }, + } ); + } ); + + it( 'does not replace edited content when the latest saved post has not changed', async () => { + const latestRecord = { + id: 123, + content: { raw: 'base content' }, + }; + const syncManager = { + applyPersistedCRDTDoc: jest.fn().mockResolvedValue( false ), + createPersistedCRDTDoc: jest.fn().mockResolvedValue( 'local-doc' ), + getCRDTRecordData: jest.fn( () => ( { + content: 'older local crdt content', + } ) ), + }; + apiFetch.mockResolvedValue( latestRecord ); + getSyncManager.mockReturnValue( syncManager ); + window._wpCollaborationEnabled = true; + + const result = await prePersistPostType( + { + id: 123, + status: 'publish', + content: { raw: 'base content' }, + }, + { content: 'new local content' }, + 'page', + false, + '/wp/v2/pages' + ); + + expect( syncManager.applyPersistedCRDTDoc ).toHaveBeenCalledWith( + 'postType/page', + 123, + latestRecord + ); + expect( syncManager.getCRDTRecordData ).not.toHaveBeenCalled(); + expect( result ).toEqual( { + meta: { + [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ]: 'local-doc', + }, + } ); + } ); } ); describe( 'loadPostTypeEntities', () => { diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 40b6f616dd963d..beb3539b127b96 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -70,9 +70,18 @@ export type YBlocks = Y.Array< YBlock >; export type YBlockAttributes = Y.Map< Y.Text | unknown >; interface MergeCrdtBlocksOptions { + baseBlocks?: Block[]; preserveClientIds?: boolean; } +type MergeCrdtBlocksArgument = MergeCrdtBlocksOptions | Block[]; + +function normalizeMergeCrdtBlocksOptions( + options: MergeCrdtBlocksArgument = {} +): MergeCrdtBlocksOptions { + return Array.isArray( options ) ? { baseBlocks: options } : options; +} + /** * Optional description of where a cursor falls. * @@ -81,7 +90,11 @@ interface MergeCrdtBlocksOptions { */ export type MergeCursorPosition = WPBlockSelection | null; +const ARRAY_ELEMENT_ID_KEY = '__unstableSyncId'; +const ARRAY_ELEMENT_ID_SYMBOL = Symbol( 'wpSyncArrayElementId' ); + const serializableBlocksCache = new WeakMap< WeakKey, Block[] >(); +const previousLocalBlocksCache = new WeakMap< YBlocks, Block[] >(); /** * Recursively walk an attribute value and convert any RichTextData instances @@ -104,10 +117,20 @@ function serializeAttributeValue( value: unknown ): unknown { // e.g. a single row inside core/table `body`: { cells: [ ... ] } if ( value && typeof value === 'object' ) { const result: Record< string, unknown > = {}; + const arrayElementId = getArrayElementId( value ); for ( const [ k, v ] of Object.entries( value ) ) { + if ( k === ARRAY_ELEMENT_ID_KEY ) { + continue; + } + result[ k ] = serializeAttributeValue( v ); } + + if ( arrayElementId ) { + result[ ARRAY_ELEMENT_ID_KEY ] = arrayElementId; + } + return result; } @@ -188,16 +211,25 @@ function deserializeAttributeValue( // e.g. a single row inside core/table `body`: { cells: [ ... ] } if ( value && typeof value === 'object' ) { const result: Record< string, unknown > = {}; + const arrayElementId = getArrayElementId( value ); for ( const [ key, innerValue ] of Object.entries( value as Record< string, unknown > ) ) { + if ( key === ARRAY_ELEMENT_ID_KEY ) { + continue; + } + result[ key ] = deserializeAttributeValue( schema?.query?.[ key ], innerValue ); } + if ( arrayElementId ) { + defineArrayElementId( result, arrayElementId ); + } + return result; } @@ -294,7 +326,7 @@ function createNewYAttributeValue( attributeValue: unknown ): Y.Text | Y.Array< unknown > | Y.Map< unknown > | unknown { const schema = getBlockAttributeSchema( blockName, attributeName ); - return createYValueFromSchema( schema, attributeValue ); + return createYValueFromSchema( schema, attributeValue, attributeName ); } /** @@ -306,13 +338,15 @@ function createNewYAttributeValue( * - `object` with query -> Y.Map * - anything else -> plain value (unchanged) * - * @param schema The attribute type definition. - * @param value The plain JS value to convert. + * @param schema The attribute type definition. + * @param value The plain JS value to convert. + * @param valuePath Optional path used to identify array elements. * @return A Y.js type or the original value. */ function createYValueFromSchema( schema: BlockAttributeSchema | undefined, - value: unknown + value: unknown, + valuePath?: string ): Y.Text | Y.Array< unknown > | Y.Map< unknown > | unknown { if ( ! schema ) { return value; @@ -328,14 +362,20 @@ function createYValueFromSchema( yArray.insert( 0, - value.map( ( item ) => createYMapFromQuery( query, item ) ) + value.map( ( item, index ) => + createYMapFromQuery( + query, + item, + valuePath ? `${ valuePath }/${ index }` : true + ) + ) ); return yArray; } if ( schema.type === 'object' && schema.query && isRecord( value ) ) { - return createYMapFromQuery( schema.query, value ); + return createYMapFromQuery( schema.query, value, undefined, valuePath ); } return value; @@ -355,24 +395,46 @@ function isRecord( value: unknown ): value is Record< string, unknown > { * Create a Y.Map from a plain object, using a query schema to decide which * properties should become nested Y.js types (Y.Text, Y.Array, Y.Map). * - * @param query The query schema defining the properties. - * @param obj The plain object to convert. + * @param query The query schema defining the properties. + * @param obj The plain object to convert. + * @param arrayElementId Optional stable ID for an array element. + * @param valuePath Optional path used to identify nested array elements. * @return A Y.Map with typed values. */ function createYMapFromQuery( query: Record< string, BlockAttributeSchema >, - obj: unknown + obj: unknown, + arrayElementId?: string | true, + valuePath?: string ): Y.Map< unknown > { if ( ! isRecord( obj ) ) { return new Y.Map(); } - const entries: [ string, unknown ][] = Object.entries( obj ).map( - ( [ key, val ] ): [ string, unknown ] => { + const nestedValuePath = + valuePath ?? + ( typeof arrayElementId === 'string' ? arrayElementId : undefined ); + const entries: [ string, unknown ][] = Object.entries( obj ) + .filter( ( [ key ] ) => key !== ARRAY_ELEMENT_ID_KEY ) + .map( ( [ key, val ] ): [ string, unknown ] => { const subSchema = query[ key ]; - return [ key, createYValueFromSchema( subSchema, val ) ]; - } - ); + return [ + key, + createYValueFromSchema( + subSchema, + val, + nestedValuePath ? `${ nestedValuePath }/${ key }` : key + ), + ]; + } ); + + const resolvedArrayElementId = + getArrayElementId( obj ) ?? + ( arrayElementId === true ? uuidv4() : arrayElementId ); + + if ( resolvedArrayElementId ) { + entries.push( [ ARRAY_ELEMENT_ID_KEY, resolvedArrayElementId ] ); + } return new Y.Map( entries ); } @@ -415,320 +477,1407 @@ function createNewYBlock( block: Block ): YBlock { ); } -/** - * Merge incoming block data into the local Y.Doc. - * This function is called to sync local block changes to a shared Y.Doc. - * - * @param yblocks The blocks in the local Y.Doc. - * @param incomingBlocks Gutenberg blocks being synced. - * @param attributeCursor When provided, describes a selection cursor falling within a - * RichText field associated with a specific block and attribute. - * Derived from the changes that produced the blocks. - * @param options Optional settings for the merge operation. - */ -export function mergeCrdtBlocks( +function getBlockClientId( block: Block ): string | null { + return block.clientId || null; +} + +function getYBlockClientId( yblock: YBlock ): string | null { + const clientId = yblock.get( 'clientId' ); + return typeof clientId === 'string' && clientId ? clientId : null; +} + +function findYBlockIndexByClientId( yblocks: YBlocks, - incomingBlocks: Block[], - attributeCursor: MergeCursorPosition, - options: MergeCrdtBlocksOptions = {} -): void { - // Ensure we are working with serializable block data. - if ( ! serializableBlocksCache.has( incomingBlocks ) ) { - serializableBlocksCache.set( - incomingBlocks, - makeBlocksSerializable( incomingBlocks ) + clientId: string, + startIndex = 0 +): number { + for ( let index = startIndex; index < yblocks.length; index++ ) { + if ( getYBlockClientId( yblocks.get( index ) ) === clientId ) { + return index; + } + } + + return -1; +} + +function normalizeBlockForIdentity( value: unknown ): unknown { + if ( Array.isArray( value ) ) { + return value.map( normalizeBlockForIdentity ); + } + + if ( isRecord( value ) ) { + return Object.fromEntries( + Object.entries( value ) + .filter( ( [ key ] ) => key !== 'clientId' ) + .sort( ( [ a ], [ b ] ) => a.localeCompare( b ) ) + .map( ( [ key, innerValue ] ) => [ + key, + normalizeBlockForIdentity( innerValue ), + ] ) ); } - const incomingBlocksToSync = - serializableBlocksCache.get( incomingBlocks ) ?? []; + return value; +} - // This is a rudimentary diff implementation similar to the y-prosemirror diffing - // approach. - // A better implementation would also diff the textual content and represent it - // using a Y.Text type. - // However, at this time it makes more sense to keep this algorithm generic to - // support all kinds of block types. - // Ideally, we ensure that block data structure have a consistent data format. - // E.g.: - // - textual content (using rich-text formatting?) may always be stored under `block.text` - // - local information that shouldn't be shared (e.g. clientId or isDragging) is stored under `block.private` - // - // @credit Kevin Jahns (dmonad) - // @link https://github.com/WordPress/gutenberg/pull/68483 - const numOfCommonEntries = Math.min( - incomingBlocksToSync.length ?? 0, - yblocks.length +function getBlockSemanticKey( block: Block ): string { + return JSON.stringify( normalizeBlockForIdentity( block ) ); +} + +function isSameBlockIdentity( firstBlock: Block, secondBlock: Block ): boolean { + const firstClientId = getBlockClientId( firstBlock ); + const secondClientId = getBlockClientId( secondBlock ); + + if ( firstClientId || secondClientId ) { + return firstClientId === secondClientId; + } + + return ( + getBlockSemanticKey( firstBlock ) === getBlockSemanticKey( secondBlock ) ); +} - let left = 0; - let right = 0; +function getYBlockSemanticKey( yblock: YBlock ): string { + return getBlockSemanticKey( yblock.toJSON() as unknown as Block ); +} - // skip equal blocks from left - for ( - ; - left < numOfCommonEntries && - areBlocksEqual( incomingBlocksToSync[ left ], yblocks.get( left ) ); - left++ - ) { - /* nop */ +function findEquivalentYBlockIndex( yblocks: YBlocks, block: Block ): number { + const clientId = getBlockClientId( block ); + + if ( clientId ) { + for ( let index = 0; index < yblocks.length; index++ ) { + if ( getYBlockClientId( yblocks.get( index ) ) === clientId ) { + return index; + } + } } - // skip equal blocks from right - for ( - ; - right < numOfCommonEntries - left && - areBlocksEqual( - incomingBlocksToSync[ incomingBlocksToSync.length - right - 1 ], - yblocks.get( yblocks.length - right - 1 ) - ); - right++ - ) { - /* nop */ + const semanticKey = getBlockSemanticKey( block ); + let matchingSemanticIndex = -1; + let semanticMatchCount = 0; + + for ( let index = 0; index < yblocks.length; index++ ) { + const yblock = yblocks.get( index ); + + if ( + getYBlockSemanticKey( yblock ) === semanticKey || + areBlocksEqual( block, yblock ) + ) { + matchingSemanticIndex = index; + semanticMatchCount++; + } } - const numOfUpdatesNeeded = numOfCommonEntries - left - right; - const numOfInsertionsNeeded = Math.max( - 0, - incomingBlocksToSync.length - yblocks.length + return semanticMatchCount === 1 ? matchingSemanticIndex : -1; +} + +function getUniqueKeys< T >( + items: T[], + getKey: ( item: T ) => string | null +): string[] | null { + const keys: string[] = []; + const seenKeys = new Set< string >(); + + for ( const item of items ) { + const key = getKey( item ); + + if ( ! key || seenKeys.has( key ) ) { + return null; + } + + keys.push( key ); + seenKeys.add( key ); + } + + return keys; +} + +function getBlockIdentityKeys( + yblocks: YBlocks, + baseBlocks: Block[], + blocksToSync: Block[] +): { + currentKeys: string[]; + baseKeys: string[]; + incomingKeys: string[]; +} | null { + const currentClientIds = getUniqueKeys( + yblocks.toArray(), + getYBlockClientId ); - const numOfDeletionsNeeded = Math.max( - 0, - yblocks.length - incomingBlocksToSync.length + const baseClientIds = getUniqueKeys( baseBlocks, getBlockClientId ); + const incomingClientIds = getUniqueKeys( blocksToSync, getBlockClientId ); + + if ( currentClientIds && baseClientIds && incomingClientIds ) { + const currentSet = new Set( currentClientIds ); + const baseSet = new Set( baseClientIds ); + + if ( + currentSet.size === baseSet.size && + incomingClientIds.length === baseClientIds.length && + baseClientIds.every( ( key ) => currentSet.has( key ) ) && + incomingClientIds.every( ( key ) => baseSet.has( key ) ) + ) { + return { + currentKeys: currentClientIds, + baseKeys: baseClientIds, + incomingKeys: incomingClientIds, + }; + } + } + + const currentBlocks = yblocks.toArray().map( ( yblock ) => { + return yblock.toJSON() as unknown as Block; + } ); + const currentSemanticKeys = getUniqueKeys( + currentBlocks, + getBlockSemanticKey + ); + const baseSemanticKeys = getUniqueKeys( baseBlocks, getBlockSemanticKey ); + const incomingSemanticKeys = getUniqueKeys( + blocksToSync, + getBlockSemanticKey ); - // updates - for ( let i = 0; i < numOfUpdatesNeeded; i++, left++ ) { - const incomingYBlock = incomingBlocksToSync[ left ]; - const localYBlock = yblocks.get( left ); + if ( + ! currentSemanticKeys || + ! baseSemanticKeys || + ! incomingSemanticKeys + ) { + return null; + } - Object.entries( incomingYBlock ).forEach( - ( [ incomingBlockProperty, incomingBlockPropertyValue ] ) => { - switch ( incomingBlockProperty ) { - case 'attributes': { - const localAttributes = localYBlock.get( - incomingBlockProperty - ); - const incomingAttributes = incomingBlockPropertyValue; - - // When the local block has no attributes, adopt the incoming set. - if ( ! localAttributes ) { - localYBlock.set( - incomingBlockProperty, - createNewYAttributeMap( - incomingYBlock.name, - incomingAttributes - ) - ); - break; - } + const currentSet = new Set( currentSemanticKeys ); + const baseSet = new Set( baseSemanticKeys ); - // Otherwise the attributes need to be merged. - Object.entries( incomingAttributes ).forEach( - ( [ - incomingAttributeName, - incomingAttributeValue, - ] ) => { - const currentAttribute = localAttributes?.get( - incomingAttributeName - ); - - const isExpectedType = isExpectedAttributeType( - incomingYBlock.name, - incomingAttributeName, - currentAttribute - ); - - // Y types (Y.Text, Y.Array, Y.Map) cannot be - // compared with fastDeepEqual against plain values. - // Delegate to mergeYValue which handles no-op - // detection at the edges. - const isYType = - currentAttribute instanceof Y.AbstractType; - - const isAttributeChanged = - ! isExpectedType || - isYType || - ! fastDeepEqual( - currentAttribute, - incomingAttributeValue - ); - - if ( isAttributeChanged ) { - updateYBlockAttribute( - incomingYBlock.name, - incomingYBlock.clientId, - incomingAttributeName, - incomingAttributeValue, - localAttributes, - attributeCursor - ); - } - } - ); + if ( + currentSet.size !== baseSet.size || + incomingSemanticKeys.length !== baseSemanticKeys.length || + ! baseSemanticKeys.every( ( key ) => currentSet.has( key ) ) || + ! incomingSemanticKeys.every( ( key ) => baseSet.has( key ) ) + ) { + return null; + } - // Delete any attributes that are no longer present. - localAttributes.forEach( - ( _attrValue: unknown, attrName: string ) => { - if ( - ! incomingBlockPropertyValue.hasOwnProperty( - attrName - ) - ) { - localAttributes.delete( attrName ); - } - } - ); + return { + currentKeys: currentSemanticKeys, + baseKeys: baseSemanticKeys, + incomingKeys: incomingSemanticKeys, + }; +} - break; - } +function haveSameBlockClientIds( + firstBlocks: Block[], + secondBlocks: Block[] +): boolean { + return ( + firstBlocks.length === secondBlocks.length && + firstBlocks.every( ( block, index ) => { + const firstClientId = getBlockClientId( block ); + const secondClientId = getBlockClientId( secondBlocks[ index ] ); - case 'innerBlocks': { - // Recursively merge innerBlocks - let yInnerBlocks = localYBlock.get( - incomingBlockProperty - ); + return !! firstClientId && firstClientId === secondClientId; + } ) + ); +} - if ( ! ( yInnerBlocks instanceof Y.Array ) ) { - yInnerBlocks = new Y.Array< YBlock >(); - localYBlock.set( - incomingBlockProperty, - yInnerBlocks - ); - } +function shouldUseCachedLocalBlocksAsBase( + blocksToSync: Block[], + explicitBaseBlocks: Block[] | undefined, + cachedBaseBlocks: Block[] | undefined, + attributeCursor: MergeCursorPosition +): cachedBaseBlocks is Block[] { + return !! ( + explicitBaseBlocks && + cachedBaseBlocks && + attributeCursor && + ! fastDeepEqual( blocksToSync, cachedBaseBlocks ) && + haveSameBlockClientIds( blocksToSync, cachedBaseBlocks ) + ); +} - mergeCrdtBlocks( - yInnerBlocks, - incomingBlockPropertyValue ?? [], - attributeCursor, - options - ); - break; - } +function getUniqueBlockMapBySemanticKey( + blocks: Block[] +): Map< string, Block > | null { + const blockMap = new Map< string, Block >(); - case 'clientId': { - // Code Editor changes reparse raw HTML on every - // keystroke and regenerate fresh clientIds. Keep Y.Doc - // clientIds stable for the code editor so peers do not - // remount unchanged blocks on every edit. - if ( options.preserveClientIds ) { - break; - } + for ( const block of blocks ) { + const key = getBlockSemanticKey( block ); - // Otherwise, accept new clientIds from updates - if ( - incomingBlockPropertyValue !== - localYBlock.get( incomingBlockProperty ) - ) { - localYBlock.set( - incomingBlockProperty, - incomingBlockPropertyValue - ); - } - break; - } + if ( blockMap.has( key ) ) { + return null; + } - default: - if ( - ! fastDeepEqual( - incomingYBlock[ incomingBlockProperty ], - localYBlock.get( incomingBlockProperty ) - ) - ) { - localYBlock.set( - incomingBlockProperty, - incomingBlockPropertyValue - ); - } - } - } - ); - localYBlock.forEach( ( _v, k ) => { - if ( ! incomingYBlock.hasOwnProperty( k ) ) { - localYBlock.delete( k ); - } - } ); + blockMap.set( key, block ); } - // deletes - yblocks.delete( left, numOfDeletionsNeeded ); + return blockMap; +} - // inserts - for ( let i = 0; i < numOfInsertionsNeeded; i++, left++ ) { - const newBlock = [ createNewYBlock( incomingBlocksToSync[ left ] ) ]; +function canReorderYBlocksByClientId( + yblocks: YBlocks, + blocksToSync: Block[] +): boolean { + if ( yblocks.length !== blocksToSync.length || yblocks.length < 2 ) { + return false; + } - yblocks.insert( left, newBlock ); + const incomingClientIds = blocksToSync.map( getBlockClientId ); + const currentClientIds = yblocks.toArray().map( getYBlockClientId ); + + if ( + incomingClientIds.some( ( clientId ) => ! clientId ) || + currentClientIds.some( ( clientId ) => ! clientId ) + ) { + return false; } - // remove duplicate clientids - const knownClientIds = new Set< string >(); - for ( let j = 0; j < yblocks.length; j++ ) { - const yblock: YBlock = yblocks.get( j ); + const incomingSet = new Set( incomingClientIds ); - let clientId = yblock.get( 'clientId' ); + if ( incomingSet.size !== incomingClientIds.length ) { + return false; + } - if ( ! clientId ) { + const currentSet = new Set( currentClientIds ); + + return ( + currentSet.size === currentClientIds.length && + currentSet.size === incomingSet.size && + currentClientIds.every( ( clientId ) => incomingSet.has( clientId ) ) + ); +} + +function reorderYBlocksByClientId( + yblocks: YBlocks, + blocksToSync: Block[] +): void { + if ( ! canReorderYBlocksByClientId( yblocks, blocksToSync ) ) { + return; + } + + for ( + let targetIndex = 0; + targetIndex < blocksToSync.length; + targetIndex++ + ) { + const targetClientId = getBlockClientId( blocksToSync[ targetIndex ] ); + + if ( + getYBlockClientId( yblocks.get( targetIndex ) ) === targetClientId + ) { continue; } - if ( knownClientIds.has( clientId ) ) { - clientId = uuidv4(); - yblock.set( 'clientId', clientId ); + const currentIndex = yblocks + .toArray() + .findIndex( + ( yblock ) => getYBlockClientId( yblock ) === targetClientId + ); + + if ( currentIndex === -1 ) { + return; } - knownClientIds.add( clientId ); + + const reorderedBlock = createNewYBlock( blocksToSync[ targetIndex ] ); + yblocks.delete( currentIndex, 1 ); + yblocks.insert( targetIndex, [ reorderedBlock ] ); } } -/** - * Compare a plain array element against a Y.Map element for equality. - * Used by the left-right sweep diff in mergeYArray. - * - * @param newElement The plain object from the incoming array. - * @param yElement The Y.Map element from the existing Y.Array. - * @return True if the elements are deeply equal. - */ -function areArrayElementsEqual( - newElement: unknown, - yElement: unknown +function rebaseYBlocksByClientId( + yblocks: YBlocks, + baseBlocks: Block[] | undefined, + blocksToSync: Block[] ): boolean { - if ( yElement instanceof Y.Map && isRecord( newElement ) ) { - return fastDeepEqual( newElement, yElement.toJSON() ); + if ( ! baseBlocks || yblocks.length !== blocksToSync.length ) { + return false; } - return fastDeepEqual( newElement, yElement ); -} + const identityKeys = getBlockIdentityKeys( + yblocks, + baseBlocks, + blocksToSync + ); -/** - * Merge an incoming plain array into an existing Y.Array in-place. - * - * Uses the same left-right sweep diff approach as mergeCrdtBlocks: - * equal elements are skipped from both ends, then the middle section - * is updated, deleted, or inserted as needed. This preserves existing - * Y.Map/Y.Text objects for unchanged elements, so concurrent edits - * to those elements are not lost. - * - * @param yArray The existing Y.Array to update. - * @param newValue The new plain array to merge into the Y.Array. - * @param schema The attribute schema (must have `query`). - * @param cursorPosition The local cursor position for rich-text delta merges. - * @param cursorScope The selected block attribute scope for rich-text cursor hints. - */ -function mergeYArray( - yArray: Y.Array< unknown >, - newValue: unknown[], - schema: BlockAttributeSchema, - cursorPosition: MergeCursorPosition, - cursorScope: RichTextCursorScope -): void { - if ( ! schema.query ) { - return; + if ( ! identityKeys || identityKeys.baseKeys.length < 2 ) { + return false; } - const query = schema.query; - const numOfCommonEntries = Math.min( newValue.length, yArray.length ); - - let left = 0; - let right = 0; + const rebasedKeys = [ ...identityKeys.baseKeys ]; + + for ( + let targetIndex = 0; + targetIndex < blocksToSync.length; + targetIndex++ + ) { + const targetKey = identityKeys.incomingKeys[ targetIndex ]; + + if ( rebasedKeys[ targetIndex ] === targetKey ) { + continue; + } + + const baseIndex = rebasedKeys.indexOf( targetKey ); + const currentIndex = identityKeys.currentKeys.indexOf( targetKey ); + + if ( baseIndex === -1 || currentIndex === -1 ) { + return false; + } + + const reorderedBlock = createNewYBlock( + yblocks.get( currentIndex ).toJSON() as unknown as Block + ); + yblocks.delete( currentIndex, 1 ); + yblocks.insert( targetIndex, [ reorderedBlock ] ); + + identityKeys.currentKeys.splice( currentIndex, 1 ); + identityKeys.currentKeys.splice( targetIndex, 0, targetKey ); + rebasedKeys.splice( baseIndex, 1 ); + rebasedKeys.splice( targetIndex, 0, targetKey ); + } + + return true; +} + +function mergeBlockIntoYBlock( + yblock: YBlock, + block: Block, + attributeCursor: MergeCursorPosition, + options: MergeCrdtBlocksOptions, + baseBlock?: Block +): void { + const baseAttributes = baseBlock?.attributes ?? {}; + + Object.entries( block ).forEach( ( [ key, value ] ) => { + switch ( key ) { + case 'attributes': { + const currentAttributes = yblock.get( key ); + + // If attributes are not set on the yblock, use the new values. + if ( ! currentAttributes ) { + yblock.set( + key, + createNewYAttributeMap( block.name, value ) + ); + break; + } + + Object.entries( value ).forEach( + ( [ attributeName, attributeValue ] ) => { + const currentAttribute = + currentAttributes?.get( attributeName ); + + const isExpectedType = isExpectedAttributeType( + block.name, + attributeName, + currentAttribute + ); + + if ( + baseBlock && + isExpectedType && + fastDeepEqual( + baseAttributes[ attributeName ], + attributeValue + ) + ) { + return; + } + + // Y types (Y.Text, Y.Array, Y.Map) cannot be compared + // with fastDeepEqual against plain values. Delegate to + // mergeYValue which handles no-op detection at the edges. + const isYType = + currentAttribute instanceof Y.AbstractType; + + const isAttributeChanged = + ! isExpectedType || + isYType || + ! fastDeepEqual( currentAttribute, attributeValue ); + + if ( isAttributeChanged ) { + updateYBlockAttribute( + block.name, + block.clientId, + attributeName, + attributeValue, + currentAttributes, + attributeCursor, + baseAttributes[ attributeName ] + ); + } + } + ); + + // Delete any attributes that are no longer present. + currentAttributes.forEach( + ( _attrValue: unknown, attrName: string ) => { + if ( ! value.hasOwnProperty( attrName ) ) { + if ( + baseBlock && + ! Object.prototype.hasOwnProperty.call( + baseAttributes, + attrName + ) + ) { + return; + } + currentAttributes.delete( attrName ); + } + } + ); + + break; + } + + case 'innerBlocks': { + if ( + baseBlock && + fastDeepEqual( baseBlock.innerBlocks, value ?? [] ) + ) { + break; + } + + // Recursively merge innerBlocks. + let yInnerBlocks = yblock.get( key ); + + if ( ! ( yInnerBlocks instanceof Y.Array ) ) { + yInnerBlocks = new Y.Array< YBlock >(); + yblock.set( key, yInnerBlocks ); + } + + mergeCrdtBlocks( + yInnerBlocks, + value ?? [], + attributeCursor, + { + ...options, + baseBlocks: baseBlock?.innerBlocks, + } + ); + break; + } + + case 'clientId': { + if ( options.preserveClientIds ) { + break; + } + + if ( + baseBlock && + fastDeepEqual( baseBlock.clientId, value ) + ) { + break; + } + + if ( value !== yblock.get( key ) ) { + yblock.set( key, value ); + } + break; + } + + default: { + const blockKey = key as keyof Block; + + if ( + baseBlock && + fastDeepEqual( baseBlock[ blockKey ], value ) + ) { + break; + } + + if ( ! fastDeepEqual( value, yblock.get( key ) ) ) { + yblock.set( key, value ); + } + } + } + } ); + yblock.forEach( ( _v, k ) => { + if ( ! Object.hasOwn( block, k ) ) { + if ( + baseBlock && + ! Object.prototype.hasOwnProperty.call( baseBlock, k ) + ) { + return; + } + yblock.delete( k ); + } + } ); +} + +function mergeYBlocksByClientId( + yblocks: YBlocks, + blocksToSync: Block[], + attributeCursor: MergeCursorPosition, + options: MergeCrdtBlocksOptions, + baseBlocks?: Block[] +): void { + const incomingBlocksByClientId = new Map( + blocksToSync.map( ( block ) => [ getBlockClientId( block ), block ] ) + ); + const baseBlocksByClientId = new Map( + ( baseBlocks ?? [] ).map( ( block ) => [ + getBlockClientId( block ), + block, + ] ) + ); + const incomingBlocksBySemanticKey = + getUniqueBlockMapBySemanticKey( blocksToSync ); + const baseBlocksBySemanticKey = baseBlocks + ? getUniqueBlockMapBySemanticKey( baseBlocks ) + : null; + + for ( let index = 0; index < yblocks.length; index++ ) { + const yblock = yblocks.get( index ); + const clientId = getYBlockClientId( yblock ); + let block = incomingBlocksByClientId.get( clientId ); + let baseBlock = baseBlocksByClientId.get( clientId ); + + if ( ! block && incomingBlocksBySemanticKey ) { + const semanticKey = getBlockSemanticKey( + yblock.toJSON() as unknown as Block + ); + block = incomingBlocksBySemanticKey.get( semanticKey ); + baseBlock = baseBlocksBySemanticKey?.get( semanticKey ); + } + + if ( block ) { + mergeBlockIntoYBlock( + yblock, + block, + attributeCursor, + options, + baseBlock + ); + } + } +} + +function areYBlocksEqualToPlainBlocks( + yblocks: YBlocks, + blocks: Block[] +): boolean { + return ( + yblocks.length === blocks.length && + blocks.every( ( block, index ) => + areBlocksEqual( block, yblocks.get( index ) ) + ) + ); +} + +function findYBlockIndex( + yblocks: YBlocks, + baseBlock: Block, + preferredIndex: number, + baseLength: number +): number { + const clientId = getBlockClientId( baseBlock ); + + if ( clientId ) { + for ( let index = 0; index < yblocks.length; index++ ) { + if ( getYBlockClientId( yblocks.get( index ) ) === clientId ) { + return index; + } + } + } + + for ( let index = 0; index < yblocks.length; index++ ) { + if ( areBlocksEqual( baseBlock, yblocks.get( index ) ) ) { + return index; + } + } + + if ( yblocks.length === baseLength && preferredIndex < yblocks.length ) { + return preferredIndex; + } + + return preferredIndex < yblocks.length ? preferredIndex : -1; +} + +function findStrictYBlockIndex( yblocks: YBlocks, block: Block ): number { + const clientId = getBlockClientId( block ); + + if ( clientId ) { + for ( let index = 0; index < yblocks.length; index++ ) { + if ( getYBlockClientId( yblocks.get( index ) ) === clientId ) { + return index; + } + } + + return -1; + } + + for ( let index = 0; index < yblocks.length; index++ ) { + if ( areBlocksEqual( block, yblocks.get( index ) ) ) { + return index; + } + } + + return -1; +} + +function mergeYBlocksLocalSuffixAppend( + yblocks: YBlocks, + blocksToSync: Block[], + baseBlocks: Block[] +): void { + if ( blocksToSync.length <= baseBlocks.length || baseBlocks.length === 0 ) { + return; + } + + if ( + ! fastDeepEqual( + blocksToSync.slice( 0, baseBlocks.length ), + baseBlocks + ) + ) { + return; + } + + const anchorIndex = findStrictYBlockIndex( + yblocks, + baseBlocks[ baseBlocks.length - 1 ] + ); + + if ( anchorIndex === -1 ) { + return; + } + + let insertIndex = anchorIndex + 1; + + for ( const block of blocksToSync.slice( baseBlocks.length ) ) { + const existingIndex = findEquivalentYBlockIndex( yblocks, block ); + + if ( existingIndex !== -1 ) { + insertIndex = Math.max( insertIndex, existingIndex + 1 ); + continue; + } + + yblocks.insert( insertIndex, [ createNewYBlock( block ) ] ); + insertIndex++; + } +} + +function mergeYBlocksLocalChanges( + yblocks: YBlocks, + blocksToSync: Block[], + baseBlocks: Block[], + attributeCursor: MergeCursorPosition, + options: MergeCrdtBlocksOptions +): boolean { + if ( fastDeepEqual( blocksToSync, baseBlocks ) ) { + return true; + } + + if ( areYBlocksEqualToPlainBlocks( yblocks, baseBlocks ) ) { + return false; + } + + if ( + yblocks.length === baseBlocks.length && + blocksToSync.length === baseBlocks.length + ) { + return false; + } + + mergeYBlocksLocalSuffixAppend( yblocks, blocksToSync, baseBlocks ); + + const sharedLength = Math.min( baseBlocks.length, blocksToSync.length ); + + for ( let index = 0; index < sharedLength; index++ ) { + const baseBlock = baseBlocks[ index ]; + const block = blocksToSync[ index ]; + + if ( ! isSameBlockIdentity( baseBlock, block ) ) { + return false; + } + + if ( fastDeepEqual( baseBlock, block ) ) { + continue; + } + + const currentIndex = findYBlockIndex( + yblocks, + baseBlock, + index, + baseBlocks.length + ); + + if ( currentIndex === -1 ) { + continue; + } + + mergeBlockIntoYBlock( + yblocks.get( currentIndex ), + block, + attributeCursor, + options, + baseBlock + ); + } + + deleteRemovedLocalBlocks( yblocks, blocksToSync, baseBlocks ); + insertMissingLocalBlocks( yblocks, blocksToSync, baseBlocks, options ); + + return true; +} + +function deleteRemovedLocalBlocks( + yblocks: YBlocks, + blocksToSync: Block[], + baseBlocks: Block[] +): void { + const incomingClientIds = new Set( + blocksToSync + .map( getBlockClientId ) + .filter( ( clientId ): clientId is string => !! clientId ) + ); + + for ( const baseBlock of baseBlocks ) { + const clientId = getBlockClientId( baseBlock ); + + if ( ! clientId || incomingClientIds.has( clientId ) ) { + continue; + } + + const currentIndex = findYBlockIndexByClientId( yblocks, clientId ); + + if ( currentIndex !== -1 ) { + yblocks.delete( currentIndex, 1 ); + } + } +} + +function insertMissingLocalBlocks( + yblocks: YBlocks, + blocksToSync: Block[], + baseBlocks: Block[], + options: MergeCrdtBlocksOptions +): void { + const baseClientIds = new Set( + baseBlocks + .map( getBlockClientId ) + .filter( ( clientId ): clientId is string => !! clientId ) + ); + let insertIndex = 0; + let hasInsertionAnchor = true; + + for ( const block of blocksToSync ) { + const clientId = getBlockClientId( block ); + + if ( ! clientId ) { + continue; + } + + if ( baseClientIds.has( clientId ) ) { + const matchingIndex = findYBlockIndexByClientId( + yblocks, + clientId + ); + + if ( matchingIndex !== -1 ) { + insertIndex = Math.max( insertIndex, matchingIndex + 1 ); + hasInsertionAnchor = true; + } else { + hasInsertionAnchor = false; + } + continue; + } + + const matchingIndex = findYBlockIndexByClientId( yblocks, clientId ); + + if ( matchingIndex !== -1 ) { + mergeBlockIntoYBlock( + yblocks.get( matchingIndex ), + block, + null, + options + ); + insertIndex = Math.max( insertIndex, matchingIndex + 1 ); + hasInsertionAnchor = true; + continue; + } + + if ( ! hasInsertionAnchor ) { + continue; + } + + yblocks.insert( insertIndex, [ createNewYBlock( block ) ] ); + insertIndex++; + } +} + +/** + * Merge incoming block data into the local Y.Doc. + * This function is called to sync local block changes to a shared Y.Doc. + * + * @param yblocks The blocks in the local Y.Doc. + * @param incomingBlocks Gutenberg blocks being synced. + * @param attributeCursor When provided, describes a selection cursor falling within a + * RichText field associated with a specific block and attribute. + * Derived from the changes that produced the blocks. + * @param options Optional settings for the merge operation, or a legacy + * pre-change block snapshot used for rebasing. + */ +export function mergeCrdtBlocks( + yblocks: YBlocks, + incomingBlocks: Block[], + attributeCursor: MergeCursorPosition, + options: MergeCrdtBlocksArgument = {} +): void { + const mergeOptions = normalizeMergeCrdtBlocksOptions( options ); + + // Ensure we are working with serializable block data. + if ( ! serializableBlocksCache.has( incomingBlocks ) ) { + serializableBlocksCache.set( + incomingBlocks, + makeBlocksSerializable( incomingBlocks ) + ); + } + + const blocksToSync = serializableBlocksCache.get( incomingBlocks ) ?? []; + const explicitBaseBlocksToSync = mergeOptions.baseBlocks + ? makeBlocksSerializable( mergeOptions.baseBlocks ) + : undefined; + const cachedBaseBlocksToSync = previousLocalBlocksCache.get( yblocks ); + const useCachedLocalBlocksAsBase = shouldUseCachedLocalBlocksAsBase( + blocksToSync, + explicitBaseBlocksToSync, + cachedBaseBlocksToSync, + attributeCursor + ); + const baseBlocksToSync = useCachedLocalBlocksAsBase + ? cachedBaseBlocksToSync + : explicitBaseBlocksToSync ?? cachedBaseBlocksToSync; + + if ( + baseBlocksToSync && + mergeYBlocksLocalChanges( + yblocks, + blocksToSync, + baseBlocksToSync, + attributeCursor, + mergeOptions + ) + ) { + removeDuplicateClientIds( yblocks ); + previousLocalBlocksCache.set( yblocks, blocksToSync ); + return; + } + + if ( rebaseYBlocksByClientId( yblocks, baseBlocksToSync, blocksToSync ) ) { + mergeYBlocksByClientId( + yblocks, + blocksToSync, + attributeCursor, + mergeOptions, + baseBlocksToSync + ); + removeDuplicateClientIds( yblocks ); + previousLocalBlocksCache.set( yblocks, blocksToSync ); + return; + } + + if ( ! baseBlocksToSync ) { + reorderYBlocksByClientId( yblocks, blocksToSync ); + } + + // This is a rudimentary diff implementation similar to the y-prosemirror diffing + // approach. + // A better implementation would also diff the textual content and represent it + // using a Y.Text type. + // However, at this time it makes more sense to keep this algorithm generic to + // support all kinds of block types. + // Ideally, we ensure that block data structure have a consistent data format. + // E.g.: + // - textual content (using rich-text formatting?) may always be stored under `block.text` + // - local information that shouldn't be shared (e.g. clientId or isDragging) is stored under `block.private` + // + // @credit Kevin Jahns (dmonad) + // @link https://github.com/WordPress/gutenberg/pull/68483 + const numOfCommonEntries = Math.min( + blocksToSync.length ?? 0, + yblocks.length + ); + + let left = 0; + let right = 0; + + // skip equal blocks from left + for ( + ; + left < numOfCommonEntries && + areBlocksEqual( blocksToSync[ left ], yblocks.get( left ) ); + left++ + ) { + /* nop */ + } + + // skip equal blocks from right + for ( + ; + right < numOfCommonEntries - left && + areBlocksEqual( + blocksToSync[ blocksToSync.length - right - 1 ], + yblocks.get( yblocks.length - right - 1 ) + ); + right++ + ) { + /* nop */ + } + + const numOfUpdatesNeeded = numOfCommonEntries - left - right; + const numOfInsertionsNeeded = Math.max( + 0, + blocksToSync.length - yblocks.length + ); + const numOfDeletionsNeeded = Math.max( + 0, + yblocks.length - blocksToSync.length + ); + + // updates + for ( let i = 0; i < numOfUpdatesNeeded; i++, left++ ) { + const block = blocksToSync[ left ]; + const yblock = yblocks.get( left ); + + mergeBlockIntoYBlock( + yblock, + block, + attributeCursor, + mergeOptions, + baseBlocksToSync?.[ left ] + ); + } + + // deletes + yblocks.delete( left, numOfDeletionsNeeded ); + + // inserts + for ( let i = 0; i < numOfInsertionsNeeded; i++, left++ ) { + const newBlock = [ createNewYBlock( blocksToSync[ left ] ) ]; + + yblocks.insert( left, newBlock ); + } + + removeDuplicateClientIds( yblocks ); + previousLocalBlocksCache.set( yblocks, blocksToSync ); +} + +function removeDuplicateClientIds( yblocks: YBlocks ): void { + const knownClientIds = new Set< string >(); + for ( let j = 0; j < yblocks.length; j++ ) { + const yblock: YBlock = yblocks.get( j ); + + let clientId = yblock.get( 'clientId' ); + + if ( ! clientId ) { + continue; + } + + if ( knownClientIds.has( clientId ) ) { + clientId = uuidv4(); + yblock.set( 'clientId', clientId ); + } + knownClientIds.add( clientId ); + } +} + +/** + * Compare a plain array element against a Y.Map element for equality. + * Used by the left-right sweep diff in mergeYArray. + * + * @param newElement The plain object from the incoming array. + * @param yElement The Y.Map element from the existing Y.Array. + * @return True if the elements are deeply equal. + */ +function areArrayElementsEqual( + newElement: unknown, + yElement: unknown +): boolean { + if ( yElement instanceof Y.Map && isRecord( newElement ) ) { + return fastDeepEqual( + stripArrayElementIds( newElement ), + stripArrayElementIds( yElement.toJSON() ) + ); + } + + return fastDeepEqual( + stripArrayElementIds( newElement ), + stripArrayElementIds( yElement ) + ); +} + +function getArrayElementId( value: unknown ): string | undefined { + if ( value instanceof Y.Map ) { + const id = value.get( ARRAY_ELEMENT_ID_KEY ); + return typeof id === 'string' ? id : undefined; + } + + if ( isRecord( value ) ) { + const id = value[ ARRAY_ELEMENT_ID_KEY ]; + if ( typeof id === 'string' ) { + return id; + } + + const symbolId = ( value as Record< symbol, unknown > )[ + ARRAY_ELEMENT_ID_SYMBOL + ]; + return typeof symbolId === 'string' ? symbolId : undefined; + } + + return undefined; +} + +function defineArrayElementId( + value: Record< string, unknown >, + id: string +): void { + Object.defineProperty( value, ARRAY_ELEMENT_ID_SYMBOL, { + configurable: true, + enumerable: true, + value: id, + } ); +} + +function stripArrayElementIds( value: unknown ): unknown { + if ( Array.isArray( value ) ) { + return value.map( stripArrayElementIds ); + } + + if ( isRecord( value ) ) { + return Object.fromEntries( + Object.entries( value ) + .filter( ( [ key ] ) => key !== ARRAY_ELEMENT_ID_KEY ) + .map( ( [ key, innerValue ] ) => [ + key, + stripArrayElementIds( innerValue ), + ] ) + ); + } + + return value; +} + +function arePlainValuesEqual( a: unknown, b: unknown ): boolean { + return fastDeepEqual( + stripArrayElementIds( a ), + stripArrayElementIds( b ) + ); +} + +function isYArrayEqualToPlainArray( + yArray: Y.Array< unknown >, + value: unknown[] +): boolean { + return ( + yArray.length === value.length && + value.every( ( element, index ) => + areArrayElementsEqual( element, yArray.get( index ) ) + ) + ); +} + +function hasSharedArrayElementAnchor( + firstElement: unknown, + secondElement: unknown +): boolean { + const firstId = getArrayElementId( firstElement ); + const secondId = getArrayElementId( secondElement ); + + if ( firstId && secondId ) { + return firstId === secondId; + } + + const firstValue = + firstElement instanceof Y.Map ? firstElement.toJSON() : firstElement; + const secondValue = + secondElement instanceof Y.Map ? secondElement.toJSON() : secondElement; + + if ( arePlainValuesEqual( firstValue, secondValue ) ) { + return true; + } + + if ( Array.isArray( firstValue ) && Array.isArray( secondValue ) ) { + const sharedLength = Math.min( firstValue.length, secondValue.length ); + + for ( let index = 0; index < sharedLength; index++ ) { + if ( + hasSharedArrayElementAnchor( + firstValue[ index ], + secondValue[ index ] + ) + ) { + return true; + } + } + + return false; + } + + if ( isRecord( firstValue ) && isRecord( secondValue ) ) { + for ( const [ key, value ] of Object.entries( firstValue ) ) { + if ( + key === ARRAY_ELEMENT_ID_KEY || + ! Object.hasOwn( secondValue, key ) + ) { + continue; + } + + if ( hasSharedArrayElementAnchor( value, secondValue[ key ] ) ) { + return true; + } + } + } + + return false; +} + +function findYArrayElementIndex( + yArray: Y.Array< unknown >, + previousElement: unknown, + preferredIndex: number, + previousLength: number +): number { + const previousId = getArrayElementId( previousElement ); + + if ( previousId ) { + for ( let i = 0; i < yArray.length; i++ ) { + if ( getArrayElementId( yArray.get( i ) ) === previousId ) { + return i; + } + } + } + + for ( let i = 0; i < yArray.length; i++ ) { + if ( areArrayElementsEqual( previousElement, yArray.get( i ) ) ) { + return i; + } + } + + if ( yArray.length === previousLength && preferredIndex < yArray.length ) { + return preferredIndex; + } + + return preferredIndex < yArray.length ? preferredIndex : -1; +} + +function mergeYArrayLocalChanges( + yArray: Y.Array< unknown >, + newValue: unknown[], + previousValue: unknown[], + query: Record< string, BlockAttributeSchema >, + cursorPosition: MergeCursorPosition, + cursorScope: RichTextCursorScope +): boolean { + if ( arePlainValuesEqual( newValue, previousValue ) ) { + return true; + } + + // If the current CRDT value still equals the previous local value, use the + // normal merge path so local inserts/deletes/reorders are applied. + if ( isYArrayEqualToPlainArray( yArray, previousValue ) ) { + return false; + } + + if ( yArray.length === previousValue.length ) { + return false; + } + + const sharedLength = Math.min( previousValue.length, newValue.length ); + + for ( let i = 0; i < sharedLength; i++ ) { + const previousElement = previousValue[ i ]; + const newElement = newValue[ i ]; + + if ( arePlainValuesEqual( previousElement, newElement ) ) { + continue; + } + + const currentIndex = findYArrayElementIndex( + yArray, + previousElement, + i, + previousValue.length + ); + + if ( currentIndex === -1 ) { + continue; + } + + const currentElement = yArray.get( currentIndex ); + + if ( currentElement instanceof Y.Map && isRecord( newElement ) ) { + mergeYMapValues( + currentElement, + newElement, + query, + cursorPosition, + appendCursorScopeKey( cursorScope, currentIndex.toString() ), + isRecord( previousElement ) ? previousElement : undefined + ); + } + } + + for ( let i = sharedLength; i < newValue.length; i++ ) { + const newElement = newValue[ i ]; + + if ( i < yArray.length ) { + const currentElement = yArray.get( i ); + + if ( + currentElement instanceof Y.Map && + isRecord( newElement ) && + hasSharedArrayElementAnchor( currentElement, newElement ) + ) { + mergeYMapValues( + currentElement, + newElement, + query, + cursorPosition, + appendCursorScopeKey( cursorScope, i.toString() ) + ); + } + + continue; + } + + yArray.insert( i, [ createYMapFromQuery( query, newElement, true ) ] ); + } + + return true; +} + +function mergeYArrayByElementIds( + yArray: Y.Array< unknown >, + newValue: unknown[], + query: Record< string, BlockAttributeSchema >, + cursorPosition: MergeCursorPosition, + cursorScope: RichTextCursorScope +): boolean { + if ( ! newValue.some( getArrayElementId ) ) { + return false; + } + + let index = 0; + + for ( const newElement of newValue ) { + const newId = getArrayElementId( newElement ); + let currentIndex = -1; + + if ( newId ) { + for ( let i = index; i < yArray.length; i++ ) { + if ( getArrayElementId( yArray.get( i ) ) === newId ) { + currentIndex = i; + break; + } + } + } + + if ( currentIndex > index ) { + yArray.delete( index, currentIndex - index ); + } + + if ( currentIndex >= index ) { + const currentElement = yArray.get( index ); + if ( currentElement instanceof Y.Map && isRecord( newElement ) ) { + mergeYMapValues( + currentElement, + newElement, + query, + cursorPosition, + cursorScope + ); + } + } else { + yArray.insert( index, [ + createYMapFromQuery( query, newElement, true ), + ] ); + } + + index++; + } + + if ( yArray.length > index ) { + yArray.delete( index, yArray.length - index ); + } + + return true; +} + +/** + * Merge an incoming plain array into an existing Y.Array in-place. + * + * Uses the same left-right sweep diff approach as mergeCrdtBlocks: + * equal elements are skipped from both ends, then the middle section + * is updated, deleted, or inserted as needed. This preserves existing + * Y.Map/Y.Text objects for unchanged elements, so concurrent edits + * to those elements are not lost. + * + * @param yArray The existing Y.Array to update. + * @param newValue The new plain array to merge into the Y.Array. + * @param schema The attribute schema (must have `query`). + * @param cursorPosition The local cursor position for rich-text delta merges. + * @param cursorScope The selected block attribute scope for rich-text cursor hints. + * @param baseValue Optional pre-change array snapshot used for rebasing. + */ +function mergeYArray( + yArray: Y.Array< unknown >, + newValue: unknown[], + schema: BlockAttributeSchema, + cursorPosition: MergeCursorPosition, + cursorScope: RichTextCursorScope, + baseValue?: unknown +): void { + if ( ! schema.query ) { + return; + } + + const query = schema.query; + + if ( + Array.isArray( baseValue ) && + mergeYArrayLocalChanges( + yArray, + newValue, + baseValue, + query, + cursorPosition, + cursorScope + ) + ) { + return; + } + + if ( + Array.isArray( baseValue ) && + mergeYArrayWithBase( + yArray, + newValue, + schema, + cursorPosition, + cursorScope, + baseValue + ) + ) { + return; + } + + if ( + mergeYArrayByElementIds( + yArray, + newValue, + query, + cursorPosition, + cursorScope + ) + ) { + return; + } + + const numOfCommonEntries = Math.min( newValue.length, yArray.length ); + + let left = 0; + let right = 0; // Skip equal elements from left. for ( @@ -766,7 +1915,7 @@ function mergeYArray( newElement, query, cursorPosition, - cursorScope + appendCursorScopeKey( cursorScope, ( left + i ).toString() ) ); } else { // Element is the wrong type (e.g. partial migration) or the @@ -774,7 +1923,9 @@ function mergeYArray( yArray.delete( 0, yArray.length ); yArray.insert( 0, - newValue.map( ( item ) => createYMapFromQuery( query, item ) ) + newValue.map( ( item ) => + createYMapFromQuery( query, item, true ) + ) ); return; } @@ -802,7 +1953,8 @@ function mergeYArray( for ( let i = 0; i < numOfInsertionsNeeded; i++ ) { itemsToInsert[ i ] = createYMapFromQuery( query, - newValue[ insertAt + i ] + newValue[ insertAt + i ], + true ); } @@ -810,6 +1962,149 @@ function mergeYArray( } } +function mergeYArrayWithBase( + yArray: Y.Array< unknown >, + newValue: unknown[], + schema: BlockAttributeSchema, + cursorPosition: MergeCursorPosition, + cursorScope: RichTextCursorScope, + baseValue: unknown[] +): boolean { + if ( ! schema.query || yArray.length !== baseValue.length ) { + return false; + } + + const query = schema.query; + const numOfCommonEntries = Math.min( baseValue.length, newValue.length ); + + let left = 0; + let right = 0; + + for ( + ; + left < numOfCommonEntries && + arePlainValuesEqual( baseValue[ left ], newValue[ left ] ); + left++ + ) { + /* nop */ + } + + for ( + ; + right < numOfCommonEntries - left && + arePlainValuesEqual( + baseValue[ baseValue.length - right - 1 ], + newValue[ newValue.length - right - 1 ] + ); + right++ + ) { + /* nop */ + } + + if ( baseValue.length === newValue.length + 1 ) { + const preferredDeleteIndex = getPreferredSingleDeleteIndex( + yArray, + baseValue, + newValue + ); + + if ( preferredDeleteIndex !== undefined ) { + left = preferredDeleteIndex; + right = baseValue.length - preferredDeleteIndex - 1; + } + } + + const deleteCount = + baseValue.length === newValue.length + ? 0 + : baseValue.length - left - right; + const insertCount = + baseValue.length === newValue.length + ? 0 + : newValue.length - left - right; + + if ( deleteCount > 0 ) { + yArray.delete( left, deleteCount ); + } + + if ( insertCount > 0 ) { + yArray.insert( + left, + newValue + .slice( left, left + insertCount ) + .map( ( item ) => createYMapFromQuery( query, item, true ) ) + ); + } + + for ( let index = 0; index < newValue.length; index++ ) { + const isInserted = index >= left && index < left + insertCount; + let baseIndex: number | undefined; + if ( ! isInserted ) { + baseIndex = + index < left ? index : index - insertCount + deleteCount; + } + const newElement = newValue[ index ]; + + if ( + baseIndex !== undefined && + arePlainValuesEqual( baseValue[ baseIndex ], newElement ) + ) { + continue; + } + + const currentElement = yArray.get( index ); + if ( currentElement instanceof Y.Map && isRecord( newElement ) ) { + mergeYMapValues( + currentElement, + newElement, + query, + cursorPosition, + cursorScope, + baseIndex === undefined ? undefined : baseValue[ baseIndex ] + ); + continue; + } + + yArray.delete( 0, yArray.length ); + yArray.insert( + 0, + newValue.map( ( item ) => createYMapFromQuery( query, item, true ) ) + ); + break; + } + + return true; +} + +function getPreferredSingleDeleteIndex( + yArray: Y.Array< unknown >, + baseValue: unknown[], + newValue: unknown[] +): number | undefined { + let firstCandidate: number | undefined; + + for ( let index = 0; index < baseValue.length; index++ ) { + const candidateValue = [ + ...baseValue.slice( 0, index ), + ...baseValue.slice( index + 1 ), + ]; + + if ( ! arePlainValuesEqual( candidateValue, newValue ) ) { + continue; + } + + firstCandidate ??= index; + + if ( + areArrayElementsEqual( baseValue[ index ], yArray.get( index ) ) + ) { + return index; + } + } + + return firstCandidate; +} + /** * Merge a single value into a Y.Map entry, using the attribute schema to * decide how to merge. @@ -825,6 +2120,7 @@ function mergeYArray( * @param cursorPosition The cursor position for rich-text delta merges from the updated value. * @param cursorScope Indicates a specific block and attribute associated with the editor; * determines whether the cursor should be updated based on the change. + * @param baseVal Optional pre-change value used for rebasing. */ function mergeYValue( schema: BlockAttributeSchema | undefined, @@ -832,7 +2128,8 @@ function mergeYValue( yMap: Y.Map< unknown >, key: string, cursorPosition: MergeCursorPosition, - cursorScope: RichTextCursorScope + cursorScope: RichTextCursorScope, + baseVal?: unknown ): void { const currentVal = yMap.get( key ); if ( @@ -851,7 +2148,14 @@ function mergeYValue( Array.isArray( newVal ) && currentVal instanceof Y.Array ) { - mergeYArray( currentVal, newVal, schema, cursorPosition, cursorScope ); + mergeYArray( + currentVal, + newVal, + schema, + cursorPosition, + cursorScope, + baseVal + ); } else if ( schema?.type === 'object' && schema.query && @@ -863,7 +2167,8 @@ function mergeYValue( newVal, schema.query, cursorPosition, - cursorScope + cursorScope, + baseVal ); } else { const newYValue = createYValueFromSchema( schema, newVal ); @@ -888,44 +2193,62 @@ function mergeYValue( * @param query The query schema defining property types. * @param cursorPosition The local cursor position for rich-text delta merges. * @param cursorScope The selected block attribute scope for rich-text cursor hints. + * @param baseObj Optional pre-change object used for rebasing. */ function mergeYMapValues( yMap: Y.Map< unknown >, newObj: Record< string, unknown >, query: Record< string, BlockAttributeSchema >, cursorPosition: MergeCursorPosition, - cursorScope: RichTextCursorScope + cursorScope: RichTextCursorScope, + baseObj?: unknown ): void { + const baseRecord = isRecord( baseObj ) ? baseObj : undefined; + for ( const [ key, newVal ] of Object.entries( newObj ) ) { + if ( + baseRecord && + Object.hasOwn( baseRecord, key ) && + fastDeepEqual( baseRecord[ key ], newVal ) + ) { + continue; + } + mergeYValue( query[ key ], newVal, yMap, key, cursorPosition, - cursorScope + appendCursorScopeKey( cursorScope, key ), + baseRecord?.[ key ] ); } // Delete properties absent from the incoming object. for ( const key of yMap.keys() ) { - if ( ! Object.hasOwn( newObj, key ) ) { - yMap.delete( key ); + if ( key === ARRAY_ELEMENT_ID_KEY || Object.hasOwn( newObj, key ) ) { + continue; + } + if ( baseRecord && ! Object.hasOwn( baseRecord, key ) ) { + continue; } + yMap.delete( key ); } } /** * Update a single attribute on a Yjs block attributes map (currentAttributes). * - * @param blockName The block type name, e.g. 'core/paragraph'. - * @param clientId The local clientId for the block being merged. - * @param attributeName The name of the attribute to update, e.g. 'content'. - * @param attributeValue The new value for the attribute. - * @param currentAttributes The Y.Map holding the block's current attributes. - * @param newCursorPosition The cursor position for rich-text delta merges from the updated value. - * Notably, this may not correspond to the attribute being edited and is - * used to determine if any cursors need shifting in response to the change. + * @param blockName The block type name, e.g. 'core/paragraph'. + * @param clientId The local clientId for the block being merged. + * @param attributeName The name of the attribute to update, e.g. 'content'. + * @param attributeValue The new value for the attribute. + * @param currentAttributes The Y.Map holding the block's current attributes. + * @param newCursorPosition The cursor position for rich-text delta merges from the updated value. + * Notably, this may not correspond to the attribute being edited and is + * used to determine if any cursors need shifting in response to the change. + * @param baseAttributeValue Optional pre-change attribute value used for rebasing. */ function updateYBlockAttribute( blockName: string, @@ -933,7 +2256,8 @@ function updateYBlockAttribute( attributeName: string, attributeValue: unknown, currentAttributes: YBlockAttributes, - newCursorPosition: MergeCursorPosition + newCursorPosition: MergeCursorPosition, + baseAttributeValue?: unknown ): void { const schema = getBlockAttributeSchema( blockName, attributeName ); @@ -952,7 +2276,8 @@ function updateYBlockAttribute( currentAttributes, attributeName, newCursorPosition, - { attributeKey: attributeName, clientId } + { attributeKey: attributeName, clientId }, + baseAttributeValue ); } @@ -968,6 +2293,16 @@ interface RichTextCursorScope { clientId: string | undefined; } +function appendCursorScopeKey( + cursorScope: RichTextCursorScope, + key: string +): RichTextCursorScope { + return { + ...cursorScope, + attributeKey: `${ cursorScope.attributeKey }.${ key }`, + }; +} + interface DeltaWithOps { ops: Parameters< Y.Text[ 'applyDelta' ] >[ 0 ]; } diff --git a/packages/core-data/src/utils/crdt-selection.ts b/packages/core-data/src/utils/crdt-selection.ts index 61cdf94932aced..d41935b6d5c7ef 100644 --- a/packages/core-data/src/utils/crdt-selection.ts +++ b/packages/core-data/src/utils/crdt-selection.ts @@ -20,6 +20,8 @@ import { import { asHtmlStringIndex, findBlockByClientIdInDoc, + getAttributeKeyForYText, + getYTextByAttributeKey, htmlIndexToRichTextOffset, } from './crdt-utils'; import type { WPBlockSelection, WPSelection } from '../types'; @@ -67,16 +69,33 @@ function convertYSelectionToBlockSelection( ): WPBlockSelection | null { if ( ySelection.type === YSelectionType.RelativeSelection ) { const { relativePosition, attributeKey, clientId } = ySelection; + const block = findBlockByClientIdInDoc( clientId, ydoc ); + const attributes = block?.get( 'attributes' ); const absolutePosition = Y.createAbsolutePositionFromRelativePosition( relativePosition, ydoc ); - if ( absolutePosition ) { + if ( + absolutePosition && + attributes instanceof Y.Map && + absolutePosition.type instanceof Y.Text + ) { + const currentAttributeKey = + getAttributeKeyForYText( attributes, absolutePosition.type ) ?? + ( getYTextByAttributeKey( attributes, attributeKey ) === + absolutePosition.type + ? attributeKey + : null ); + + if ( ! currentAttributeKey ) { + return null; + } + return { clientId, - attributeKey, + attributeKey: currentAttributeKey, offset: htmlIndexToRichTextOffset( absolutePosition.type.toString(), asHtmlStringIndex( absolutePosition.index ) diff --git a/packages/core-data/src/utils/crdt-user-selections.ts b/packages/core-data/src/utils/crdt-user-selections.ts index a0c55a78021533..c88f1e5842b0b1 100644 --- a/packages/core-data/src/utils/crdt-user-selections.ts +++ b/packages/core-data/src/utils/crdt-user-selections.ts @@ -394,6 +394,10 @@ function areCursorPositionsEqual( // This is necessary because Y.Text relative positions can remain the same after text changes. const isAbsoluteOffsetEqual = cursorPosition1.absoluteOffset === cursorPosition2.absoluteOffset; + const isAttributeKeyEqual = + cursorPosition1.attributeKey === cursorPosition2.attributeKey; - return isRelativePositionEqual && isAbsoluteOffsetEqual; + return ( + isRelativePositionEqual && isAbsoluteOffsetEqual && isAttributeKeyEqual + ); } diff --git a/packages/core-data/src/utils/crdt-utils.ts b/packages/core-data/src/utils/crdt-utils.ts index ad9cbc03c10d84..e902121f90656c 100644 --- a/packages/core-data/src/utils/crdt-utils.ts +++ b/packages/core-data/src/utils/crdt-utils.ts @@ -166,6 +166,83 @@ export function getYTextByAttributeKey( return value instanceof Y.Text ? value : null; } +/** + * Resolve the current RichText attribute key for a Y.Text by walking the + * block attributes tree. Direct top-level keys are checked first to preserve + * the lookup semantics of getYTextByAttributeKey for keys that contain dots. + * + * @param attributes The block attributes map. + * @param yText The Y.Text to locate. + * @return The current attribute key, or null when no representable key exists. + */ +export function getAttributeKeyForYText( + attributes: Y.Map< unknown >, + yText: Y.Text +): string | null { + for ( const key of attributes.keys() ) { + if ( attributes.get( key ) === yText ) { + return key; + } + } + + for ( const key of attributes.keys() ) { + if ( key.includes( '.' ) ) { + continue; + } + + const path = findYTextPath( attributes.get( key ), yText, [ key ] ); + if ( path ) { + return path.join( '.' ); + } + } + + return null; +} + +function findYTextPath( + value: unknown, + yText: Y.Text, + path: string[] +): string[] | null { + if ( value === yText ) { + return path; + } + + if ( value instanceof Y.Text ) { + return null; + } + + if ( value instanceof Y.Map ) { + for ( const key of value.keys() ) { + if ( key.includes( '.' ) ) { + continue; + } + + const nestedPath = findYTextPath( value.get( key ), yText, [ + ...path, + key, + ] ); + if ( nestedPath ) { + return nestedPath; + } + } + } + + if ( value instanceof Y.Array ) { + for ( let index = 0; index < value.length; index++ ) { + const nestedPath = findYTextPath( value.get( index ), yText, [ + ...path, + index.toString(), + ] ); + if ( nestedPath ) { + return nestedPath; + } + } + } + + return null; +} + /** * Given a block ID and a Y.Doc, find the block in the document. * diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index 635192a7ec4f40..0abdf771bd3cd3 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -8,7 +8,6 @@ import fastDeepEqual from 'fast-deep-equal/es6/index.js'; */ import { __unstableSerializeAndClean, - parse, type Block as WPBlock, } from '@wordpress/blocks'; import { @@ -17,6 +16,7 @@ import { type ObjectID, type ObjectType, type SyncConfig, + type SyncManagerUpdateOptions, Y, } from '@wordpress/sync'; @@ -50,21 +50,20 @@ import { type YMapWrap, } from './crdt-utils'; -// A function that derives content from blocks. Two callers produce this: -// `useEntityBlockEditor` reads blocks from its argument (so the optional arg -// lets it accept whatever caller is invoked with), and the receiver-side -// injection in this file captures blocks in a closure and ignores the arg. -type ContentFromBlocksFn = ( args?: { blocks: Block[] } ) => string; - // Changes that can be applied to a post entity record. -export type PostChanges = Partial< Post > & { +export type PostChanges = Omit< + Partial< Post >, + 'blocks' | 'content' | 'excerpt' | 'selection' | 'title' +> & { blocks?: Block[]; - content?: Post[ 'content' ] | string | ContentFromBlocksFn; + content?: Post[ 'content' ] | string; excerpt?: Post[ 'excerpt' ] | string; selection?: WPSelection; title?: Post[ 'title' ] | string; }; +type PostWithTransientBlocks = Post & { blocks?: Block[] }; + // A post record as represented in the CRDT document (Y.Map). export interface YPostRecord extends YMapRecord { author: number; @@ -132,14 +131,21 @@ function defaultApplyChangesToCRDTDoc( * @param {CRDTDoc} ydoc * @param {PostChanges} changes * @param {Set} syncedProperties + * @param {Object} options + * @param {ObjectData} options.baseRecord * @return {void} */ export function applyPostChangesToCRDTDoc( ydoc: CRDTDoc, changes: PostChanges, - syncedProperties: Set< string > + syncedProperties: Set< string >, + options: SyncManagerUpdateOptions = {} ): void { const ymap = getRootMap< YPostRecord >( ydoc, CRDT_RECORD_MAP_KEY ); + const shouldDeriveContentFromBlocks = + syncedProperties.has( 'content' ) && Array.isArray( changes.blocks ); + const baseRecord = options.baseRecord as PostChanges | undefined; + const baseBlocks = options.isSave ? undefined : baseRecord?.blocks; Object.keys( changes ).forEach( ( key ) => { if ( ! syncedProperties.has( key ) ) { @@ -148,45 +154,25 @@ export function applyPostChangesToCRDTDoc( const newValue = changes[ key ]; - // Cannot serialize function values, so cannot sync them. `content` is - // often passed as a lazy serializer by `useEntityBlockEditor`; the - // receiver re-derives it from the synced blocks (see - // getPostChangesFromCRDTDoc), so dropping it here is intentional. + // Cannot serialize function values, so cannot sync them. if ( 'function' === typeof newValue ) { return; } switch ( key ) { case 'blocks': { - // Block changes from typing are bundled with a 'selection' update. - // Use the resulting cursor position for block merging. - const newCursorPosition = parseCursorSelection( - changes.selection - ); - // Blocks are undefined when they need to be re-parsed from content. - // When new content is also part of this change (e.g. the Code - // Editor dispatching `{ content, blocks: undefined }` on every - // keystroke), derive blocks from content so the merge keeps - // stable YBlock identities for unchanged blocks. - - const rawContent = getRawValue( changes.content ); - if ( ! newValue && typeof rawContent === 'string' ) { - // We have no blocks but an updated content string. - mergeContentWithoutBlocks( - ymap, - rawContent, - newCursorPosition - ); - break; - } else if ( ! newValue ) { - // We have an update containing empty blocks and content. + if ( ! newValue ) { // Set to undefined instead of deleting the key. This is important // since we iterate over the Y.Map keys in getPostChangesFromCRDTDoc. ymap.set( key, undefined ); break; } + if ( syncedProperties.has( 'content' ) ) { + ymap.delete( 'content' ); + } + let currentBlocks = ymap.get( key ); // Initialize. @@ -195,15 +181,30 @@ export function applyPostChangesToCRDTDoc( ymap.set( key, currentBlocks ); } + // Block changes from typing are bundled with a 'selection' update. + // Pass the resulting cursor position to the mergeCrdtBlocks function. + const newCursorPosition = parseCursorSelection( + changes.selection + ); + // Merge blocks does not need `setValue` because it is operating on a // Yjs type that is already in the Y.Doc. - mergeCrdtBlocks( currentBlocks, newValue, newCursorPosition ); + mergeCrdtBlocks( + currentBlocks, + newValue, + newCursorPosition, + baseBlocks + ); break; } case 'content': case 'excerpt': case 'title': { + if ( key === 'content' && shouldDeriveContentFromBlocks ) { + break; + } + const currentValue = ymap.get( key ); let rawValue = getRawValue( newValue ); @@ -277,6 +278,23 @@ export function applyPostChangesToCRDTDoc( } } ); + if ( shouldDeriveContentFromBlocks ) { + const currentBlocks = ymap.get( 'blocks' ); + + if ( currentBlocks instanceof Y.Array ) { + const currentValue = ymap.get( 'content' ); + const rawValue = __unstableSerializeAndClean( + currentBlocks.toJSON() + ).trim(); + + if ( currentValue instanceof Y.Text ) { + mergeRichTextUpdate( currentValue, rawValue ); + } else { + ymap.set( 'content', new Y.Text( rawValue ) ); + } + } + } + // Process changes that we don't want to persist to the CRDT document. if ( changes.selection ) { const selection = changes.selection; @@ -291,37 +309,6 @@ export function applyPostChangesToCRDTDoc( } } -/** - * Derive blocks from a raw content string and merge them into the post's - * blocks Y.Array. Used when a caller dispatches a change with `blocks: - * undefined` alongside new content, most notably the Code Editor's - * per-keystroke dispatch. - * - * @param ymap The post's root Y.Map. - * @param rawContent The raw HTML content to parse. - * @param cursorPosition Cursor position derived from the change's selection, - * used by mergeCrdtBlocks for rich-text cursor hints. - */ -function mergeContentWithoutBlocks( - ymap: YMapWrap< YPostRecord >, - rawContent: string, - cursorPosition: MergeCursorPosition -): void { - let currentBlocks = ymap.get( 'blocks' ); - - if ( ! ( currentBlocks instanceof Y.Array ) ) { - currentBlocks = new Y.Array< YBlock >(); - ymap.set( 'blocks', currentBlocks ); - } - - mergeCrdtBlocks( - currentBlocks, - parse( rawContent ) as Block[], - cursorPosition, - { preserveClientIds: true } - ); -} - /** * Only returns a selection object if it describes a selection within a block, with * a cursor inside a RichText field associated with one of that block’s attributes. @@ -349,6 +336,126 @@ function defaultGetChangesFromCRDTDoc( crdtDoc: CRDTDoc ): ObjectData { return getRootMap( crdtDoc, CRDT_RECORD_MAP_KEY ).toJSON(); } +function serializeBlocks( blocks: Block[] ): string { + return __unstableSerializeAndClean( blocks as unknown as WPBlock[] ); +} + +function getGeneratedBlockSerialization( blocks: Block[] ): string { + return serializeAndCleanBlocks( + getGeneratedBlockSerializationBlocks( blocks ) + ); +} + +function serializeAndCleanBlocks( blocks: Block[] ): string { + return serializeBlocks( blocks ).trim(); +} + +function getGeneratedBlockSerializationBlocks( blocks: Block[] ): Block[] { + return blocks.map( ( block ) => { + const innerBlocks = getGeneratedBlockSerializationBlocks( + block.innerBlocks ?? [] + ); + + if ( + block.isValid !== false || + typeof block.originalContent !== 'string' + ) { + return { + ...block, + innerBlocks, + }; + } + + const generatedBlock: Block & { __unstableBlockSource?: unknown } = { + ...block, + isValid: true, + innerBlocks, + }; + delete generatedBlock.__unstableBlockSource; + delete generatedBlock.originalContent; + delete generatedBlock.validationIssues; + + return generatedBlock; + } ); +} + +function hasInvalidBlockOriginalContent( blocks: Block[] ): boolean { + return blocks.some( + ( block ) => + ( block.isValid === false && + typeof block.originalContent === 'string' ) || + hasInvalidBlockOriginalContent( block.innerBlocks ?? [] ) + ); +} + +function parseHTMLFragmentForComparison( + html: string +): DocumentFragment | null { + if ( typeof document === 'undefined' ) { + return null; + } + + const template = document.createElement( 'template' ); + template.innerHTML = html; + + for ( const childNode of Array.from( template.content.childNodes ) ) { + if ( + childNode.nodeType === Node.TEXT_NODE && + childNode.textContent?.trim() === '' + ) { + childNode.remove(); + } + } + + return template.content; +} + +function areHTMLFragmentsEquivalent( first: string, second: string ): boolean { + const firstFragment = parseHTMLFragmentForComparison( first ); + const secondFragment = parseHTMLFragmentForComparison( second ); + + return !! firstFragment && firstFragment.isEqualNode( secondFragment ); +} + +function hasPersistedBlockContentChanged( + blocks: Block[], + persistedContent: string | undefined +): boolean { + if ( persistedContent === undefined ) { + return true; + } + + const rawPersistedContent = persistedContent; + const serializedBlocks = serializeAndCleanBlocks( blocks ); + + if ( serializedBlocks === rawPersistedContent ) { + return false; + } + + // Invalid parsed blocks preserve originalContent to avoid data loss. When a + // save round-trip normalizes equivalent HTML entities, originalContent may + // differ from the server value even though the block attributes still + // serialize to the server's canonical content. Treat that as unchanged so + // the persisted CRDT doc is not invalidated on every save/reload cycle. + if ( ! hasInvalidBlockOriginalContent( blocks ) ) { + return true; + } + + try { + const generatedSerialization = getGeneratedBlockSerialization( blocks ); + + return ( + generatedSerialization !== rawPersistedContent && + ! areHTMLFragmentsEquivalent( + generatedSerialization, + rawPersistedContent + ) + ); + } catch { + return true; + } +} + /** * Given a local Y.Doc that *may* contain changes from remote peers, compare * against the local record and determine if there are changes (edits) we want @@ -400,10 +507,18 @@ export function getPostChangesFromCRDTDoc( editedRecord.content ) { const blocksJson = ymap.get( 'blocks' )?.toJSON() ?? []; + const editedRecordBlocks = ( + editedRecord as PostWithTransientBlocks + ).blocks; + const persistedContent = Array.isArray( + editedRecordBlocks + ) + ? serializeBlocks( editedRecordBlocks ).trim() + : getRawValue( editedRecord.content ); - return ( - __unstableSerializeAndClean( blocksJson ).trim() !== - getRawValue( editedRecord.content ) + return hasPersistedBlockContentChanged( + blocksJson, + persistedContent ); } @@ -466,6 +581,13 @@ export function getPostChangesFromCRDTDoc( case 'content': case 'excerpt': case 'title': { + if ( + key === 'content' && + ymap.get( 'blocks' ) instanceof Y.Array + ) { + return false; + } + return haveValuesChanged( getRawValue( currentValue ), newValue @@ -490,21 +612,6 @@ export function getPostChangesFromCRDTDoc( ); } - // When blocks changed but content didn't (the sender internally used a lazy - // serializer function), inject a closure that captures the synced blocks - // and serializes them on demand. Mirrors what useEntityBlockEditor does - // locally. A fresh function on every persistent edit marks the entity - // dirty (so the save button reactivates for peers), while serialization - // stays lazy (only runs when getEditedPostContent reads it). The closure - // captures `capturedBlocks` so the right content is returned even if the - // caller later clears `record.blocks` (e.g. the Code Editor re-parsing - // from content). - if ( changes.blocks && ! changes.content ) { - const capturedBlocks = changes.blocks; - changes.content = () => - __unstableSerializeAndClean( capturedBlocks as WPBlock[] ); - } - // Meta changes must be merged with the edited record since not all meta // properties are synced. if ( 'object' === typeof changes.meta ) { diff --git a/packages/core-data/src/utils/test/crdt-blocks.ts b/packages/core-data/src/utils/test/crdt-blocks.ts index 306a541b5c5d6e..edcd2943cc6f0b 100644 --- a/packages/core-data/src/utils/test/crdt-blocks.ts +++ b/packages/core-data/src/utils/test/crdt-blocks.ts @@ -331,6 +331,83 @@ describe( 'crdt-blocks', () => { expect( innerBlock.get( 'name' ) ).toBe( 'core/paragraph' ); } ); + it( 'does not duplicate unchanged innerBlocks when parsed clientIds change', () => { + const initialBlocks: Block[] = [ + { + name: 'core/group', + attributes: {}, + clientId: 'group-1', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: 'Nested one' }, + clientId: 'old-inner-1', + innerBlocks: [], + }, + { + name: 'core/paragraph', + attributes: { content: 'Nested two' }, + clientId: 'old-inner-2', + innerBlocks: [], + }, + ], + }, + ]; + + mergeCrdtBlocks( yblocks, initialBlocks, null ); + + const reparsedBlocks: Block[] = [ + { + name: 'core/group', + attributes: {}, + clientId: 'group-1', + innerBlocks: [ + { + name: 'core/paragraph', + attributes: { content: 'Nested one' }, + clientId: 'new-inner-1', + innerBlocks: [], + }, + { + name: 'core/paragraph', + attributes: { content: 'Nested two' }, + clientId: 'new-inner-2', + innerBlocks: [], + }, + ], + }, + ]; + const baseBlocksWithMissingInnerBlocks: Block[] = [ + { + ...reparsedBlocks[ 0 ], + innerBlocks: [], + }, + ]; + + mergeCrdtBlocks( + yblocks, + reparsedBlocks, + null, + baseBlocksWithMissingInnerBlocks + ); + + const innerBlocks = yblocks + .get( 0 ) + .get( 'innerBlocks' ) as YBlocks; + expect( innerBlocks.length ).toBe( 2 ); + expect( + innerBlocks + .toArray() + .map( ( block ) => + ( + ( + block.get( 'attributes' ) as YBlockAttributes + ).get( 'content' ) as Y.Text + ).toString() + ) + ).toEqual( [ 'Nested one', 'Nested two' ] ); + } ); + it( 'strips local attributes when syncing blocks', () => { const imageWithBlob: Block[] = [ { @@ -434,6 +511,293 @@ describe( 'crdt-blocks', () => { expect( content1.toString() ).toBe( 'First' ); } ); + it( 'preserves concurrent list item moves by client ID', () => { + const createListBlock = ( itemOrder: string[] ): Block[] => [ + { + name: 'core/list', + attributes: {}, + innerBlocks: itemOrder.map( ( item ) => ( { + name: 'core/list-item', + attributes: { content: `Item ${ item }` }, + innerBlocks: [], + clientId: `item-${ item.toLowerCase() }`, + } ) ), + clientId: 'list-block', + }, + ]; + const getListItems = ( checkBlocks: YBlocks ): string[] => + ( checkBlocks.get( 0 ).get( 'innerBlocks' ) as YBlocks ) + .toArray() + .map( ( item ) => + ( + ( + item.get( 'attributes' ) as YBlockAttributes + ).get( 'content' ) as Y.Text + ).toString() + ); + const initialOrder = [ + 'Alpha', + 'Beta', + 'Gamma', + 'Delta', + 'Epsilon', + 'Zeta', + ]; + + mergeCrdtBlocks( yblocks, createListBlock( initialOrder ), null ); + + const doc2 = new Y.Doc(); + const yblocks2 = doc2.getArray< YBlock >(); + Y.applyUpdate( doc2, Y.encodeStateAsUpdate( doc ) ); + + mergeCrdtBlocks( + yblocks, + createListBlock( [ + 'Alpha', + 'Gamma', + 'Beta', + 'Delta', + 'Epsilon', + 'Zeta', + ] ), + null + ); + mergeCrdtBlocks( + yblocks2, + createListBlock( [ + 'Alpha', + 'Beta', + 'Gamma', + 'Epsilon', + 'Delta', + 'Zeta', + ] ), + null + ); + + const updateA = Y.encodeStateAsUpdate( doc ); + const updateB = Y.encodeStateAsUpdate( doc2 ); + Y.applyUpdate( doc2, updateA ); + Y.applyUpdate( doc, updateB ); + + for ( const checkBlocks of [ yblocks, yblocks2 ] ) { + expect( getListItems( checkBlocks ) ).toEqual( [ + 'Item Alpha', + 'Item Gamma', + 'Item Beta', + 'Item Epsilon', + 'Item Delta', + 'Item Zeta', + ] ); + } + + doc2.destroy(); + } ); + + it( 'rebases a delayed list item move over a remote list item move', () => { + const createListBlock = ( itemOrder: string[] ): Block[] => [ + { + name: 'core/list', + attributes: {}, + innerBlocks: itemOrder.map( ( item ) => ( { + name: 'core/list-item', + attributes: { content: `Item ${ item }` }, + innerBlocks: [], + clientId: `item-${ item.toLowerCase() }`, + } ) ), + clientId: 'list-block', + }, + ]; + const getListItems = ( checkBlocks: YBlocks ): string[] => + ( checkBlocks.get( 0 ).get( 'innerBlocks' ) as YBlocks ) + .toArray() + .map( ( item ) => + ( + ( + item.get( 'attributes' ) as YBlockAttributes + ).get( 'content' ) as Y.Text + ).toString() + ); + const initialBlocks = createListBlock( [ + 'Alpha', + 'Beta', + 'Gamma', + 'Delta', + 'Epsilon', + 'Zeta', + ] ); + + mergeCrdtBlocks( yblocks, initialBlocks, null ); + mergeCrdtBlocks( + yblocks, + createListBlock( [ + 'Alpha', + 'Beta', + 'Gamma', + 'Epsilon', + 'Delta', + 'Zeta', + ] ), + null, + initialBlocks + ); + mergeCrdtBlocks( + yblocks, + createListBlock( [ + 'Alpha', + 'Gamma', + 'Beta', + 'Delta', + 'Epsilon', + 'Zeta', + ] ), + null, + initialBlocks + ); + + expect( getListItems( yblocks ) ).toEqual( [ + 'Item Alpha', + 'Item Gamma', + 'Item Beta', + 'Item Epsilon', + 'Item Delta', + 'Item Zeta', + ] ); + } ); + + it( 'rebases a delayed list item move when equivalent blocks have different client IDs', () => { + const createListBlock = ( + itemOrder: string[], + clientIdPrefix: string + ): Block[] => [ + { + name: 'core/list', + attributes: {}, + innerBlocks: itemOrder.map( ( item ) => ( { + name: 'core/list-item', + attributes: { content: `Item ${ item }` }, + innerBlocks: [], + clientId: `${ clientIdPrefix }-${ item.toLowerCase() }`, + } ) ), + clientId: `${ clientIdPrefix }-list-block`, + }, + ]; + const getListItems = ( checkBlocks: YBlocks ): string[] => + ( checkBlocks.get( 0 ).get( 'innerBlocks' ) as YBlocks ) + .toArray() + .map( ( item ) => + ( + ( + item.get( 'attributes' ) as YBlockAttributes + ).get( 'content' ) as Y.Text + ).toString() + ); + const initialBlocks = createListBlock( + [ 'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta' ], + 'local' + ); + + mergeCrdtBlocks( yblocks, initialBlocks, null ); + mergeCrdtBlocks( + yblocks, + createListBlock( + [ 'Alpha', 'Gamma', 'Beta', 'Delta', 'Epsilon', 'Zeta' ], + 'remote' + ), + null + ); + mergeCrdtBlocks( + yblocks, + createListBlock( + [ 'Alpha', 'Beta', 'Gamma', 'Epsilon', 'Delta', 'Zeta' ], + 'local' + ), + null, + initialBlocks + ); + + expect( getListItems( yblocks ) ).toEqual( [ + 'Item Alpha', + 'Item Gamma', + 'Item Beta', + 'Item Epsilon', + 'Item Delta', + 'Item Zeta', + ] ); + } ); + + it( 'does not overwrite remote list item content while rebasing a delayed move', () => { + const createListBlock = ( + itemOrder: string[], + contentByItem: Record< string, string > = {} + ): Block[] => [ + { + name: 'core/list', + attributes: {}, + innerBlocks: itemOrder.map( ( item ) => ( { + name: 'core/list-item', + attributes: { + content: contentByItem[ item ] ?? `Item ${ item }`, + }, + innerBlocks: [], + clientId: `item-${ item.toLowerCase() }`, + } ) ), + clientId: 'list-block', + }, + ]; + const getListItems = ( checkBlocks: YBlocks ): string[] => + ( checkBlocks.get( 0 ).get( 'innerBlocks' ) as YBlocks ) + .toArray() + .map( ( item ) => + ( + ( + item.get( 'attributes' ) as YBlockAttributes + ).get( 'content' ) as Y.Text + ).toString() + ); + const initialBlocks = createListBlock( [ + 'Alpha', + 'Beta', + 'Gamma', + 'Delta', + 'Epsilon', + 'Zeta', + ] ); + + mergeCrdtBlocks( yblocks, initialBlocks, null ); + mergeCrdtBlocks( + yblocks, + createListBlock( + [ 'Alpha', 'Beta', 'Gamma', 'Epsilon', 'Delta', 'Zeta' ], + { Epsilon: 'Item Epsilon remote edit' } + ), + null, + initialBlocks + ); + mergeCrdtBlocks( + yblocks, + createListBlock( [ + 'Alpha', + 'Gamma', + 'Beta', + 'Delta', + 'Epsilon', + 'Zeta', + ] ), + null, + initialBlocks + ); + + expect( getListItems( yblocks ) ).toEqual( [ + 'Item Alpha', + 'Item Gamma', + 'Item Beta', + 'Item Epsilon remote edit', + 'Item Delta', + 'Item Zeta', + ] ); + } ); + it( 'creates Y.Text for rich-text attributes', () => { const blocks: Block[] = [ { @@ -1901,6 +2265,212 @@ describe( 'crdt-blocks', () => { doc2.destroy(); } ); + it( 'preserves a remote-edited duplicate table row when a stale local snapshot deletes the earlier duplicate row', () => { + const createTableBlocks = ( + body: { + cells: { content: string; tag: string }[]; + }[] + ): Block[] => [ + { + name: 'core/table', + clientId: 'table-block', + attributes: { body }, + innerBlocks: [], + }, + ]; + const initialBody = [ + { + cells: [ { content: 'anchor', tag: 'td' } ], + }, + { + cells: [ { content: 'same', tag: 'td' } ], + }, + { + cells: [ { content: 'same', tag: 'td' } ], + }, + ]; + const initialBlocks = createTableBlocks( initialBody ); + + mergeCrdtBlocks( yblocks, initialBlocks, null ); + + mergeCrdtBlocks( + yblocks, + createTableBlocks( [ + { + cells: [ { content: 'anchor', tag: 'td' } ], + }, + { + cells: [ { content: 'same', tag: 'td' } ], + }, + { + cells: [ + { content: 'edited-duplicate', tag: 'td' }, + { content: 'extra', tag: 'td' }, + ], + }, + ] ), + null, + initialBlocks + ); + + mergeCrdtBlocks( + yblocks, + createTableBlocks( [ + { + cells: [ { content: 'anchor', tag: 'td' } ], + }, + { + cells: [ { content: 'same', tag: 'td' } ], + }, + ] ), + null, + initialBlocks + ); + + const attrs = yblocks + .get( 0 ) + .get( 'attributes' ) as YBlockAttributes; + const body = ( + attrs.get( 'body' ) as Y.Array< unknown > + ).toJSON() as { cells: { content: string }[] }[]; + + expect( body ).toHaveLength( 2 ); + expect( body[ 0 ].cells[ 0 ].content ).toBe( 'anchor' ); + expect( body[ 1 ].cells[ 0 ].content ).toBe( 'edited-duplicate' ); + expect( body[ 1 ].cells[ 1 ].content ).toBe( 'extra' ); + } ); + + it( 'preserves remote sibling fields when a stale local nested object changes another field', () => { + const createTableBlocks = ( cell: { + content: string; + tag: string; + } ): Block[] => [ + { + name: 'core/table', + clientId: 'table-block', + attributes: { + body: [ + { + cells: [ cell ], + }, + ], + }, + innerBlocks: [], + }, + ]; + const initialBlocks = createTableBlocks( { + content: 'same', + tag: 'td', + } ); + + mergeCrdtBlocks( yblocks, initialBlocks, null ); + + mergeCrdtBlocks( + yblocks, + createTableBlocks( { + content: 'edited-remotely', + tag: 'td', + } ), + null, + initialBlocks + ); + + mergeCrdtBlocks( + yblocks, + createTableBlocks( { + content: 'same', + tag: 'th', + } ), + null, + initialBlocks + ); + + const attrs = yblocks + .get( 0 ) + .get( 'attributes' ) as YBlockAttributes; + const body = ( + attrs.get( 'body' ) as Y.Array< unknown > + ).toJSON() as { cells: { content: string; tag: string }[] }[]; + + expect( body[ 0 ].cells[ 0 ].content ).toBe( 'edited-remotely' ); + expect( body[ 0 ].cells[ 0 ].tag ).toBe( 'th' ); + } ); + + it( 'merges local edits made to a remotely inserted table row', () => { + const createTableBlocks = ( + body: { + cells: { content: string; tag: string }[]; + }[] + ): Block[] => [ + { + name: 'core/table', + clientId: 'table-block', + attributes: { body }, + innerBlocks: [], + }, + ]; + const baseBody = [ + { + cells: [ + { content: 'base row 1', tag: 'td' }, + { content: 'base row 1 sibling', tag: 'td' }, + ], + }, + { + cells: [ + { content: 'base row 2', tag: 'td' }, + { content: 'base row 2 sibling', tag: 'td' }, + ], + }, + ]; + const baseBlocks = createTableBlocks( baseBody ); + + mergeCrdtBlocks( yblocks, baseBlocks, null ); + + mergeCrdtBlocks( + yblocks, + createTableBlocks( [ + ...baseBody, + { + cells: [ + { content: 'remote row', tag: 'td' }, + { content: 'remote row sibling', tag: 'td' }, + ], + }, + ] ), + null, + baseBlocks + ); + + mergeCrdtBlocks( + yblocks, + createTableBlocks( [ + ...baseBody, + { + cells: [ + { content: 'remote row', tag: 'td' }, + { content: 'local edit in remote row', tag: 'td' }, + ], + }, + ] ), + null, + baseBlocks + ); + + const attrs = yblocks + .get( 0 ) + .get( 'attributes' ) as YBlockAttributes; + const body = ( + attrs.get( 'body' ) as Y.Array< unknown > + ).toJSON() as { cells: { content: string; tag: string }[] }[]; + + expect( body ).toHaveLength( 3 ); + expect( body[ 2 ].cells[ 0 ].content ).toBe( 'remote row' ); + expect( body[ 2 ].cells[ 1 ].content ).toBe( + 'local edit in remote row' + ); + } ); + it( 'preserves Y.Map identity for untouched rows when a row is appended', () => { const initialBlocks: Block[] = [ { diff --git a/packages/core-data/src/utils/test/crdt-object-query-stale-snapshot-repro.test.ts b/packages/core-data/src/utils/test/crdt-object-query-stale-snapshot-repro.test.ts new file mode 100644 index 00000000000000..9a8834e339b5ee --- /dev/null +++ b/packages/core-data/src/utils/test/crdt-object-query-stale-snapshot-repro.test.ts @@ -0,0 +1,234 @@ +/** + * WordPress dependencies + */ +import { Y } from '@wordpress/sync'; + +/** + * External dependencies + */ +import { describe, expect, it, jest } from '@jest/globals'; + +jest.mock( 'uuid', () => ( { + v4: () => 'mocked-uuid', +} ) ); + +jest.mock( '@wordpress/blocks', () => { + const actual = jest.requireActual( '@wordpress/blocks' ) as Record< + string, + unknown + >; + + return { + ...actual, + __unstableSerializeAndClean: ( blocks: unknown ) => + JSON.stringify( blocks ), + getBlockTypes: () => [ + { + name: 'test/object-query-card', + attributes: { + hero: { + type: 'object', + query: { + headline: { type: 'string' }, + caption: { type: 'string' }, + }, + }, + }, + }, + ], + }; +} ); + +/** + * Internal dependencies + */ +import { applyPostChangesToCRDTDoc, getPostChangesFromCRDTDoc } from '../crdt'; +import { + mergeCrdtBlocks, + type Block, + type YBlock, + type YBlockAttributes, +} from '../crdt-blocks'; +import { getRootMap } from '../crdt-utils'; +import { CRDT_RECORD_MAP_KEY } from '../../sync'; +import type { Post } from '../../entity-types/post'; + +const syncedProperties = new Set( [ 'blocks' ] ); + +function objectQueryBlock( hero: { + headline?: string; + caption?: string; +} ): Block { + return { + name: 'test/object-query-card', + attributes: { + hero, + }, + innerBlocks: [], + clientId: 'object-query-card-1', + }; +} + +function getHeroFromBlocks( yblocks: Y.Array< YBlock > ) { + const attributes = yblocks.get( 0 ).get( 'attributes' ) as YBlockAttributes; + + return attributes.get( 'hero' ) as Y.Map< unknown >; +} + +function getHeroFromDoc( doc: Y.Doc ) { + const record = getRootMap< { blocks: Y.Array< YBlock > } >( + doc, + CRDT_RECORD_MAP_KEY + ); + const yblocks = record.get( 'blocks' ); + + if ( ! ( yblocks instanceof Y.Array ) ) { + throw new Error( 'Expected CRDT doc to contain blocks.' ); + } + + return getHeroFromBlocks( yblocks ); +} + +function applyRemoteUpdate( receiver: Y.Doc, sender: Y.Doc ) { + Y.applyUpdate( receiver, Y.encodeStateAsUpdate( sender ) ); +} + +describe( 'object+query stale snapshot repro', () => { + it( 'mergeCrdtBlocks preserves a remote sibling object property update', () => { + const docA = new Y.Doc(); + const docB = new Y.Doc(); + const blocksA = docA.getArray< YBlock >(); + const blocksB = docB.getArray< YBlock >(); + const initialBlocks = [ + objectQueryBlock( { + headline: 'headline before', + caption: 'caption before', + } ), + ]; + + mergeCrdtBlocks( blocksA, initialBlocks, null ); + applyRemoteUpdate( docB, docA ); + + const staleLocalSnapshot = [ + objectQueryBlock( { + headline: 'headline from user A', + caption: 'caption before', + } ), + ]; + const remoteCaptionUpdate = [ + objectQueryBlock( { + headline: 'headline before', + caption: 'caption from user B', + } ), + ]; + + mergeCrdtBlocks( blocksB, remoteCaptionUpdate, null ); + applyRemoteUpdate( docA, docB ); + expect( getHeroFromBlocks( blocksA ).get( 'caption' ) ).toBe( + 'caption from user B' + ); + + mergeCrdtBlocks( blocksA, staleLocalSnapshot, null ); + + expect( getHeroFromBlocks( blocksA ).toJSON() ).toEqual( { + headline: 'headline from user A', + caption: 'caption from user B', + } ); + } ); + + it( 'mergeCrdtBlocks preserves a remote sibling object property delete', () => { + const docA = new Y.Doc(); + const docB = new Y.Doc(); + const blocksA = docA.getArray< YBlock >(); + const blocksB = docB.getArray< YBlock >(); + const initialBlocks = [ + objectQueryBlock( { + headline: 'headline before', + caption: 'caption before', + } ), + ]; + + mergeCrdtBlocks( blocksA, initialBlocks, null ); + applyRemoteUpdate( docB, docA ); + + const staleLocalSnapshot = [ + objectQueryBlock( { + headline: 'headline from user A', + caption: 'caption before', + } ), + ]; + const remoteCaptionDelete = [ + objectQueryBlock( { + headline: 'headline before', + } ), + ]; + + mergeCrdtBlocks( blocksB, remoteCaptionDelete, null ); + applyRemoteUpdate( docA, docB ); + expect( getHeroFromBlocks( blocksA ).has( 'caption' ) ).toBe( false ); + + mergeCrdtBlocks( blocksA, staleLocalSnapshot, null ); + + expect( getHeroFromBlocks( blocksA ).toJSON() ).toEqual( { + headline: 'headline from user A', + } ); + } ); + + it( 'post CRDT adapter preserves remote object+query sibling changes', () => { + const docA = new Y.Doc(); + const docB = new Y.Doc(); + const initialBlocks = [ + objectQueryBlock( { + headline: 'headline before', + caption: 'caption before', + } ), + ]; + + applyPostChangesToCRDTDoc( + docA, + { blocks: initialBlocks }, + syncedProperties + ); + applyRemoteUpdate( docB, docA ); + + const staleLocalSnapshot = [ + objectQueryBlock( { + headline: 'headline from user A', + caption: 'caption before', + } ), + ]; + const remoteCaptionUpdate = [ + objectQueryBlock( { + headline: 'headline before', + caption: 'caption from user B', + } ), + ]; + + applyPostChangesToCRDTDoc( + docB, + { blocks: remoteCaptionUpdate }, + syncedProperties + ); + applyRemoteUpdate( docA, docB ); + expect( getHeroFromDoc( docA ).get( 'caption' ) ).toBe( + 'caption from user B' + ); + + applyPostChangesToCRDTDoc( + docA, + { blocks: staleLocalSnapshot }, + syncedProperties + ); + + const changes = getPostChangesFromCRDTDoc( + docA, + { blocks: initialBlocks } as unknown as Post, + syncedProperties + ); + + expect( ( changes.blocks as Block[] )[ 0 ].attributes.hero ).toEqual( { + headline: 'headline from user A', + caption: 'caption from user B', + } ); + } ); +} ); diff --git a/packages/core-data/src/utils/test/crdt-stale-query-array-post.test.ts b/packages/core-data/src/utils/test/crdt-stale-query-array-post.test.ts new file mode 100644 index 00000000000000..3db90727962f44 --- /dev/null +++ b/packages/core-data/src/utils/test/crdt-stale-query-array-post.test.ts @@ -0,0 +1,260 @@ +/** + * WordPress dependencies + */ +import { RichTextData } from '@wordpress/rich-text'; +import { Y } from '@wordpress/sync'; + +/** + * External dependencies + */ +import { describe, expect, it, jest, afterEach } from '@jest/globals'; + +jest.mock( '@wordpress/blocks', () => { + const actual = jest.requireActual( '@wordpress/blocks' ) as Record< + string, + unknown + >; + return { + ...actual, + getBlockTypes: () => [ + { + name: 'core/table', + attributes: { + body: { + type: 'array', + query: { + cells: { + type: 'array', + query: { + content: { type: 'rich-text' }, + tag: { type: 'string' }, + }, + }, + }, + }, + }, + }, + ], + }; +} ); + +/** + * Internal dependencies + */ +import { + applyPostChangesToCRDTDoc, + getPostChangesFromCRDTDoc, + type PostChanges, +} from '../crdt'; +import type { Block } from '../crdt-blocks'; +import type { Post } from '../../entity-types'; + +const syncedProperties = new Set( [ 'blocks' ] ); +const RANDOM_SEEDS = Array.from( + { length: 24 }, + ( _value, index ) => index + 1 +); + +function tableBlock( rows: string[][] ): Block { + return { + name: 'core/table', + clientId: 'table-1', + attributes: { + body: rows.map( ( cells ) => ( { + cells: cells.map( ( content ) => ( { content, tag: 'td' } ) ), + } ) ), + }, + innerBlocks: [], + }; +} + +function applyBlocks( doc: Y.Doc, blocks: Block[] ) { + applyPostChangesToCRDTDoc( + doc, + { blocks } as PostChanges, + syncedProperties + ); +} + +function syncDocs( from: Y.Doc, to: Y.Doc ) { + Y.applyUpdate( to, Y.encodeStateAsUpdate( from ) ); +} + +function textValue( value: unknown ): string { + if ( value instanceof RichTextData ) { + return value.text; + } + return String( value ); +} + +function getBody( doc: Y.Doc ): string[][] { + const changes = getPostChangesFromCRDTDoc( + doc, + { blocks: [] } as unknown as Post, + syncedProperties + ); + const block = ( changes.blocks as Block[] )[ 0 ]; + const body = block.attributes.body as { + cells: { content: unknown }[]; + }[]; + + return body.map( ( row ) => + row.cells.map( ( cell ) => textValue( cell.content ) ) + ); +} + +function cloneRows( rows: string[][] ): string[][] { + return rows.map( ( cells ) => [ ...cells ] ); +} + +/* eslint-disable no-bitwise */ +function createSeededRandom( seed: number ) { + let state = seed >>> 0; + + function next() { + state += 0x6d2b79f5; + let value = state; + value = Math.imul( value ^ ( value >>> 15 ), value | 1 ); + value ^= value + Math.imul( value ^ ( value >>> 7 ), value | 61 ); + return ( ( value ^ ( value >>> 14 ) ) >>> 0 ) / 0x100000000; + } + + return { + int( maxExclusive: number ) { + return Math.floor( next() * maxExclusive ); + }, + pick< T >( values: readonly T[] ): T { + return values[ this.int( values.length ) ]; + }, + }; +} +/* eslint-enable no-bitwise */ + +function rowsContain( rows: string[][], value: string ): boolean { + return rows.some( ( row ) => row.includes( value ) ); +} + +function runRandomStaleTableScenario( seed: number ) { + const random = createSeededRandom( seed ); + const docA = new Y.Doc(); + const docB = new Y.Doc(); + const initialRows = [ + [ `seed-${ seed }-A1`, `seed-${ seed }-B1` ], + [ `seed-${ seed }-A2`, `seed-${ seed }-B2` ], + [ `seed-${ seed }-A3`, `seed-${ seed }-B3` ], + ]; + const staleLocalRows = cloneRows( initialRows ); + const remoteRows = cloneRows( initialRows ); + const localMarker = `local-${ seed }`; + const remoteMarker = `remote-${ seed }`; + const scenario = random.pick( [ + 'remote-cell-edit', + 'remote-append-row', + 'remote-prepend-row', + 'remote-delete-row', + ] as const ); + + try { + applyBlocks( docA, [ tableBlock( initialRows ) ] ); + syncDocs( docA, docB ); + + switch ( scenario ) { + case 'remote-cell-edit': + remoteRows[ 1 ][ 1 ] = remoteMarker; + break; + + case 'remote-append-row': + remoteRows.push( [ remoteMarker, `remote-tail-${ seed }` ] ); + break; + + case 'remote-prepend-row': + remoteRows.unshift( [ remoteMarker, `remote-head-${ seed }` ] ); + break; + + case 'remote-delete-row': + remoteRows[ 2 ][ 0 ] = remoteMarker; + applyBlocks( docA, [ tableBlock( remoteRows ) ] ); + syncDocs( docA, docB ); + remoteRows.splice( 2, 1 ); + break; + } + + applyBlocks( docB, [ tableBlock( remoteRows ) ] ); + syncDocs( docB, docA ); + + staleLocalRows[ 0 ][ 0 ] = localMarker; + applyBlocks( docA, [ tableBlock( staleLocalRows ) ] ); + + const body = getBody( docA ); + expect( rowsContain( body, localMarker ) ).toBe( true ); + + if ( scenario === 'remote-delete-row' ) { + expect( rowsContain( body, remoteMarker ) ).toBe( false ); + } else { + expect( rowsContain( body, remoteMarker ) ).toBe( true ); + } + } catch ( error ) { + throw new Error( + `Stale table scenario failed for seed ${ seed } (${ scenario }): ${ + error instanceof Error ? error.message : String( error ) + }` + ); + } finally { + docA.destroy(); + docB.destroy(); + } +} + +describe( 'post CRDT stale query-array snapshots', () => { + const docs: Y.Doc[] = []; + + afterEach( () => { + for ( const doc of docs ) { + doc.destroy(); + } + docs.length = 0; + } ); + + it( 'preserves a remote table cell edit through the post changes adapter', () => { + const docA = new Y.Doc(); + const docB = new Y.Doc(); + docs.push( docA, docB ); + + applyBlocks( docA, [ + tableBlock( [ + [ 'A1', 'B1' ], + [ 'A2', 'B2' ], + ] ), + ] ); + syncDocs( docA, docB ); + + applyBlocks( docB, [ + tableBlock( [ + [ 'A1', 'B1' ], + [ 'A2', 'remote-B2' ], + ] ), + ] ); + syncDocs( docB, docA ); + expect( getBody( docA )[ 1 ][ 1 ] ).toBe( 'remote-B2' ); + + applyBlocks( docA, [ + tableBlock( [ + [ 'local-A1', 'B1' ], + [ 'A2', 'B2' ], + ] ), + ] ); + + expect( getBody( docA ) ).toEqual( [ + [ 'local-A1', 'B1' ], + [ 'A2', 'remote-B2' ], + ] ); + } ); + + it.each( RANDOM_SEEDS )( + 'preserves acknowledged remote table operations after a stale local snapshot (seed %i)', + ( seed ) => { + expect.hasAssertions(); + runRandomStaleTableScenario( seed ); + } + ); +} ); diff --git a/packages/core-data/src/utils/test/crdt-stale-query-array.test.ts b/packages/core-data/src/utils/test/crdt-stale-query-array.test.ts new file mode 100644 index 00000000000000..feb44fdbcc2d72 --- /dev/null +++ b/packages/core-data/src/utils/test/crdt-stale-query-array.test.ts @@ -0,0 +1,238 @@ +/** + * WordPress dependencies + */ +import { Y } from '@wordpress/sync'; + +/** + * External dependencies + */ +import { describe, expect, it, jest, afterEach } from '@jest/globals'; + +jest.mock( '@wordpress/blocks', () => ( { + getBlockTypes: () => [ + { + name: 'core/table', + attributes: { + body: { + type: 'array', + query: { + cells: { + type: 'array', + query: { + content: { type: 'rich-text' }, + tag: { type: 'string' }, + }, + }, + }, + }, + }, + }, + ], +} ) ); + +/** + * Internal dependencies + */ +import { mergeCrdtBlocks, type Block, type YBlock } from '../crdt-blocks'; + +function tableBlock( rows: string[][] ): Block { + return { + name: 'core/table', + clientId: 'table-1', + attributes: { + body: rows.map( ( cells ) => ( { + cells: cells.map( ( content ) => ( { content, tag: 'td' } ) ), + } ) ), + }, + innerBlocks: [], + }; +} + +function syncDocs( from: Y.Doc, to: Y.Doc ) { + Y.applyUpdate( to, Y.encodeStateAsUpdate( from ) ); +} + +function getTableBody( yblocks: Y.Array< YBlock > ) { + return yblocks.toJSON()[ 0 ].attributes.body as { + cells: { content: string }[]; + }[]; +} + +describe( 'stale query-array block snapshots', () => { + const docs: Y.Doc[] = []; + + afterEach( () => { + for ( const doc of docs ) { + doc.destroy(); + } + docs.length = 0; + } ); + + function createSyncedDocs( initialRows: string[][] ) { + const docA = new Y.Doc(); + const docB = new Y.Doc(); + docs.push( docA, docB ); + + const yblocksA = docA.getArray< YBlock >(); + const yblocksB = docB.getArray< YBlock >(); + + mergeCrdtBlocks( yblocksA, [ tableBlock( initialRows ) ], null ); + syncDocs( docA, docB ); + + return { docA, docB, yblocksA, yblocksB }; + } + + it( 'preserves a remote nested cell edit after a stale local cell edit', () => { + const { docA, docB, yblocksA, yblocksB } = createSyncedDocs( [ + [ 'A1', 'B1' ], + [ 'A2', 'B2' ], + ] ); + + mergeCrdtBlocks( + yblocksB, + [ + tableBlock( [ + [ 'A1', 'B1' ], + [ 'A2', 'remote-B2' ], + ] ), + ], + null + ); + syncDocs( docB, docA ); + expect( getTableBody( yblocksA )[ 1 ].cells[ 1 ].content ).toBe( + 'remote-B2' + ); + + mergeCrdtBlocks( + yblocksA, + [ + tableBlock( [ + [ 'local-A1', 'B1' ], + [ 'A2', 'B2' ], + ] ), + ], + null + ); + + const body = getTableBody( yblocksA ); + expect( body[ 0 ].cells[ 0 ].content ).toBe( 'local-A1' ); + expect( body[ 1 ].cells[ 1 ].content ).toBe( 'remote-B2' ); + } ); + + it( 'preserves a remote appended row after a stale local cell edit', () => { + const { docA, docB, yblocksA, yblocksB } = createSyncedDocs( [ + [ 'A1' ], + [ 'A2' ], + ] ); + + mergeCrdtBlocks( + yblocksB, + [ tableBlock( [ [ 'A1' ], [ 'A2' ], [ 'remote-A3' ] ] ) ], + null + ); + syncDocs( docB, docA ); + expect( getTableBody( yblocksA ) ).toHaveLength( 3 ); + + mergeCrdtBlocks( + yblocksA, + [ tableBlock( [ [ 'local-A1' ], [ 'A2' ] ] ) ], + null + ); + + const body = getTableBody( yblocksA ); + expect( body ).toHaveLength( 3 ); + expect( body[ 0 ].cells[ 0 ].content ).toBe( 'local-A1' ); + expect( body[ 2 ].cells[ 0 ].content ).toBe( 'remote-A3' ); + } ); + + it( 'preserves a remote appended row when an explicit base has the row but a stale local snapshot does not', () => { + const { docA, docB, yblocksA, yblocksB } = createSyncedDocs( [ + [ '' ], + [ '' ], + ] ); + + const explicitBaseWithRemoteRow = [ + tableBlock( [ [ '' ], [ '' ], [ '' ] ] ), + ]; + + mergeCrdtBlocks( + yblocksB, + [ tableBlock( [ [ '' ], [ '' ], [ '' ] ] ) ], + null + ); + syncDocs( docB, docA ); + expect( getTableBody( yblocksA ) ).toHaveLength( 3 ); + + mergeCrdtBlocks( + yblocksA, + [ tableBlock( [ [ 'local-A1' ], [ '' ] ] ) ], + { + attributeKey: 'body.0.cells.0.content', + clientId: 'table-1', + offset: 'local-A1'.length, + }, + explicitBaseWithRemoteRow + ); + + const body = getTableBody( yblocksA ); + expect( body ).toHaveLength( 3 ); + expect( body[ 0 ].cells[ 0 ].content ).toBe( 'local-A1' ); + } ); + + it( 'still applies an explicit-base row deletion when no rich-text edit cursor is present', () => { + const { docA, docB, yblocksA, yblocksB } = createSyncedDocs( [ + [ '' ], + [ '' ], + ] ); + + const explicitBaseWithRemoteRow = [ + tableBlock( [ [ '' ], [ '' ], [ '' ] ] ), + ]; + + mergeCrdtBlocks( + yblocksB, + [ tableBlock( [ [ '' ], [ '' ], [ '' ] ] ) ], + null + ); + syncDocs( docB, docA ); + expect( getTableBody( yblocksA ) ).toHaveLength( 3 ); + + mergeCrdtBlocks( + yblocksA, + [ tableBlock( [ [ '' ], [ '' ] ] ) ], + null, + explicitBaseWithRemoteRow + ); + + expect( getTableBody( yblocksA ) ).toHaveLength( 2 ); + } ); + + it( 'does not resurrect a remotely deleted row from a stale local snapshot', () => { + const { docA, docB, yblocksA, yblocksB } = createSyncedDocs( [ + [ 'A1' ], + [ 'A2' ], + [ 'A3' ], + ] ); + + mergeCrdtBlocks( + yblocksB, + [ tableBlock( [ [ 'A1' ], [ 'A2' ] ] ) ], + null + ); + syncDocs( docB, docA ); + expect( getTableBody( yblocksA ) ).toHaveLength( 2 ); + + mergeCrdtBlocks( + yblocksA, + [ tableBlock( [ [ 'local-A1' ], [ 'A2' ], [ 'A3' ] ] ) ], + null + ); + + const body = getTableBody( yblocksA ); + expect( body ).toHaveLength( 2 ); + expect( body[ 0 ].cells[ 0 ].content ).toBe( 'local-A1' ); + expect( body.map( ( row ) => row.cells[ 0 ].content ) ).not.toContain( + 'A3' + ); + } ); +} ); diff --git a/packages/core-data/src/utils/test/crdt-stale-top-level-blocks.test.ts b/packages/core-data/src/utils/test/crdt-stale-top-level-blocks.test.ts new file mode 100644 index 00000000000000..621eafd35791ff --- /dev/null +++ b/packages/core-data/src/utils/test/crdt-stale-top-level-blocks.test.ts @@ -0,0 +1,704 @@ +/** + * WordPress dependencies + */ +import { Y } from '@wordpress/sync'; + +/** + * External dependencies + */ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; + +/** + * Mock getBlockTypes so CRDT merging can identify rich-text attributes. + */ +jest.mock( '@wordpress/blocks', () => { + const actual = jest.requireActual( '@wordpress/blocks' ) as Record< + string, + unknown + >; + return { + ...actual, + __unstableSerializeAndClean: ( + blocks: { attributes: { content?: string } }[] + ) => + blocks + .map( ( block ) => `

${ block.attributes.content }

` ) + .join( '\n\n' ), + getBlockTypes: () => [ + { + name: 'core/paragraph', + attributes: { content: { type: 'rich-text' } }, + }, + ], + }; +} ); + +/** + * Internal dependencies + */ +import { CRDT_RECORD_MAP_KEY } from '../../sync'; +import { applyPostChangesToCRDTDoc, type YPostRecord } from '../crdt'; +import { + mergeCrdtBlocks, + type Block, + type YBlock, + type YBlocks, +} from '../crdt-blocks'; +import { getRootMap } from '../crdt-utils'; + +const SYNCED_BLOCK_PROPERTIES = new Set( [ 'blocks' ] ); +const SYNCED_POST_PROPERTIES = new Set( [ 'blocks', 'content' ] ); + +function paragraph( clientId: string, content: string ): Block { + return { + name: 'core/paragraph', + clientId, + attributes: { content }, + innerBlocks: [], + }; +} + +function group( clientId: string, innerBlocks: Block[] = [] ): Block { + return { + name: 'core/group', + clientId, + attributes: {}, + innerBlocks, + }; +} + +function contentsOf( yblocks: YBlocks ): string[] { + return ( yblocks.toJSON() as Block[] ).map( + ( block ) => block.attributes.content as string + ); +} + +function blockTreeOf( yblocks: YBlocks ): string[] { + return ( yblocks.toJSON() as Block[] ).map( blockTreeSignature ); +} + +function blockTreeSignature( block: Block ): string { + if ( block.name === 'core/group' ) { + return `${ block.clientId }:${ block.name }[${ block.innerBlocks + .map( blockTreeSignature ) + .join( ',' ) }]`; + } + + return `${ block.clientId }:${ block.attributes.content }`; +} + +function clientIdsOf( yblocks: YBlocks ): string[] { + return ( yblocks.toJSON() as Block[] ).map( + ( block ) => block.clientId ?? '' + ); +} + +function postBlocks( doc: Y.Doc ): YBlocks { + return getRootMap< YPostRecord >( doc, CRDT_RECORD_MAP_KEY ).get( + 'blocks' + ) as YBlocks; +} + +function postContent( doc: Y.Doc ): string { + return ( + getRootMap< YPostRecord >( doc, CRDT_RECORD_MAP_KEY ) + .get( 'content' ) + ?.toString() ?? '' + ); +} + +function serializeBlocks( blocks: Block[] ): string { + return blocks + .map( ( block ) => `

${ block.attributes.content }

` ) + .join( '\n\n' ); +} + +describe( 'stale top-level block snapshots', () => { + let doc: Y.Doc; + let yblocks: Y.Array< YBlock >; + + beforeEach( () => { + doc = new Y.Doc(); + yblocks = doc.getArray< YBlock >(); + } ); + + afterEach( () => { + doc.destroy(); + } ); + + it( 'applies a local suffix append when the explicit base differs from current blocks', () => { + const baseBlocks = [ + paragraph( 'canonicalized', 'Alpha' ), + paragraph( 'unchanged', 'Beta' ), + ]; + const currentBlocks = [ + paragraph( 'canonicalized', 'Alpha canonicalized' ), + paragraph( 'unchanged', 'Beta' ), + ]; + const blocksWithLocalAppend = [ + ...baseBlocks, + paragraph( 'checkpoint-paragraph', 'Checkpoint paragraph' ), + paragraph( 'checkpoint-search', 'Checkpoint search' ), + ]; + + mergeCrdtBlocks( yblocks, currentBlocks, null ); + mergeCrdtBlocks( yblocks, blocksWithLocalAppend, null, baseBlocks ); + mergeCrdtBlocks( yblocks, blocksWithLocalAppend, null, baseBlocks ); + + expect( contentsOf( yblocks ) ).toEqual( [ + 'Alpha canonicalized', + 'Beta', + 'Checkpoint paragraph', + 'Checkpoint search', + ] ); + } ); + + it( 'inserts a missing local checkpoint paragraph before an already-present suffix block', () => { + const baseBlocks = [ + paragraph( 'intro', 'Alpha' ), + paragraph( 'unchanged', 'Beta' ), + ]; + const currentBlocks = [ + ...baseBlocks, + paragraph( 'checkpoint-search', 'Checkpoint search' ), + ]; + const blocksWithLocalAppend = [ + ...baseBlocks, + paragraph( 'checkpoint-paragraph', 'Checkpoint paragraph' ), + paragraph( 'checkpoint-search', 'Checkpoint search' ), + ]; + + mergeCrdtBlocks( yblocks, currentBlocks, null ); + mergeCrdtBlocks( yblocks, blocksWithLocalAppend, null, baseBlocks ); + + expect( contentsOf( yblocks ) ).toEqual( [ + 'Alpha', + 'Beta', + 'Checkpoint paragraph', + 'Checkpoint search', + ] ); + expect( clientIdsOf( yblocks ) ).toEqual( [ + 'intro', + 'unchanged', + 'checkpoint-paragraph', + 'checkpoint-search', + ] ); + } ); + + it( 'does not collapse distinct appended blocks with matching content', () => { + const baseBlocks = [ paragraph( 'base', 'Alpha' ) ]; + const currentBlocks = [ + ...baseBlocks, + paragraph( 'remote-appended', 'Duplicate content' ), + ]; + const blocksWithLocalAppend = [ + ...baseBlocks, + paragraph( 'local-appended', 'Duplicate content' ), + ]; + + mergeCrdtBlocks( yblocks, currentBlocks, null ); + mergeCrdtBlocks( yblocks, blocksWithLocalAppend, null, baseBlocks ); + + expect( contentsOf( yblocks ) ).toEqual( [ + 'Alpha', + 'Duplicate content', + 'Duplicate content', + ] ); + expect( clientIdsOf( yblocks ) ).toEqual( [ + 'base', + 'local-appended', + 'remote-appended', + ] ); + } ); + + it( 'does not append a stale suffix when the base tail anchor is absent', () => { + const baseBlocks = [ + paragraph( 'base-start', 'Alpha' ), + paragraph( 'base-tail', 'Beta' ), + ]; + const currentBlocks = [ + paragraph( 'base-start', 'Alpha' ), + paragraph( 'replacement', 'Beta' ), + paragraph( 'remote-tail', 'Remote tail' ), + ]; + const blocksWithLocalAppend = [ + ...baseBlocks, + paragraph( 'local-appended', 'Local suffix' ), + ]; + + mergeCrdtBlocks( yblocks, currentBlocks, null ); + mergeCrdtBlocks( yblocks, blocksWithLocalAppend, null, baseBlocks ); + + expect( contentsOf( yblocks ) ).toEqual( [ + 'Alpha', + 'Beta', + 'Remote tail', + ] ); + expect( clientIdsOf( yblocks ) ).toEqual( [ + 'base-start', + 'replacement', + 'remote-tail', + ] ); + } ); + + it( 'applies an explicit-base suffix append through the post CRDT adapter', () => { + const baseBlocks = [ + paragraph( 'canonicalized', 'Alpha' ), + paragraph( 'unchanged', 'Beta' ), + ]; + const currentBlocks = [ + paragraph( 'canonicalized', 'Alpha canonicalized' ), + paragraph( 'unchanged', 'Beta' ), + ]; + const blocksWithLocalAppend = [ + ...baseBlocks, + paragraph( 'checkpoint-paragraph', 'Checkpoint paragraph' ), + ]; + + applyPostChangesToCRDTDoc( + doc, + { blocks: currentBlocks }, + SYNCED_BLOCK_PROPERTIES + ); + applyPostChangesToCRDTDoc( + doc, + { blocks: blocksWithLocalAppend }, + SYNCED_BLOCK_PROPERTIES, + { baseRecord: { blocks: baseBlocks } } + ); + + expect( contentsOf( postBlocks( doc ) ) ).toEqual( [ + 'Alpha canonicalized', + 'Beta', + 'Checkpoint paragraph', + ] ); + } ); + + it( 'preserves a remote top-level append when a stale local edit touches a different block', () => { + const initialBlocks = [ + paragraph( 'local-edited', 'Alpha' ), + paragraph( 'unchanged', 'Beta' ), + ]; + mergeCrdtBlocks( yblocks, initialBlocks, null ); + + const remoteDoc = new Y.Doc(); + const remoteBlocks = remoteDoc.getArray< YBlock >(); + Y.applyUpdate( remoteDoc, Y.encodeStateAsUpdate( doc ) ); + + mergeCrdtBlocks( + remoteBlocks, + [ ...initialBlocks, paragraph( 'remote-appended', 'Gamma' ) ], + null + ); + + Y.applyUpdate( doc, Y.encodeStateAsUpdate( remoteDoc ) ); + expect( contentsOf( yblocks ) ).toEqual( [ 'Alpha', 'Beta', 'Gamma' ] ); + + const staleLocalBlocks = [ + paragraph( 'local-edited', 'Alpha local edit' ), + paragraph( 'unchanged', 'Beta' ), + ]; + mergeCrdtBlocks( yblocks, staleLocalBlocks, null ); + + expect( contentsOf( yblocks ) ).toEqual( [ + 'Alpha local edit', + 'Beta', + 'Gamma', + ] ); + + remoteDoc.destroy(); + } ); + + it( 'preserves a remote top-level delete when a stale local edit touches a different block', () => { + const initialBlocks = [ + paragraph( 'local-edited', 'Alpha' ), + paragraph( 'unchanged', 'Beta' ), + paragraph( 'remote-deleted', 'Gamma' ), + ]; + mergeCrdtBlocks( yblocks, initialBlocks, null ); + + const remoteDoc = new Y.Doc(); + const remoteBlocks = remoteDoc.getArray< YBlock >(); + Y.applyUpdate( remoteDoc, Y.encodeStateAsUpdate( doc ) ); + + mergeCrdtBlocks( + remoteBlocks, + [ + paragraph( 'local-edited', 'Alpha' ), + paragraph( 'unchanged', 'Beta' ), + ], + null + ); + + Y.applyUpdate( doc, Y.encodeStateAsUpdate( remoteDoc ) ); + expect( contentsOf( yblocks ) ).toEqual( [ 'Alpha', 'Beta' ] ); + + const staleLocalBlocks = [ + paragraph( 'local-edited', 'Alpha local edit' ), + paragraph( 'unchanged', 'Beta' ), + paragraph( 'remote-deleted', 'Gamma' ), + ]; + mergeCrdtBlocks( yblocks, staleLocalBlocks, null ); + + expect( contentsOf( yblocks ) ).toEqual( [ + 'Alpha local edit', + 'Beta', + ] ); + + remoteDoc.destroy(); + } ); + + it( 'preserves a remote rich-text edit when a stale local edit touches a different block', () => { + const initialBlocks = [ + paragraph( 'local-edited', 'Alpha' ), + paragraph( 'remote-edited', 'Beta' ), + ]; + mergeCrdtBlocks( yblocks, initialBlocks, null ); + + const remoteDoc = new Y.Doc(); + const remoteBlocks = remoteDoc.getArray< YBlock >(); + Y.applyUpdate( remoteDoc, Y.encodeStateAsUpdate( doc ) ); + + mergeCrdtBlocks( + remoteBlocks, + [ + paragraph( 'local-edited', 'Alpha' ), + paragraph( 'remote-edited', 'Beta remote edit' ), + ], + null + ); + + Y.applyUpdate( doc, Y.encodeStateAsUpdate( remoteDoc ) ); + expect( contentsOf( yblocks ) ).toEqual( [ + 'Alpha', + 'Beta remote edit', + ] ); + + const staleLocalBlocks = [ + paragraph( 'local-edited', 'Alpha stale edit' ), + paragraph( 'remote-edited', 'Beta' ), + ]; + mergeCrdtBlocks( yblocks, staleLocalBlocks, null ); + + expect( contentsOf( yblocks ) ).toEqual( [ + 'Alpha stale edit', + 'Beta remote edit', + ] ); + + remoteDoc.destroy(); + } ); + + it( 'preserves a remote move into a group when a stale local edit still has the moved source top-level', () => { + const initialBlocks = [ + paragraph( 'moved', 'Moved' ), + group( 'target-group' ), + paragraph( 'tail', 'Tail' ), + ]; + mergeCrdtBlocks( yblocks, initialBlocks, null ); + + const remoteDoc = new Y.Doc(); + const remoteBlocks = remoteDoc.getArray< YBlock >(); + Y.applyUpdate( remoteDoc, Y.encodeStateAsUpdate( doc ) ); + + mergeCrdtBlocks( + remoteBlocks, + [ + group( 'target-group', [ paragraph( 'moved', 'Moved' ) ] ), + paragraph( 'tail', 'Tail' ), + ], + null + ); + + Y.applyUpdate( doc, Y.encodeStateAsUpdate( remoteDoc ) ); + expect( blockTreeOf( yblocks ) ).toEqual( [ + 'target-group:core/group[moved:Moved]', + 'tail:Tail', + ] ); + + const staleLocalBlocks = [ + paragraph( 'moved', 'Moved' ), + group( 'target-group' ), + paragraph( 'tail', 'Tail local edit' ), + ]; + mergeCrdtBlocks( yblocks, staleLocalBlocks, null ); + + expect( blockTreeOf( yblocks ) ).toEqual( [ + 'target-group:core/group[moved:Moved]', + 'tail:Tail local edit', + ] ); + + remoteDoc.destroy(); + } ); + + it( 'preserves identity when a stale top-level move follows a remote append and group prepend', () => { + const initialBlocks = [ + paragraph( 'heading', 'Heading' ), + paragraph( 'tail', 'Tail' ), + paragraph( 'long', 'LongParagraph' ), + ]; + mergeCrdtBlocks( yblocks, initialBlocks, null ); + + const remoteDoc = new Y.Doc(); + const remoteBlocks = remoteDoc.getArray< YBlock >(); + Y.applyUpdate( remoteDoc, Y.encodeStateAsUpdate( doc ) ); + + mergeCrdtBlocks( + remoteBlocks, + [ ...initialBlocks, paragraph( 'inserted', 'InsertedParagraph' ) ], + null + ); + mergeCrdtBlocks( + remoteBlocks, + [ + group( 'prepended-group' ), + ...initialBlocks, + paragraph( 'inserted', 'InsertedParagraph' ), + ], + null + ); + + Y.applyUpdate( doc, Y.encodeStateAsUpdate( remoteDoc ) ); + expect( blockTreeOf( yblocks ) ).toEqual( [ + 'prepended-group:core/group[]', + 'heading:Heading', + 'tail:Tail', + 'long:LongParagraph', + 'inserted:InsertedParagraph', + ] ); + + const staleLocalMove = [ + group( 'prepended-group' ), + paragraph( 'heading', 'Heading' ), + paragraph( 'tail', 'Tail' ), + paragraph( 'inserted', 'InsertedParagraph' ), + paragraph( 'long', 'LongParagraph' ), + ]; + mergeCrdtBlocks( yblocks, staleLocalMove, null ); + + expect( blockTreeOf( yblocks ) ).toEqual( [ + 'prepended-group:core/group[]', + 'heading:Heading', + 'tail:Tail', + 'inserted:InsertedParagraph', + 'long:LongParagraph', + ] ); + + remoteDoc.destroy(); + } ); + + it( 'applies a local suffix append when the explicit base differs from current blocks', () => { + const baseBlocks = [ + paragraph( 'canonicalized', 'Alpha' ), + paragraph( 'unchanged', 'Beta' ), + ]; + const currentBlocks = [ + paragraph( 'canonicalized', 'Alpha canonicalized' ), + paragraph( 'unchanged', 'Beta' ), + ]; + const blocksWithLocalAppend = [ + ...baseBlocks, + paragraph( 'checkpoint-paragraph', 'Checkpoint paragraph' ), + paragraph( 'checkpoint-search', 'Checkpoint search' ), + ]; + + mergeCrdtBlocks( yblocks, currentBlocks, null ); + mergeCrdtBlocks( yblocks, blocksWithLocalAppend, null, baseBlocks ); + mergeCrdtBlocks( yblocks, blocksWithLocalAppend, null, baseBlocks ); + + expect( contentsOf( yblocks ) ).toEqual( [ + 'Alpha canonicalized', + 'Beta', + 'Checkpoint paragraph', + 'Checkpoint search', + ] ); + } ); + + it( 'derives post content from merged blocks instead of stale serialized content', () => { + const initialBlocks = [ + paragraph( 'local-edited', 'Alpha' ), + paragraph( 'remote-edited', 'Beta' ), + ]; + applyPostChangesToCRDTDoc( + doc, + { + blocks: initialBlocks, + content: serializeBlocks( initialBlocks ), + }, + SYNCED_POST_PROPERTIES + ); + + const remoteDoc = new Y.Doc(); + Y.applyUpdate( remoteDoc, Y.encodeStateAsUpdate( doc ) ); + + const remoteBlocks = [ + paragraph( 'local-edited', 'Alpha' ), + paragraph( 'remote-edited', 'Beta remote edit' ), + ]; + applyPostChangesToCRDTDoc( + remoteDoc, + { + blocks: remoteBlocks, + content: serializeBlocks( remoteBlocks ), + }, + SYNCED_POST_PROPERTIES + ); + + Y.applyUpdate( doc, Y.encodeStateAsUpdate( remoteDoc ) ); + expect( postContent( doc ) ).toContain( 'Beta remote edit' ); + + const staleLocalBlocks = [ + paragraph( 'local-edited', 'Alpha stale edit' ), + paragraph( 'remote-edited', 'Beta' ), + ]; + applyPostChangesToCRDTDoc( + doc, + { + blocks: staleLocalBlocks, + content: serializeBlocks( staleLocalBlocks ), + }, + SYNCED_POST_PROPERTIES + ); + + expect( contentsOf( postBlocks( doc ) ) ).toEqual( [ + 'Alpha stale edit', + 'Beta remote edit', + ] ); + expect( postContent( doc ) ).toContain( 'Alpha stale edit' ); + expect( postContent( doc ) ).toContain( 'Beta remote edit' ); + + remoteDoc.destroy(); + } ); + + it( 'updates an existing locally inserted block instead of preserving stale nested children', () => { + const baseBlocks = [ paragraph( 'base', 'Base' ) ]; + const staleInsertedGroup = group( 'inserted-group', [ + paragraph( 'inner-a', 'Nested paragraph' ), + paragraph( 'inner-b', 'Nested heading' ), + paragraph( 'inner-a-duplicate', 'Nested paragraph' ), + paragraph( 'inner-b-duplicate', 'Nested heading' ), + ] ); + + mergeCrdtBlocks( + yblocks, + [ baseBlocks[ 0 ], staleInsertedGroup ], + null + ); + + const incomingBlocks = [ + baseBlocks[ 0 ], + group( 'inserted-group', [ + paragraph( 'inner-a', 'Nested paragraph' ), + paragraph( 'inner-b', 'Nested heading' ), + ] ), + ]; + + mergeCrdtBlocks( yblocks, incomingBlocks, null, baseBlocks ); + + expect( blockTreeOf( yblocks ) ).toEqual( [ + 'base:Base', + 'inserted-group:core/group[inner-a:Nested paragraph,inner-b:Nested heading]', + ] ); + } ); + + it( 'uses save snapshots to remove stale nested children even when the base record matches the save', () => { + const baseBlocks = [ + paragraph( 'base', 'Base' ), + group( 'inserted-group', [ + paragraph( 'inner-a', 'Nested paragraph' ), + paragraph( 'inner-b', 'Nested heading' ), + ] ), + ]; + const staleBlocks = [ + baseBlocks[ 0 ], + group( 'inserted-group', [ + paragraph( 'inner-a', 'Nested paragraph' ), + paragraph( 'inner-b', 'Nested heading' ), + paragraph( 'inner-a-duplicate', 'Nested paragraph' ), + paragraph( 'inner-b-duplicate', 'Nested heading' ), + ] ), + ]; + + applyPostChangesToCRDTDoc( + doc, + { + blocks: staleBlocks, + content: serializeBlocks( staleBlocks ), + }, + SYNCED_POST_PROPERTIES + ); + + applyPostChangesToCRDTDoc( + doc, + { + blocks: baseBlocks, + content: serializeBlocks( baseBlocks ), + }, + SYNCED_POST_PROPERTIES, + { baseRecord: { blocks: baseBlocks }, isSave: true } + ); + + expect( blockTreeOf( postBlocks( doc ) ) ).toEqual( [ + 'base:Base', + 'inserted-group:core/group[inner-a:Nested paragraph,inner-b:Nested heading]', + ] ); + expect( postContent( doc ) ).toBe( serializeBlocks( baseBlocks ) ); + } ); + + it( 'preserves a remote top-level append through the post CRDT adapter', () => { + const initialBlocks = [ + paragraph( 'local-edited', 'Alpha' ), + paragraph( 'unchanged', 'Beta' ), + ]; + applyPostChangesToCRDTDoc( + doc, + { blocks: initialBlocks }, + SYNCED_BLOCK_PROPERTIES + ); + + const remoteDoc = new Y.Doc(); + Y.applyUpdate( remoteDoc, Y.encodeStateAsUpdate( doc ) ); + applyPostChangesToCRDTDoc( + remoteDoc, + { + blocks: [ + ...initialBlocks, + paragraph( 'remote-appended', 'Gamma' ), + ], + }, + SYNCED_BLOCK_PROPERTIES + ); + + Y.applyUpdate( doc, Y.encodeStateAsUpdate( remoteDoc ) ); + expect( contentsOf( postBlocks( doc ) ) ).toEqual( [ + 'Alpha', + 'Beta', + 'Gamma', + ] ); + + applyPostChangesToCRDTDoc( + doc, + { + blocks: [ + paragraph( 'local-edited', 'Alpha local edit' ), + paragraph( 'unchanged', 'Beta' ), + ], + }, + SYNCED_BLOCK_PROPERTIES + ); + + expect( contentsOf( postBlocks( doc ) ) ).toEqual( [ + 'Alpha local edit', + 'Beta', + 'Gamma', + ] ); + + remoteDoc.destroy(); + } ); +} ); diff --git a/packages/core-data/src/utils/test/crdt-table-duplicates-repro.test.ts b/packages/core-data/src/utils/test/crdt-table-duplicates-repro.test.ts new file mode 100644 index 00000000000000..e2f22186f27b2c --- /dev/null +++ b/packages/core-data/src/utils/test/crdt-table-duplicates-repro.test.ts @@ -0,0 +1,124 @@ +/** + * External dependencies + */ +import { describe, expect, it, jest } from '@jest/globals'; + +/** + * WordPress dependencies + */ +import { Y } from '@wordpress/sync'; + +/** + * Internal dependencies + */ +import { + deserializeBlockAttributes, + mergeCrdtBlocks, + type Block, + type YBlock, +} from '../crdt-blocks'; + +jest.mock( '@wordpress/blocks', () => ( { + getBlockTypes: () => [ + { + name: 'core/table', + attributes: { + body: { + type: 'array', + query: { + cells: { + type: 'array', + query: { + content: { type: 'rich-text' }, + tag: { type: 'string' }, + }, + }, + }, + }, + }, + }, + ], +} ) ); + +function createTableBlock( values: string[] ): Block { + return { + name: 'core/table', + clientId: 'table', + attributes: { + body: values.map( ( value ) => ( { + cells: [ + { + content: value, + tag: 'td', + }, + ], + } ) ), + }, + innerBlocks: [], + }; +} + +function getRuntimeTableBody( blocks: Block[] ) { + return blocks[ 0 ].attributes.body as Array< { + cells: Array< Record< string, unknown > >; + } >; +} + +function getCellContentText( content: unknown ) { + return typeof content === 'object' && content && 'valueOf' in content + ? String( content.valueOf() ) + : content; +} + +function getTableBodyCellContents( yblocks: Y.Array< YBlock > ) { + const blocks = deserializeBlockAttributes( yblocks.toJSON() as Block[] ); + return getRuntimeTableBody( blocks ).map( ( row ) => + getCellContentText( row.cells[ 0 ].content ) + ); +} + +describe( 'CRDT duplicate table row repro', () => { + it( 'converges when one user edits the later duplicate row and another deletes the earlier duplicate row', () => { + const docA = new Y.Doc(); + const docB = new Y.Doc(); + const yblocksA = docA.getArray< YBlock >( 'blocks' ); + const yblocksB = docB.getArray< YBlock >( 'blocks' ); + + mergeCrdtBlocks( + yblocksA, + [ createTableBlock( [ 'anchor', 'same', 'same' ] ) ], + null + ); + Y.applyUpdate( docB, Y.encodeStateAsUpdate( docA ) ); + + const stateVectorA = Y.encodeStateVector( docA ); + const stateVectorB = Y.encodeStateVector( docB ); + const runtimeBlocksA = deserializeBlockAttributes( + yblocksA.toJSON() as Block[] + ); + const runtimeBlocksB = deserializeBlockAttributes( + yblocksB.toJSON() as Block[] + ); + + getRuntimeTableBody( runtimeBlocksA )[ 2 ].cells[ 0 ].content = + 'edited-second-duplicate'; + getRuntimeTableBody( runtimeBlocksB ).splice( 1, 1 ); + + mergeCrdtBlocks( yblocksA, runtimeBlocksA, null ); + mergeCrdtBlocks( yblocksB, runtimeBlocksB, null ); + + const updateA = Y.encodeStateAsUpdate( docA, stateVectorB ); + const updateB = Y.encodeStateAsUpdate( docB, stateVectorA ); + Y.applyUpdate( docA, updateB ); + Y.applyUpdate( docB, updateA ); + + expect( getTableBodyCellContents( yblocksA ) ).toEqual( [ + 'anchor', + 'edited-second-duplicate', + ] ); + expect( getTableBodyCellContents( yblocksB ) ).toEqual( [ + 'anchor', + 'edited-second-duplicate', + ] ); + } ); +} ); diff --git a/packages/core-data/src/utils/test/crdt-table-query-identity.test.ts b/packages/core-data/src/utils/test/crdt-table-query-identity.test.ts new file mode 100644 index 00000000000000..1a177ca7c8abaa --- /dev/null +++ b/packages/core-data/src/utils/test/crdt-table-query-identity.test.ts @@ -0,0 +1,211 @@ +/** + * External dependencies + */ +import { describe, expect, it, jest } from '@jest/globals'; + +/** + * WordPress dependencies + */ +import { Y } from '@wordpress/sync'; + +/** + * Internal dependencies + */ +import { + deserializeBlockAttributes, + mergeCrdtBlocks, + type Block, + type YBlock, +} from '../crdt-blocks'; + +jest.mock( '@wordpress/blocks', () => ( { + getBlockTypes: () => [ + { + name: 'core/table', + attributes: { + body: { + type: 'array', + query: { + cells: { + type: 'array', + query: { + content: { type: 'rich-text' }, + tag: { type: 'string' }, + }, + }, + }, + }, + }, + }, + ], +} ) ); + +const INTERNAL_ID_KEY = '__unstableSyncId'; + +function createTableBlock( values: string[] ): Block { + return { + name: 'core/table', + clientId: 'table', + attributes: { + body: values.map( ( value ) => ( { + cells: [ + { + content: value, + tag: 'td', + }, + ], + } ) ), + }, + innerBlocks: [], + }; +} + +describe( 'CRDT table query array identity', () => { + function getRuntimeBody( yblocks: Y.Array< YBlock > ) { + const [ table ] = deserializeBlockAttributes( + yblocks.toJSON() as Block[] + ); + return table.attributes.body as Array< + Record< string, unknown > & { + cells: Array< Record< string, unknown > >; + } + >; + } + + function getRuntimeCellValues( yblocks: Y.Array< YBlock > ) { + return getRuntimeBody( yblocks ).map( ( row ) => { + const content = row.cells[ 0 ].content; + return typeof content === 'object' && + content && + 'valueOf' in content + ? String( content.valueOf() ) + : content; + } ); + } + + function getYBody( yblocks: Y.Array< YBlock > ) { + const [ table ] = yblocks.toJSON() as Block[]; + return table.attributes.body as Array< + Record< string, unknown > & { + cells: Array< Record< string, unknown > >; + } + >; + } + + it( 'stores internal identities in the CRDT document only', () => { + const doc = new Y.Doc(); + const yblocks = doc.getArray< YBlock >( 'blocks' ); + + mergeCrdtBlocks( + yblocks, + [ createTableBlock( [ 'anchor', 'same', 'same' ] ) ], + null + ); + + const yBody = getYBody( yblocks ); + expect( yBody[ 0 ][ INTERNAL_ID_KEY ] ).toEqual( expect.any( String ) ); + expect( yBody[ 1 ][ INTERNAL_ID_KEY ] ).toEqual( expect.any( String ) ); + expect( yBody[ 2 ][ INTERNAL_ID_KEY ] ).toEqual( expect.any( String ) ); + expect( yBody[ 2 ].cells[ 0 ][ INTERNAL_ID_KEY ] ).toEqual( + expect.any( String ) + ); + + const [ table ] = deserializeBlockAttributes( + yblocks.toJSON() as Block[] + ); + const body = table.attributes.body as Array< + Record< string, unknown > & { + cells: Array< Record< string, unknown > >; + } + >; + + expect( body[ 0 ] ).not.toHaveProperty( INTERNAL_ID_KEY ); + expect( body[ 1 ] ).not.toHaveProperty( INTERNAL_ID_KEY ); + expect( body[ 2 ] ).not.toHaveProperty( INTERNAL_ID_KEY ); + expect( body[ 2 ].cells[ 0 ] ).not.toHaveProperty( INTERNAL_ID_KEY ); + expect( JSON.stringify( table.attributes ) ).not.toContain( + INTERNAL_ID_KEY + ); + } ); + + it( 'preserves CRDT identities when deserialized blocks are merged back', () => { + const doc = new Y.Doc(); + const yblocks = doc.getArray< YBlock >( 'blocks' ); + + mergeCrdtBlocks( + yblocks, + [ createTableBlock( [ 'anchor', 'same', 'same' ] ) ], + null + ); + + const beforeIds = getYBody( yblocks ).map( + ( row ) => row[ INTERNAL_ID_KEY ] + ); + const beforeCellIds = getYBody( yblocks ).map( + ( row ) => row.cells[ 0 ][ INTERNAL_ID_KEY ] + ); + const runtimeBlocks = deserializeBlockAttributes( + yblocks.toJSON() as Block[] + ); + + mergeCrdtBlocks( yblocks, runtimeBlocks, null ); + + expect( + getYBody( yblocks ).map( ( row ) => row[ INTERNAL_ID_KEY ] ) + ).toEqual( beforeIds ); + expect( + getYBody( yblocks ).map( + ( row ) => row.cells[ 0 ][ INTERNAL_ID_KEY ] + ) + ).toEqual( beforeCellIds ); + } ); + + it( 'preserves a later duplicate row edit when the earlier duplicate is deleted', () => { + const docA = new Y.Doc(); + const docB = new Y.Doc(); + const yblocksA = docA.getArray< YBlock >( 'blocks' ); + const yblocksB = docB.getArray< YBlock >( 'blocks' ); + + mergeCrdtBlocks( + yblocksA, + [ createTableBlock( [ 'anchor', 'same', 'same' ] ) ], + null + ); + Y.applyUpdate( docB, Y.encodeStateAsUpdate( docA ) ); + + const stateVectorA = Y.encodeStateVector( docA ); + const stateVectorB = Y.encodeStateVector( docB ); + const runtimeBlocksA = deserializeBlockAttributes( + yblocksA.toJSON() as Block[] + ); + const runtimeBlocksB = deserializeBlockAttributes( + yblocksB.toJSON() as Block[] + ); + const bodyA = runtimeBlocksA[ 0 ].attributes.body as Array< { + cells: Array< Record< string, unknown > >; + } >; + const bodyB = runtimeBlocksB[ 0 ].attributes.body as Array< { + cells: Array< Record< string, unknown > >; + } >; + + bodyA[ 2 ].cells[ 0 ].content = 'edited-second-duplicate'; + bodyB.splice( 1, 1 ); + + mergeCrdtBlocks( yblocksA, runtimeBlocksA, null ); + mergeCrdtBlocks( yblocksB, runtimeBlocksB, null ); + + const updateA = Y.encodeStateAsUpdate( docA, stateVectorB ); + const updateB = Y.encodeStateAsUpdate( docB, stateVectorA ); + Y.applyUpdate( docA, updateB ); + Y.applyUpdate( docB, updateA ); + + expect( getRuntimeCellValues( yblocksA ) ).toEqual( [ + 'anchor', + 'edited-second-duplicate', + ] ); + expect( getRuntimeCellValues( yblocksB ) ).toEqual( [ + 'anchor', + 'edited-second-duplicate', + ] ); + } ); +} ); diff --git a/packages/core-data/src/utils/test/crdt-utils.ts b/packages/core-data/src/utils/test/crdt-utils.ts index 16333318deec4d..a54274ac7b9966 100644 --- a/packages/core-data/src/utils/test/crdt-utils.ts +++ b/packages/core-data/src/utils/test/crdt-utils.ts @@ -10,6 +10,7 @@ import { Y } from '@wordpress/sync'; import { asHtmlStringIndex, asRichTextOffset, + getAttributeKeyForYText, getYTextByAttributeKey, htmlIndexToRichTextOffset as typedHtmlIndexToRichTextOffset, richTextOffsetToHtmlIndex as typedRichTextOffsetToHtmlIndex, @@ -79,6 +80,53 @@ describe( 'getYTextByAttributeKey', () => { } ); } ); +describe( 'getAttributeKeyForYText', () => { + it( 'returns a top-level rich-text attribute key', () => { + const attributes = createAttachedAttributes(); + const text = new Y.Text( 'Top level' ); + attributes.set( 'content', text ); + + expect( getAttributeKeyForYText( attributes, text ) ).toBe( 'content' ); + } ); + + it( 'returns the current nested path after an array insertion', () => { + const attributes = createAttachedAttributes(); + const body = new Y.Array< Y.Map< unknown > >(); + const firstRow = new Y.Map< unknown >(); + const insertedRow = new Y.Map< unknown >(); + const cells = new Y.Array< Y.Map< unknown > >(); + const cell = new Y.Map< unknown >(); + const text = new Y.Text( 'Cell text' ); + + cell.set( 'content', text ); + cells.push( [ cell ] ); + firstRow.set( 'cells', cells ); + body.push( [ firstRow ] ); + attributes.set( 'body', body ); + + expect( getAttributeKeyForYText( attributes, text ) ).toBe( + 'body.0.cells.0.content' + ); + + insertedRow.set( 'cells', new Y.Array() ); + body.insert( 0, [ insertedRow ] ); + + expect( getAttributeKeyForYText( attributes, text ) ).toBe( + 'body.1.cells.0.content' + ); + } ); + + it( 'prefers direct top-level keys that contain dots', () => { + const attributes = createAttachedAttributes(); + const text = new Y.Text( 'Direct dotted key' ); + attributes.set( 'body.0.content', text ); + + expect( getAttributeKeyForYText( attributes, text ) ).toBe( + 'body.0.content' + ); + } ); +} ); + describe( 'htmlIndexToRichTextOffset', () => { it( 'returns the index unchanged when there are no tags', () => { expect( htmlIndexToRichTextOffset( 'hello world', 5 ) ).toBe( 5 ); diff --git a/packages/core-data/src/utils/test/crdt.ts b/packages/core-data/src/utils/test/crdt.ts index ea6e2dbcaa5e1e..196f05864faa7e 100644 --- a/packages/core-data/src/utils/test/crdt.ts +++ b/packages/core-data/src/utils/test/crdt.ts @@ -2,17 +2,22 @@ * WordPress dependencies */ import { Y } from '@wordpress/sync'; +import type { Block as WPBlock } from '@wordpress/blocks'; /** * External dependencies */ -import { describe, expect, it, jest, beforeEach } from '@jest/globals'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from '@jest/globals'; /** * Mock getBlockTypes so CRDT merging can identify rich-text attributes. - * Also stub __unstableSerializeAndClean so we can assert how it's invoked - * (the real implementation returns "" without registered block types, which - * isn't useful for asserting closure-capture behavior). */ jest.mock( '@wordpress/blocks', () => { const actual = jest.requireActual( '@wordpress/blocks' ) as Record< @@ -26,6 +31,10 @@ jest.mock( '@wordpress/blocks', () => { name: 'core/paragraph', attributes: { content: { type: 'rich-text' } }, }, + { + name: 'core/heading', + attributes: { content: { type: 'rich-text' } }, + }, { name: 'core/table', attributes: { @@ -46,25 +55,30 @@ jest.mock( '@wordpress/blocks', () => { }, }, ], - // Mocked so tests can control what the Code Editor sync path "parses" - // from raw content without needing real block-type registration. - parse: jest.fn( () => [] ), - __unstableSerializeAndClean: jest.fn( - ( blocks: unknown[] ) => `serialized:${ blocks?.length ?? 0 }` - ), }; } ); -/** - * WordPress dependencies - */ -import { parse } from '@wordpress/blocks'; +jest.mock( '@wordpress/block-editor', () => ( { + store: { name: 'core/block-editor' }, +} ) ); + +const { + __unstableSerializeAndClean, + getBlockType, + parse, + registerBlockType, + unregisterBlockType, +} = jest.requireActual( + '@wordpress/blocks' +) as typeof import('@wordpress/blocks'); + +import { createElement, RawHTML } from '@wordpress/element'; import { RichTextData } from '@wordpress/rich-text'; /** * Internal dependencies */ -import { CRDT_RECORD_MAP_KEY } from '../../sync'; +import { CRDT_DOC_META_PERSISTENCE_KEY, CRDT_RECORD_MAP_KEY } from '../../sync'; import { applyPostChangesToCRDTDoc, defaultCollectionSyncConfig, @@ -78,6 +92,81 @@ import { updateSelectionHistory } from '../crdt-selection'; import { createYMap, getRootMap, type YMapWrap } from '../crdt-utils'; import type { Post } from '../../entity-types'; +type ConsoleMatcherExpect = ( actual: Console ) => { + toHaveErrored: () => void; + toHaveWarned: () => void; +}; + +const expectConsole = expect as unknown as ConsoleMatcherExpect; + +function serializeBlocksForTest( blocks: Block[] | WPBlock[] ): string { + return __unstableSerializeAndClean( blocks as unknown as WPBlock[] ).trim(); +} + +function renderRichTextValue( value?: string | RichTextData ): string { + return typeof value === 'string' ? value : value?.toHTMLString() ?? ''; +} + +function registerEntityReferenceBlocks() { + registerBlockType( 'core/paragraph', { + apiVersion: 3, + category: 'text', + title: 'Paragraph', + attributes: { + content: { + type: 'rich-text', + source: 'rich-text', + selector: 'p', + }, + }, + save: ( { + attributes, + }: { + attributes: { content?: string | RichTextData }; + } ) => + createElement( + 'p', + null, + createElement( + RawHTML, + null, + renderRichTextValue( attributes.content ) + ) + ), + } ); + + registerBlockType( 'core/heading', { + apiVersion: 3, + category: 'text', + title: 'Heading', + attributes: { + content: { + type: 'rich-text', + source: 'rich-text', + selector: 'h1,h2,h3,h4,h5,h6', + }, + level: { + type: 'number', + default: 2, + }, + }, + save: ( { + attributes, + }: { + attributes: { content?: string | RichTextData; level?: number }; + } ) => + createElement( + `h${ attributes.level ?? 2 }`, + null, + createElement( + RawHTML, + null, + renderRichTextValue( attributes.content ) + ) + ), + } ); +} + // Default synced properties matching the base set built in entities.js, // plus 'categories' and 'tags' as example taxonomy rest_base values. const defaultSyncedProperties = new Set< string >( [ @@ -134,15 +223,17 @@ describe( 'crdt', () => { let doc: Y.Doc; beforeEach( () => { - doc = new Y.Doc(); + doc = new Y.Doc( { meta: new Map() } ); jest.clearAllMocks(); - jest.useFakeTimers(); } ); afterEach( () => { - jest.runAllTimers(); - jest.useRealTimers(); doc.destroy(); + for ( const blockName of [ 'core/paragraph', 'core/heading' ] ) { + if ( getBlockType( blockName ) ) { + unregisterBlockType( blockName ); + } + } } ); describe( 'applyPostChangesToCRDTDoc', () => { @@ -288,110 +379,195 @@ describe( 'crdt', () => { ); } ); - it( 'initializes blocks as Y.Array when not present', () => { - const changes = { - blocks: [], - }; - - applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); - - const blocks = map.get( 'blocks' ); - expect( blocks ).toBeInstanceOf( Y.Array ); - } ); - - it( 'sets blocks to undefined when blocks value is undefined and no content is provided', () => { - // First, set some blocks. - map.set( 'blocks', new Y.Array< YBlock >() ); - - const changes = { - blocks: undefined, - }; - - applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); + it( 'rebases local block insertions when the CRDT has remote changes since the last local snapshot', () => { + const initialBlocks = [ + { + name: 'core/paragraph', + clientId: 'existing-client-id', + attributes: { content: 'Initial content' }, + innerBlocks: [], + }, + ]; + applyPostChangesToCRDTDoc( + doc, + { blocks: initialBlocks }, + defaultSyncedProperties + ); - // The key should still exist, but the value should be undefined. - expect( map.has( 'blocks' ) ).toBe( true ); - expect( map.get( 'blocks' ) ).toBeUndefined(); - } ); + const yblocks = map.get( 'blocks' ) as YBlocks; + const attributes = yblocks + .get( 0 ) + .get( 'attributes' ) as Y.Map< unknown >; + const content = attributes.get( 'content' ) as Y.Text; + content.delete( 0, content.length ); + content.insert( 0, 'Remote content' ); - it( 'parses content into blocks when blocks=undefined is paired with new content', () => { - // Pre-populate the Y.Doc with two stable blocks. Simulates the - // state after the initial sync: peers share the same blocks Y.Array - // with stable clientIds on every YBlock. applyPostChangesToCRDTDoc( doc, { blocks: [ { - name: 'core/paragraph', - attributes: { content: 'Hello' }, - innerBlocks: [], - clientId: 'stable-first', + ...initialBlocks[ 0 ], + attributes: { content: 'Remote content' }, }, { name: 'core/paragraph', - attributes: { content: 'World' }, + clientId: 'inserted-client-id', + attributes: { + content: 'rtc-save-paragraph-marker', + }, innerBlocks: [], - clientId: 'stable-second', }, ], - } as PostChanges, + }, defaultSyncedProperties ); - // The Code Editor flow: dispatch `{ content, blocks: undefined }` - // when the user types. The new HTML edits the second paragraph - // only. `parse()` is mocked to return blocks with freshly minted - // clientIds — the sync layer must not let those overwrite the - // stable clientIds already in the Y.Array. - ( parse as jest.Mock ).mockReturnValueOnce( [ + expect( yblocks.toJSON() ).toEqual( [ + { + ...initialBlocks[ 0 ], + attributes: { content: 'Remote content' }, + }, { name: 'core/paragraph', - attributes: { content: 'Hello' }, + clientId: 'inserted-client-id', + attributes: { + content: 'rtc-save-paragraph-marker', + }, innerBlocks: [], - clientId: 'fresh-first', }, + ] ); + } ); + + it( 'rebases local block deletions when the CRDT has remote changes since the last local snapshot', () => { + const initialBlocks = [ { name: 'core/paragraph', - attributes: { content: 'World!' }, + clientId: 'existing-client-id', + attributes: { content: 'Initial content' }, innerBlocks: [], - clientId: 'fresh-second', }, - ] ); + { + name: 'core/paragraph', + clientId: 'deleted-client-id', + attributes: { + content: 'rtc-save-paragraph-marker', + }, + innerBlocks: [], + }, + ]; + applyPostChangesToCRDTDoc( + doc, + { blocks: initialBlocks }, + defaultSyncedProperties + ); + + const yblocks = map.get( 'blocks' ) as YBlocks; + const attributes = yblocks + .get( 0 ) + .get( 'attributes' ) as Y.Map< unknown >; + const content = attributes.get( 'content' ) as Y.Text; + content.delete( 0, content.length ); + content.insert( 0, 'Remote content' ); applyPostChangesToCRDTDoc( doc, { - content: - '

Hello

' + - '

World!

', - blocks: undefined, - } as PostChanges, + blocks: [ + { + ...initialBlocks[ 0 ], + attributes: { content: 'Remote content' }, + }, + ], + }, defaultSyncedProperties ); - const yblocks = map.get( 'blocks' ); - expect( yblocks ).toBeInstanceOf( Y.Array ); - const blocksArray = yblocks as YBlocks; - expect( blocksArray.length ).toBe( 2 ); + expect( yblocks.toJSON() ).toEqual( [ + { + ...initialBlocks[ 0 ], + attributes: { content: 'Remote content' }, + }, + ] ); + } ); - // Both clientIds must be preserved: the unchanged first block via - // the left-right diff sweep, the edited second block via the - // explicit clientId-skip in the update loop. - expect( blocksArray.get( 0 ).get( 'clientId' ) ).toBe( - 'stable-first' - ); - expect( blocksArray.get( 1 ).get( 'clientId' ) ).toBe( - 'stable-second' - ); + it( 'converges duplicate table row edit/delete through the post changes wrapper', () => { + const docB = new Y.Doc(); + + try { + applyPostChangesToCRDTDoc( + doc, + { + blocks: [ + createTableBlock( [ 'anchor', 'same', 'same' ] ), + ], + }, + defaultSyncedProperties + ); + Y.applyUpdate( docB, Y.encodeStateAsUpdate( doc ) ); - // The second block's content reflects the edit. - const updatedContent = ( - blocksArray - .get( 1 ) - .get( 'attributes' ) as unknown as YMapWrap< YBlockRecord > - ).get( 'content' ) as Y.Text; - expect( updatedContent.toString() ).toBe( 'World!' ); + const stateVectorA = Y.encodeStateVector( doc ); + const stateVectorB = Y.encodeStateVector( docB ); + const runtimeBlocksA = getRuntimeBlocksFromDoc( doc ); + const runtimeBlocksB = getRuntimeBlocksFromDoc( docB ); + + getRuntimeTableBody( runtimeBlocksA )[ 2 ].cells[ 0 ].content = + 'edited-second-duplicate'; + getRuntimeTableBody( runtimeBlocksB ).splice( 1, 1 ); + + applyPostChangesToCRDTDoc( + doc, + { blocks: runtimeBlocksA }, + defaultSyncedProperties + ); + applyPostChangesToCRDTDoc( + docB, + { blocks: runtimeBlocksB }, + defaultSyncedProperties + ); + + const updateA = Y.encodeStateAsUpdate( doc, stateVectorB ); + const updateB = Y.encodeStateAsUpdate( docB, stateVectorA ); + Y.applyUpdate( doc, updateB ); + Y.applyUpdate( docB, updateA ); + + expect( getTableBodyCellContentsFromDoc( doc ) ).toEqual( [ + 'anchor', + 'edited-second-duplicate', + ] ); + expect( getTableBodyCellContentsFromDoc( docB ) ).toEqual( [ + 'anchor', + 'edited-second-duplicate', + ] ); + } finally { + docB.destroy(); + } + } ); + + it( 'initializes blocks as Y.Array when not present', () => { + const changes = { + blocks: [], + }; + + applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); + + const blocks = map.get( 'blocks' ); + expect( blocks ).toBeInstanceOf( Y.Array ); + } ); + + it( 'sets blocks to undefined when blocks value is undefined', () => { + // First, set some blocks. + map.set( 'blocks', new Y.Array< YBlock >() ); + + const changes = { + blocks: undefined, + }; + + applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); + + // The key should still exist, but the value should be undefined. + expect( map.has( 'blocks' ) ).toBe( true ); + expect( map.get( 'blocks' ) ).toBeUndefined(); } ); it( 'syncs content as Y.Text', () => { @@ -463,6 +639,30 @@ describe( 'crdt', () => { expect( map.get( 'content' )?.toString() ).toBe( 'New content' ); } ); + it( 'clears stale content text when syncing block changes', () => { + applyPostChangesToCRDTDoc( + doc, + { content: 'Stale content' } as PostChanges, + defaultSyncedProperties + ); + + applyPostChangesToCRDTDoc( + doc, + { + blocks: parse( + [ + '', + '

Block content

', + '', + ].join( '\n' ) + ), + } as PostChanges, + defaultSyncedProperties + ); + + expect( map.get( 'content' )?.toString() ?? '' ).toBe( '' ); + } ); + it( 'updates existing Y.Text excerpt in place via mergeRichTextUpdate', () => { // First apply to create the Y.Text. applyPostChangesToCRDTDoc( @@ -532,23 +732,6 @@ describe( 'crdt', () => { expect( metaMap?.get( 'custom_field' ) ).toBe( 'value' ); } ); - it( 'skips function-valued content in changes', () => { - const changes = { - content: ( { - blocks: blocksForSerialization = [], - }: { - blocks: Block[]; - } ) => - blocksForSerialization - .map( ( b ) => b.attributes.content ) - .join( '' ), - } as unknown as PostChanges; - - applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties ); - - expect( map.has( 'content' ) ).toBe( false ); - } ); - it( 'syncs taxonomy rest_base values included in syncedProperties', () => { const changes = { categories: [ 1, 2, 3 ], @@ -791,6 +974,334 @@ describe( 'crdt', () => { expect( changes.blocks ).toBeUndefined(); } ); + it( 'does not invalidate persisted blocks when only entity-normalized originalContent differs from generated content', () => { + registerBlockType( 'core/paragraph', { + apiVersion: 3, + category: 'text', + title: 'Paragraph', + attributes: { + content: { + type: 'rich-text', + source: 'rich-text', + selector: 'p', + }, + }, + save: ( { + attributes, + }: { + attributes: { content?: string | RichTextData }; + } ) => + createElement( + 'p', + null, + createElement( + RawHTML, + null, + renderRichTextValue( attributes.content ) + ) + ), + } ); + + const originalContent = [ + '', + '

Entity refs: ∉ / ¬in text, nbsp   gap, quote "value", apos 'value', lt < and gt >.

', + '', + ].join( '\n' ); + const blocks = parse( originalContent ); + const generatedBlocks = blocks.map( ( block ) => { + const generatedBlock = { ...block, isValid: true }; + delete generatedBlock.__unstableBlockSource; + delete generatedBlock.originalContent; + delete generatedBlock.validationIssues; + return generatedBlock; + } ); + const persistedContent = + __unstableSerializeAndClean( generatedBlocks ).trim(); + + expect( __unstableSerializeAndClean( blocks ).trim() ).not.toBe( + persistedContent + ); + expectConsole( console ).toHaveWarned(); + expectConsole( console ).toHaveErrored(); + + applyPostChangesToCRDTDoc( + doc, + { blocks } as PostChanges, + defaultSyncedProperties + ); + doc.meta?.set( CRDT_DOC_META_PERSISTENCE_KEY, true ); + + const changes = getPostChangesFromCRDTDoc( + doc, + { + content: { + raw: persistedContent, + rendered: persistedContent, + }, + } as unknown as Post, + defaultSyncedProperties + ); + + expect( changes ).not.toHaveProperty( 'blocks' ); + } ); + + it( 'invalidates persisted blocks when generated content differs from persisted content', () => { + registerBlockType( 'core/paragraph', { + apiVersion: 3, + category: 'text', + title: 'Paragraph', + attributes: { + content: { + type: 'rich-text', + source: 'rich-text', + selector: 'p', + }, + }, + save: ( { + attributes, + }: { + attributes: { content?: string | RichTextData }; + } ) => + createElement( + 'p', + null, + createElement( + RawHTML, + null, + renderRichTextValue( attributes.content ) + ) + ), + } ); + + const originalContent = [ + '', + '

Entity refs: ∉ / ¬in text, nbsp   gap.

', + '', + ].join( '\n' ); + const blocks = parse( originalContent ); + + expectConsole( console ).toHaveWarned(); + expectConsole( console ).toHaveErrored(); + + applyPostChangesToCRDTDoc( + doc, + { blocks } as PostChanges, + defaultSyncedProperties + ); + doc.meta?.set( CRDT_DOC_META_PERSISTENCE_KEY, true ); + + const changes = getPostChangesFromCRDTDoc( + doc, + { + content: { + raw: [ + '', + '

Changed server content.

', + '', + ].join( '\n' ), + }, + } as unknown as Post, + defaultSyncedProperties + ); + + expect( changes ).toHaveProperty( 'blocks' ); + } ); + + it( 'hydrates stale transient blocks when persisted content already matches the CRDT blocks', () => { + registerBlockType( 'core/paragraph', { + apiVersion: 3, + category: 'text', + title: 'Paragraph', + attributes: { + content: { + type: 'rich-text', + source: 'rich-text', + selector: 'p', + }, + }, + save: ( { + attributes, + }: { + attributes: { content?: string | RichTextData }; + } ) => + createElement( + 'p', + null, + createElement( + RawHTML, + null, + renderRichTextValue( attributes.content ) + ) + ), + } ); + + const staleContent = [ + '', + '

Old editor blocks.

', + '', + ].join( '\n' ); + const persistedContent = [ + '', + '

Saved marker from persisted CRDT.

', + '', + ].join( '\n' ); + + applyPostChangesToCRDTDoc( + doc, + { blocks: parse( persistedContent ) } as PostChanges, + defaultSyncedProperties + ); + doc.meta?.set( CRDT_DOC_META_PERSISTENCE_KEY, true ); + + const changes = getPostChangesFromCRDTDoc( + doc, + { + blocks: parse( staleContent ), + content: { + raw: persistedContent, + rendered: persistedContent, + }, + } as unknown as Post, + defaultSyncedProperties + ); + + expect( changes ).toHaveProperty( 'blocks' ); + expect( + __unstableSerializeAndClean( + changes.blocks as unknown as WPBlock[] + ).trim() + ).toBe( persistedContent ); + } ); + + it( 'does not invalidate persisted blocks for equivalent entity references and link attribute order', () => { + registerEntityReferenceBlocks(); + + const staleBlocks: WPBlock[] = [ + { + name: 'core/paragraph', + clientId: 'paragraph-1', + attributes: { + content: + 'D29 escaped <em>paragraph</em> and ¬in text.', + }, + innerBlocks: [], + isValid: false, + originalContent: + '

D29 escaped <em>paragraph</em> and ¬in text.

', + }, + { + name: 'core/heading', + clientId: 'heading-1', + attributes: { + content: + 'D29 heading <em>title</em>.', + level: 2, + }, + innerBlocks: [], + isValid: false, + originalContent: + '

D29 heading <em>title</em>.

', + }, + ]; + const generatedBlocks = staleBlocks.map( ( block ) => { + const { + __unstableBlockSource, + originalContent, + validationIssues, + ...generatedBlock + } = block; + void __unstableBlockSource; + void originalContent; + void validationIssues; + return { + ...generatedBlock, + innerBlocks: generatedBlock.innerBlocks as Block[], + isValid: true, + }; + } ); + const persistedContent = [ + '', + '

D29 escaped <em>paragraph</em> and ¬in text.

', + '', + '', + '', + '

D29 heading <em>title</em>.

', + '', + ].join( '\n' ); + + expect( serializeBlocksForTest( staleBlocks ) ).not.toBe( + persistedContent + ); + expect( serializeBlocksForTest( generatedBlocks ) ).not.toBe( + persistedContent + ); + + applyPostChangesToCRDTDoc( + doc, + { + blocks: staleBlocks, + content: persistedContent, + } as unknown as PostChanges, + defaultSyncedProperties + ); + doc.meta?.set( CRDT_DOC_META_PERSISTENCE_KEY, true ); + + const changes = getPostChangesFromCRDTDoc( + doc, + { + content: { + raw: persistedContent, + rendered: persistedContent, + }, + } as unknown as Post, + defaultSyncedProperties + ); + + expect( changes ).not.toHaveProperty( 'blocks' ); + } ); + + it( 'invalidates persisted entity blocks when the generated content really changed', () => { + registerEntityReferenceBlocks(); + + const staleBlocks = [ + { + name: 'core/paragraph', + clientId: 'paragraph-1', + attributes: { + content: + 'D29 escaped <em>paragraph</em> and ¬in text.', + }, + innerBlocks: [], + isValid: false, + originalContent: + '

D29 escaped <em>paragraph</em> and ¬in text.

', + }, + ]; + + applyPostChangesToCRDTDoc( + doc, + { blocks: staleBlocks } as unknown as PostChanges, + defaultSyncedProperties + ); + doc.meta?.set( CRDT_DOC_META_PERSISTENCE_KEY, true ); + + const changes = getPostChangesFromCRDTDoc( + doc, + { + content: { + raw: [ + '', + '

Changed server content.

', + '', + ].join( '\n' ), + }, + } as unknown as Post, + defaultSyncedProperties + ); + + expect( changes ).toHaveProperty( 'blocks' ); + } ); + it( 'detects content changes from string value', () => { map.set( 'content', new Y.Text( 'New content' ) ); @@ -839,6 +1350,39 @@ describe( 'crdt', () => { expect( changes ).not.toHaveProperty( 'content' ); } ); + it( 'ignores stale content text when persisted block data matches the edited record', () => { + const parsedBlocks = parse( + [ + '', + '

Block content

', + '', + ].join( '\n' ) + ); + const persistedContent = serializeBlocksForTest( parsedBlocks ); + + applyPostChangesToCRDTDoc( + doc, + { blocks: parsedBlocks } as PostChanges, + defaultSyncedProperties + ); + map.set( 'content', new Y.Text( 'Stale content' ) ); + doc.meta?.set( CRDT_DOC_META_PERSISTENCE_KEY, true ); + + const changes = getPostChangesFromCRDTDoc( + doc, + { + content: { + raw: persistedContent, + rendered: persistedContent, + }, + } as unknown as Post, + defaultSyncedProperties + ); + + expect( changes ).not.toHaveProperty( 'blocks' ); + expect( changes ).not.toHaveProperty( 'content' ); + } ); + it( 'includes meta in changes', () => { const metaMap = createYMap(); metaMap.set( 'public_meta', 'new value' ); @@ -1068,102 +1612,6 @@ describe( 'crdt', () => { expect( changes.selection ).toBeUndefined(); } ); } ); - - it( 'injects a closure-based content function when blocks changed but content did not', () => { - addBlockToDoc( map, 'block-1', 'Hello world' ); - - const editedRecord = { - title: 'CRDT Title', - status: 'draft', - content: { raw: 'Same content', rendered: 'Same content' }, - blocks: [], - } as unknown as Post; - - const changes = getPostChangesFromCRDTDoc( - doc, - editedRecord, - defaultSyncedProperties - ); - - // Blocks changed, content didn't, so a lazy content function is injected. - expect( changes.blocks ).toBeDefined(); - expect( typeof changes.content ).toBe( 'function' ); - } ); - - it( 'injected content function captures the synced blocks and ignores its caller-supplied argument', () => { - addBlockToDoc( map, 'block-1', 'Hello world' ); - - const editedRecord = { - title: 'CRDT Title', - status: 'draft', - content: { raw: 'Same content', rendered: 'Same content' }, - blocks: [], - } as unknown as Post; - - const changes = getPostChangesFromCRDTDoc( - doc, - editedRecord, - defaultSyncedProperties - ); - - // The injected function takes no parameters and serializes the - // captured (synced) blocks. This is what makes getEditedPostContent - // keep working after the Code Editor clears `record.blocks` to force - // a re-parse: the closure already has the right blocks on hand. - // - // The mocked __unstableSerializeAndClean returns "serialized:" - // where n is the length of the blocks it was called with. The - // captured blocks have one entry, so both calls below should yield - // "serialized:1" (proving the closure ignores its argument and - // uses the captured blocks instead). - const contentFn = changes.content as ( args?: { - blocks: Block[]; - } ) => string; - expect( contentFn() ).toBe( 'serialized:1' ); - expect( contentFn( { blocks: [] } ) ).toBe( 'serialized:1' ); - } ); - - it( 'does not inject a content function when content also changed in the doc', () => { - addBlockToDoc( map, 'block-1', 'Hello world' ); - map.set( 'content', new Y.Text( 'New content' ) ); - - const editedRecord = { - title: 'CRDT Title', - status: 'draft', - content: { raw: 'Old content', rendered: 'Old content' }, - blocks: [], - } as unknown as Post; - - const changes = getPostChangesFromCRDTDoc( - doc, - editedRecord, - defaultSyncedProperties - ); - - // Content changed directly, so it should be a string, not a function. - expect( changes.blocks ).toBeDefined(); - expect( typeof changes.content ).toBe( 'string' ); - expect( changes.content ).toBe( 'New content' ); - } ); - - it( 'does not inject a content function when blocks did not change', () => { - map.set( 'content', new Y.Text( 'Same content' ) ); - - const editedRecord = { - title: 'CRDT Title', - status: 'draft', - content: { raw: 'Same content', rendered: 'Same content' }, - } as unknown as Post; - - const changes = getPostChangesFromCRDTDoc( - doc, - editedRecord, - defaultSyncedProperties - ); - - expect( changes.blocks ).toBeUndefined(); - expect( changes.content ).toBeUndefined(); - } ); } ); } ); @@ -1200,3 +1648,47 @@ function addBlockToDoc( return ytext; } + +function createTableBlock( values: string[] ): Block { + return { + name: 'core/table', + clientId: 'table', + attributes: { + body: values.map( ( value ) => ( { + cells: [ + { + content: value, + tag: 'td', + }, + ], + } ) ), + }, + innerBlocks: [], + }; +} + +function getRuntimeBlocksFromDoc( ydoc: Y.Doc ): Block[] { + return getPostChangesFromCRDTDoc( + ydoc, + { blocks: [] } as unknown as Post, + defaultSyncedProperties + ).blocks as Block[]; +} + +function getRuntimeTableBody( blocks: Block[] ) { + return blocks[ 0 ].attributes.body as Array< { + cells: Array< Record< string, unknown > >; + } >; +} + +function getCellContentText( content: unknown ) { + return typeof content === 'object' && content && 'valueOf' in content + ? String( content.valueOf() ) + : content; +} + +function getTableBodyCellContentsFromDoc( ydoc: Y.Doc ) { + return getRuntimeTableBody( getRuntimeBlocksFromDoc( ydoc ) ).map( + ( row ) => getCellContentText( row.cells[ 0 ].content ) + ); +} diff --git a/packages/core-data/src/utils/test/rtc-table-duplicate-body-revision-loss.test.ts b/packages/core-data/src/utils/test/rtc-table-duplicate-body-revision-loss.test.ts new file mode 100644 index 00000000000000..4acf06554a4cec --- /dev/null +++ b/packages/core-data/src/utils/test/rtc-table-duplicate-body-revision-loss.test.ts @@ -0,0 +1,346 @@ +/** + * External dependencies + */ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; + +/** + * WordPress dependencies + */ +import { + type CRDTDoc, + type ObjectData, + type SyncConfig, + Y, +} from '@wordpress/sync'; + +/** + * Mock sync providers so the SyncManager test can deterministically deliver + * normal Yjs updates after both collaborators make local table edits. + */ +jest.mock( '../../../../sync/src/providers', () => ( { + getProviderCreators: jest.fn(), +} ) ); + +jest.mock( '@wordpress/blocks', () => ( { + getBlockTypes: () => [ + { + name: 'core/table', + attributes: { + body: { + type: 'array', + query: { + cells: { + type: 'array', + query: { + content: { type: 'rich-text' }, + tag: { type: 'string' }, + }, + }, + }, + }, + }, + }, + ], +} ) ); + +/** + * Internal dependencies + */ +import { createSyncManager } from '../../../../sync/src/manager'; +import { getProviderCreators } from '../../../../sync/src/providers'; +import { CRDT_RECORD_MAP_KEY } from '../../sync'; +import { + deserializeBlockAttributes, + mergeCrdtBlocks, + type Block, + type YBlock, +} from '../crdt-blocks'; + +const OBJECT_TYPE = 'postType/post'; +const OBJECT_ID = '1'; +const EDITED_MARKER = 'edited-second-duplicate-body-marker'; +const INITIAL_ROWS = [ 'anchor', 'same', 'same' ]; +const mockGetProviderCreators = jest.mocked( getProviderCreators ); + +type TableCell = { + content?: unknown; + tag?: string; + [ key: string ]: unknown; +}; + +type TableRow = { + cells: TableCell[]; + [ key: string ]: unknown; +}; + +function createTableBlock( values: string[] ): Block { + return { + name: 'core/table', + clientId: 'table', + attributes: { + body: values.map( ( value ) => ( { + cells: [ + { + content: value, + tag: 'td', + }, + ], + } ) ), + }, + innerBlocks: [], + }; +} + +function createTableBlockFromRows( + sourceBlock: Block, + rows: TableRow[] +): Block { + return { + ...sourceBlock, + attributes: { + ...sourceBlock.attributes, + body: rows, + }, + }; +} + +function cloneBlocks( blocks: Block[] ): Block[] { + return JSON.parse( JSON.stringify( blocks ) ) as Block[]; +} + +function getBlocksArray( ydoc: CRDTDoc ): Y.Array< YBlock > { + const recordMap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); + let blocks = recordMap.get( 'blocks' ); + + if ( ! ( blocks instanceof Y.Array ) ) { + blocks = new Y.Array< YBlock >(); + recordMap.set( 'blocks', blocks ); + } + + return blocks as Y.Array< YBlock >; +} + +function applyTableRows( ydoc: CRDTDoc, values: string[] ) { + mergeCrdtBlocks( + getBlocksArray( ydoc ), + [ createTableBlock( values ) ], + null + ); +} + +function getSerializableBlocks( ydoc: CRDTDoc ): Block[] { + return getBlocksArray( ydoc ).toJSON() as Block[]; +} + +function getTableRowsFromBlocks( blocks: Block[] ): TableRow[] { + return ( blocks[ 0 ]?.attributes.body ?? [] ) as TableRow[]; +} + +function createBlocksWithEditedTableRow( + ydoc: CRDTDoc, + index: number, + content: string +): Block[] { + const blocks = getSerializableBlocks( ydoc ); + const rows = getTableRowsFromBlocks( blocks ).map( ( row, rowIndex ) => { + if ( rowIndex !== index ) { + return row; + } + + return { + ...row, + cells: row.cells.map( ( cell, cellIndex ) => + cellIndex === 0 ? { ...cell, content } : cell + ), + }; + } ); + + return [ createTableBlockFromRows( blocks[ 0 ], rows ) ]; +} + +function createBlocksWithDeletedTableRow( + ydoc: CRDTDoc, + index: number +): Block[] { + const blocks = getSerializableBlocks( ydoc ); + const rows = getTableRowsFromBlocks( blocks ).filter( + ( _row, rowIndex ) => rowIndex !== index + ); + + return [ createTableBlockFromRows( blocks[ 0 ], rows ) ]; +} + +function getTableCellContents( ydoc: CRDTDoc ): string[] { + const body = getTableRowsFromBlocks( getSerializableBlocks( ydoc ) ); + + return ( body ?? [] ).map( ( row ) => String( row.cells[ 0 ].content ) ); +} + +function syncDocs( first: CRDTDoc, second: CRDTDoc ) { + Y.applyUpdateV2( second, Y.encodeStateAsUpdateV2( first ) ); + Y.applyUpdateV2( first, Y.encodeStateAsUpdateV2( second ) ); +} + +function createBlocksSyncConfig( + onApply?: ( ydoc: CRDTDoc ) => void +): SyncConfig { + return { + applyChangesToCRDTDoc: ( ydoc: CRDTDoc, changes: ObjectData ) => { + onApply?.( ydoc ); + const blocks = changes.blocks as Block[] | undefined; + + if ( blocks ) { + mergeCrdtBlocks( getBlocksArray( ydoc ), blocks, null ); + } + }, + getChangesFromCRDTDoc: ( ydoc: CRDTDoc, editedRecord: ObjectData ) => { + const blocks = deserializeBlockAttributes( + getBlocksArray( ydoc ).toJSON() as Block[] + ); + + return JSON.stringify( blocks ) === + JSON.stringify( editedRecord.blocks ) + ? {} + : { blocks }; + }, + getPersistedCRDTDoc: () => null, + }; +} + +function createHandlers( blocks: Block[] ) { + let editedBlocks = cloneBlocks( blocks ); + + return { + addUndoMeta: jest.fn(), + editRecord: jest.fn( ( changes: { blocks?: Block[] } ) => { + if ( changes.blocks ) { + editedBlocks = cloneBlocks( changes.blocks ); + } + } ), + getEditedRecord: jest.fn( async () => ( { + id: 1, + blocks: cloneBlocks( editedBlocks ), + } ) ), + onUndoStackChange: jest.fn(), + onStatusChange: jest.fn(), + persistCRDTDoc: jest.fn(), + refetchRecord: jest.fn( async () => {} ), + restoreUndoMeta: jest.fn(), + }; +} + +function waitForDeferredUpdate() { + return new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); +} + +describe( 'duplicate table body revision loss', () => { + beforeEach( () => { + jest.clearAllMocks(); + mockGetProviderCreators.mockReturnValue( [ + jest.fn( async () => ( { + destroy: jest.fn(), + on: jest.fn(), + } ) ), + ] ); + } ); + + it( 'does not lose a later duplicate table row edit when another session deletes the earlier duplicate row', () => { + const editorDoc = new Y.Doc(); + const deleterDoc = new Y.Doc(); + + try { + // Old/no-CRDT posts can be independently bootstrapped from the same + // serialized table body in two browser sessions. + applyTableRows( editorDoc, INITIAL_ROWS ); + applyTableRows( deleterDoc, INITIAL_ROWS ); + syncDocs( editorDoc, deleterDoc ); + + mergeCrdtBlocks( + getBlocksArray( editorDoc ), + createBlocksWithEditedTableRow( editorDoc, 2, EDITED_MARKER ), + null + ); + mergeCrdtBlocks( + getBlocksArray( deleterDoc ), + createBlocksWithDeletedTableRow( deleterDoc, 1 ), + null + ); + syncDocs( editorDoc, deleterDoc ); + + expect( getTableCellContents( editorDoc ) ).toContain( + EDITED_MARKER + ); + expect( getTableCellContents( deleterDoc ) ).toContain( + EDITED_MARKER + ); + } finally { + editorDoc.destroy(); + deleterDoc.destroy(); + } + } ); + + it( 'does not let SyncManager lose a duplicate table row edit after independent no-CRDT bootstraps', async () => { + let editorDoc: CRDTDoc | undefined; + let deleterDoc: CRDTDoc | undefined; + const editorManager = createSyncManager(); + const deleterManager = createSyncManager(); + const initialBlocks = [ createTableBlock( INITIAL_ROWS ) ]; + + await editorManager.load( + createBlocksSyncConfig( ( ydoc ) => { + editorDoc = ydoc; + } ), + OBJECT_TYPE, + OBJECT_ID, + { id: 1, blocks: initialBlocks }, + createHandlers( initialBlocks ) + ); + await deleterManager.load( + createBlocksSyncConfig( ( ydoc ) => { + deleterDoc = ydoc; + } ), + OBJECT_TYPE, + OBJECT_ID, + { id: 1, blocks: initialBlocks }, + createHandlers( initialBlocks ) + ); + + expect( editorDoc ).toBeDefined(); + expect( deleterDoc ).toBeDefined(); + syncDocs( editorDoc as CRDTDoc, deleterDoc as CRDTDoc ); + + editorManager.update( + OBJECT_TYPE, + OBJECT_ID, + { + blocks: createBlocksWithEditedTableRow( + editorDoc as CRDTDoc, + 2, + EDITED_MARKER + ), + }, + 'LOCAL_EDITOR_ORIGIN' + ); + deleterManager.update( + OBJECT_TYPE, + OBJECT_ID, + { + blocks: createBlocksWithDeletedTableRow( + deleterDoc as CRDTDoc, + 1 + ), + }, + 'LOCAL_EDITOR_ORIGIN' + ); + await waitForDeferredUpdate(); + syncDocs( editorDoc as CRDTDoc, deleterDoc as CRDTDoc ); + await waitForDeferredUpdate(); + + expect( getTableCellContents( editorDoc as CRDTDoc ) ).toContain( + EDITED_MARKER + ); + expect( getTableCellContents( deleterDoc as CRDTDoc ) ).toContain( + EDITED_MARKER + ); + } ); +} ); diff --git a/packages/e2e-test-utils-playwright/src/editor/publish-post.ts b/packages/e2e-test-utils-playwright/src/editor/publish-post.ts index f22fd9fc843e34..ce67e14a90101e 100644 --- a/packages/e2e-test-utils-playwright/src/editor/publish-post.ts +++ b/packages/e2e-test-utils-playwright/src/editor/publish-post.ts @@ -33,17 +33,38 @@ export async function publishPost( this: Editor ) { await entitiesSaveButton.click(); } + const editorPublishRegion = this.page.getByRole( 'region', { + name: 'Editor publish', + } ); + const confirmPublishButton = editorPublishRegion.getByRole( 'button', { + name: 'Publish', + exact: true, + } ); + const openPublishPanelButton = editorPublishRegion.getByRole( 'button', { + name: 'Open publish panel', + exact: true, + } ); + + if ( ! ( await confirmPublishButton.isVisible() ) ) { + if ( await openPublishPanelButton.isVisible() ) { + await openPublishPanelButton.click(); + } + } + // Handle saving just the post. - await this.page - .getByRole( 'region', { - name: 'Editor publish', - } ) - .getByRole( 'button', { name: 'Publish', exact: true } ) - .click(); + await confirmPublishButton.click(); await this.page .getByRole( 'button', { name: 'Dismiss this notice' } ) .filter( { hasText: 'published' } ) + .or( + this.page + .locator( + '.components-snackbar, .components-notice, [role="status"], [aria-live]' + ) + .filter( { hasText: /published/i } ) + ) + .first() .waitFor(); const postId = new URL( this.page.url() ).searchParams.get( 'post' ); diff --git a/packages/e2e-tests/plugins/rtc-websocket-provider/src/index.js b/packages/e2e-tests/plugins/rtc-websocket-provider/src/index.js index d2fde096fe9e0f..37d1726d7e307d 100644 --- a/packages/e2e-tests/plugins/rtc-websocket-provider/src/index.js +++ b/packages/e2e-tests/plugins/rtc-websocket-provider/src/index.js @@ -12,6 +12,7 @@ import { WebsocketProvider } from 'y-websocket'; const TEST_PROVIDER_NAMESPACE = 'gutenberg-test/rtc-websocket-provider'; const DEFAULT_URL = 'ws://127.0.0.1:18991'; +const HAS_PROVIDER_SYNCED_REMOTE_STATE_META = 'hasProviderSyncedRemoteState'; const settings = window.gutenbergTestWebSocketSync || {}; const globalState = ( window.__gutenbergTestWebSocketSync = { @@ -37,9 +38,22 @@ function updateDebugState( room, patch ) { globalState.tick += 1; } +function areUint8ArraysEqual( a, b ) { + if ( a.length !== b.length ) { + return false; + } + + return a.every( ( value, index ) => value === b[ index ] ); +} + function createWebSocketProvider() { return async ( { awareness, objectType, objectId, ydoc } ) => { const room = objectId ? `${ objectType }:${ objectId }` : objectType; + const initialStateVector = window.wp.sync.Y.encodeStateVector( ydoc ); + let resolveInitialSync; + const initialSync = new Promise( ( resolve ) => { + resolveInitialSync = resolve; + } ); updateDebugState( room, { clientId: ydoc.clientID, @@ -76,6 +90,24 @@ function createWebSocketProvider() { // landed and the doc reflects the server state. Tests that need real // convergence should wait on `synced`, not just `status`. const onSync = ( isSynced ) => { + if ( isSynced ) { + const currentStateVector = + window.wp.sync.Y.encodeStateVector( ydoc ); + + if ( + ! areUint8ArraysEqual( + currentStateVector, + initialStateVector + ) + ) { + ydoc.meta.set( + HAS_PROVIDER_SYNCED_REMOTE_STATE_META, + true + ); + } + + resolveInitialSync(); + } updateDebugState( room, { synced: !! isSynced } ); }; provider.on( 'sync', onSync ); @@ -90,6 +122,8 @@ function createWebSocketProvider() { awarenessInstance.on( 'change', onAwarenessChange ); onAwarenessChange(); + await initialSync; + return { destroy: () => { awarenessInstance.off( 'change', onAwarenessChange ); @@ -104,6 +138,9 @@ function createWebSocketProvider() { on: ( event, callback ) => { if ( event === 'status' ) { statusListeners.add( callback ); + callback( { + status: ensureRoomDebugState( room ).status, + } ); } }, }; diff --git a/packages/editor/src/components/collaborators-overlay/compute-selection.ts b/packages/editor/src/components/collaborators-overlay/compute-selection.ts index 26380851d787bc..cbdbed227c6709 100644 --- a/packages/editor/src/components/collaborators-overlay/compute-selection.ts +++ b/packages/editor/src/components/collaborators-overlay/compute-selection.ts @@ -27,7 +27,8 @@ interface OverlayContext { /** Selection rects and the resolved block element for a single-block selection. */ interface SingleBlockResult { rects: SelectionRect[]; - blockElement: HTMLElement | null; + startElement: HTMLElement | null; + endElement: HTMLElement | null; } /** Selection rects and the resolved block elements for a multi-block selection. */ @@ -51,8 +52,9 @@ export interface SelectionVisual { * matching `data-wp-block-attribute-key` inside the block. This is what makes * cursor placement work for blocks with multiple RichText fields (e.g. * `core/table` cells: `body.0.cells.0.content`, etc.). Falls back to the - * block element when `attributeKey` is missing (WholeBlock selections, - * older senders, or DOM lookup miss). + * block element only when `attributeKey` is missing (WholeBlock selections + * or older senders). Keyed selections must resolve to the exact RichText + * target because their offsets are local to that target. * * @param editorDocument - The editor document. * @param resolvedSelection - The resolved selection. @@ -66,19 +68,36 @@ function resolveTargetElement( return null; } - const blockElement = editorDocument.querySelector< HTMLElement >( - `[data-block="${ resolvedSelection.localClientId }"]` + const blockElement = Array.from( + editorDocument.querySelectorAll< HTMLElement >( '[data-block]' ) + ).find( + ( element ) => + element.getAttribute( 'data-block' ) === + resolvedSelection.localClientId ); if ( ! blockElement || ! resolvedSelection.attributeKey ) { + return blockElement ?? null; + } + + if ( + blockElement.getAttribute( 'data-wp-block-attribute-key' ) === + resolvedSelection.attributeKey + ) { return blockElement; } - const attrKey = CSS.escape( resolvedSelection.attributeKey ); return ( - blockElement.querySelector< HTMLElement >( - `[data-wp-block-attribute-key="${ attrKey }"]` - ) ?? blockElement + Array.from( + blockElement.querySelectorAll< HTMLElement >( + '[data-wp-block-attribute-key]' + ) + ).find( + ( element ) => + element.getAttribute( 'data-wp-block-attribute-key' ) === + resolvedSelection.attributeKey && + element.closest( '[data-block]' ) === blockElement + ) ?? null ); } @@ -126,13 +145,17 @@ function computeCursorOnly( start: ResolvedSelection, overlayContext: OverlayContext ): SelectionVisual { - if ( ! start.localClientId ) { + if ( ! start.localClientId || start.richTextOffset === null ) { return {}; } const targetElement = resolveTargetElement( overlayContext.editorDocument, start ); + if ( ! targetElement ) { + return {}; + } + return { coords: getCursorPosition( start.richTextOffset, @@ -178,8 +201,7 @@ function computeTextSelection( if ( selection.type === SelectionType.SelectionInOneBlock ) { const result = computeSingleBlockRects( start, end, overlayContext ); allRects = result.rects; - // Single block: start and end share the same block element. - activeEndBlock = result.blockElement; + activeEndBlock = isReverse ? result.startElement : result.endElement; } else { const result = computeMultiBlockRects( start, end, overlayContext ); allRects = result.rects; @@ -207,6 +229,9 @@ function computeTextSelection( overlayContext.editorDocument, start ); + if ( ! startBlock ) { + return {}; + } return { coords: getCursorPosition( @@ -231,30 +256,119 @@ function computeSingleBlockRects( end: ResolvedSelection, overlayContext: OverlayContext ): SingleBlockResult { - const blockElement = resolveTargetElement( + const startElement = resolveTargetElement( overlayContext.editorDocument, start ); + const endElement = resolveTargetElement( + overlayContext.editorDocument, + end + ); if ( - ! blockElement || + ! startElement || + ! endElement || start.richTextOffset === null || end.richTextOffset === null ) { - return { rects: [], blockElement: null }; + return { rects: [], startElement: null, endElement: null }; + } + + if ( startElement === endElement ) { + return { + rects: + getSelectionRects( + startElement, + start.richTextOffset, + end.richTextOffset, + overlayContext.editorDocument, + overlayContext.overlayRect + ) ?? [], + startElement, + endElement, + }; + } + + const startIsAfterEnd = isNodeBefore( endElement, startElement ); + const firstElement = startIsAfterEnd ? endElement : startElement; + const lastElement = startIsAfterEnd ? startElement : endElement; + const firstOffset = startIsAfterEnd + ? end.richTextOffset + : start.richTextOffset; + const lastOffset = startIsAfterEnd + ? start.richTextOffset + : end.richTextOffset; + const allRects: SelectionRect[] = []; + const firstRects = getSelectionRects( + firstElement, + firstOffset, + Number.MAX_SAFE_INTEGER, + overlayContext.editorDocument, + overlayContext.overlayRect + ); + if ( firstRects ) { + allRects.push( ...firstRects ); } + + for ( const intermediateElement of getRichTextElementsBetween( + firstElement, + lastElement + ) ) { + const intermediateRects = getSelectionRects( + intermediateElement, + 0, + Number.MAX_SAFE_INTEGER, + overlayContext.editorDocument, + overlayContext.overlayRect + ); + if ( intermediateRects ) { + allRects.push( ...intermediateRects ); + } + } + + const lastRects = getSelectionRects( + lastElement, + 0, + lastOffset, + overlayContext.editorDocument, + overlayContext.overlayRect + ); + if ( lastRects ) { + allRects.push( ...lastRects ); + } + return { - rects: - getSelectionRects( - blockElement, - start.richTextOffset, - end.richTextOffset, - overlayContext.editorDocument, - overlayContext.overlayRect - ) ?? [], - blockElement, + rects: allRects, + startElement, + endElement, }; } +function getRichTextElementsBetween( + firstElement: HTMLElement, + lastElement: HTMLElement +): HTMLElement[] { + const blockElement = firstElement.closest( '[data-block]' ); + if ( + ! blockElement || + blockElement !== lastElement.closest( '[data-block]' ) + ) { + return []; + } + + return Array.from( + blockElement.querySelectorAll< HTMLElement >( + '[data-wp-block-attribute-key]' + ) + ).filter( + ( element ) => + element !== firstElement && + element !== lastElement && + element.closest( '[data-block]' ) === blockElement && + isNodeBefore( firstElement, element ) && + isNodeBefore( element, lastElement ) + ); +} + /** * Compute selection rects for a selection spanning multiple blocks. * diff --git a/packages/editor/src/components/collaborators-overlay/test/compute-selection.ts b/packages/editor/src/components/collaborators-overlay/test/compute-selection.ts new file mode 100644 index 00000000000000..799bde2af1d37c --- /dev/null +++ b/packages/editor/src/components/collaborators-overlay/test/compute-selection.ts @@ -0,0 +1,239 @@ +/** + * Internal dependencies + */ +import { computeSelectionVisual } from '../compute-selection'; +import { getCursorPosition, getSelectionRects } from '../cursor-dom-utils'; + +jest.mock( '@wordpress/core-data', () => { + const { __dangerousOptInToUnstableAPIsOnlyForCoreModules } = + jest.requireActual( '@wordpress/private-apis' ); + const { lock } = __dangerousOptInToUnstableAPIsOnlyForCoreModules( + 'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.', + '@wordpress/core-data' + ); + const privateApis = {}; + lock( privateApis, { + SelectionDirection: { + Backward: 'backward', + Forward: 'forward', + }, + SelectionType: { + None: 'none', + Cursor: 'cursor', + SelectionInOneBlock: 'selection-in-one-block', + SelectionInMultipleBlocks: 'selection-in-multiple-blocks', + WholeBlock: 'whole-block', + }, + } ); + + return { privateApis }; +} ); + +jest.mock( '../cursor-dom-utils', () => ( { + getCursorPosition: jest.fn( () => ( { + x: 10, + y: 20, + height: 30, + } ) ), + getSelectionRects: jest.fn( () => [] ), + getFullBlockSelectionRects: jest.fn( () => [] ), + getBlocksBetween: jest.fn( () => [] ), + isNodeBefore: jest.fn( () => false ), +} ) ); + +const mockGetCursorPosition = getCursorPosition as jest.Mock; +const mockGetSelectionRects = getSelectionRects as jest.Mock; +const SelectionType = { + Cursor: 'cursor', + SelectionInOneBlock: 'selection-in-one-block', +} as const; + +type ResolvedSelection = { + richTextOffset: number | null; + localClientId: string | null; + attributeKey: string | null; +}; + +function createOverlayContext( bodyHtml: string ) { + document.body.innerHTML = bodyHtml; + + return { + editorDocument: document, + overlayRect: { + left: 0, + top: 0, + right: 100, + bottom: 100, + width: 100, + height: 100, + x: 0, + y: 0, + toJSON: () => ( {} ), + } as DOMRect, + }; +} + +describe( 'computeSelectionVisual', () => { + beforeAll( () => { + Object.defineProperty( globalThis, 'CSS', { + configurable: true, + value: { + escape: ( value: string ) => value, + }, + } ); + } ); + + beforeEach( () => { + mockGetCursorPosition.mockClear(); + mockGetSelectionRects.mockClear(); + } ); + + it( 'anchors cursor selections to the matching nested RichText element', () => { + const overlayContext = createOverlayContext( + '
' + + '
Alpha
' + + '
Beta
' + + '
' + ); + + const start: ResolvedSelection = { + richTextOffset: 2, + localClientId: 'block-1', + attributeKey: 'body.0.cells.1.content', + }; + + computeSelectionVisual( + { type: SelectionType.Cursor }, + start, + undefined, + overlayContext + ); + + const targetElement = document.querySelector( + '[data-wp-block-attribute-key="body.0.cells.1.content"]' + ); + + expect( mockGetCursorPosition ).toHaveBeenCalledWith( + 2, + targetElement, + document, + overlayContext.overlayRect + ); + } ); + + it( 'anchors cursor selections to a keyed RichText block root', () => { + const overlayContext = createOverlayContext( + '

Alpha

' + ); + + const start: ResolvedSelection = { + richTextOffset: 2, + localClientId: 'block-1', + attributeKey: 'content', + }; + + computeSelectionVisual( + { type: SelectionType.Cursor }, + start, + undefined, + overlayContext + ); + + const targetElement = document.querySelector( + '[data-block="block-1"]' + ); + + expect( mockGetCursorPosition ).toHaveBeenCalledWith( + 2, + targetElement, + document, + overlayContext.overlayRect + ); + } ); + + it( 'does not fall back to the whole block for a missing keyed RichText target', () => { + const overlayContext = createOverlayContext( + '
' + + '
Alpha
' + + '
' + ); + + const start: ResolvedSelection = { + richTextOffset: 2, + localClientId: 'block-1', + attributeKey: 'body.1.cells.0.content', + }; + + const result = computeSelectionVisual( + { type: SelectionType.Cursor }, + start, + undefined, + overlayContext + ); + + expect( result.coords ).toBeUndefined(); + expect( mockGetCursorPosition ).not.toHaveBeenCalled(); + } ); + + it( 'renders a same-block selection across different nested RichText elements', () => { + const overlayContext = createOverlayContext( + '
' + + '
Beta start text
' + + '
Delta end text
' + + '
' + ); + const startElement = document.querySelector( + '[data-wp-block-attribute-key="body.0.cells.1.content"]' + ); + const endElement = document.querySelector( + '[data-wp-block-attribute-key="body.1.cells.1.content"]' + ); + const startRect = { x: 10, y: 10, width: 30, height: 12 }; + const endRect = { x: 10, y: 40, width: 30, height: 12 }; + mockGetSelectionRects + .mockReturnValueOnce( [ startRect ] ) + .mockReturnValueOnce( [ endRect ] ); + + const start: ResolvedSelection = { + richTextOffset: 4, + localClientId: 'block-1', + attributeKey: 'body.0.cells.1.content', + }; + const end: ResolvedSelection = { + richTextOffset: 8, + localClientId: 'block-1', + attributeKey: 'body.1.cells.1.content', + }; + + const result = computeSelectionVisual( + { type: SelectionType.SelectionInOneBlock }, + start, + end, + overlayContext + ); + + expect( mockGetSelectionRects ).toHaveBeenNthCalledWith( + 1, + startElement, + 4, + Number.MAX_SAFE_INTEGER, + document, + overlayContext.overlayRect + ); + expect( mockGetSelectionRects ).toHaveBeenNthCalledWith( + 2, + endElement, + 0, + 8, + document, + overlayContext.overlayRect + ); + expect( mockGetCursorPosition ).toHaveBeenCalledWith( + 8, + endElement, + document, + overlayContext.overlayRect + ); + expect( result.selectionRects ).toEqual( [ startRect, endRect ] ); + } ); +} ); diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 52df70d9fe0607..37d6b399413d89 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -203,6 +203,23 @@ export const savePost = ), content, }; + const revisionRestoreEdits = options.__unstableRevisionRestoreEdits; + if ( + options.__unstableIsRevisionRestore && + revisionRestoreEdits && + typeof revisionRestoreEdits === 'object' + ) { + for ( const key of [ 'content', 'title', 'excerpt', 'meta' ] ) { + if ( + Object.prototype.hasOwnProperty.call( + revisionRestoreEdits, + key + ) + ) { + edits[ key ] = revisionRestoreEdits[ key ]; + } + } + } dispatch( { type: 'REQUEST_POST_UPDATE_START', options } ); let error = false; diff --git a/packages/editor/src/store/private-actions.js b/packages/editor/src/store/private-actions.js index ebcd936faa41de..6016428ca3d7fb 100644 --- a/packages/editor/src/store/private-actions.js +++ b/packages/editor/src/store/private-actions.js @@ -709,7 +709,7 @@ export const restoreRevision = // Build the edits object with all restorable fields from the revision. const edits = { - blocks: undefined, + blocks: parse( revision.content.raw ), content: revision.content.raw, }; if ( revision.title?.raw !== undefined ) { @@ -729,7 +729,10 @@ export const restoreRevision = dispatch.setCurrentRevisionId( null ); // Save the post to persist the restored revision. - await dispatch.savePost(); + await dispatch.savePost( { + __unstableIsRevisionRestore: true, + __unstableRevisionRestoreEdits: edits, + } ); // Show success notice. registry.dispatch( noticesStore ).createSuccessNotice( diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index 4dfd0ead3036ef..5a3667a76e8ab5 100644 --- a/packages/editor/src/store/test/actions.js +++ b/packages/editor/src/store/test/actions.js @@ -3,6 +3,7 @@ */ import apiFetch from '@wordpress/api-fetch'; import { store as blockEditorStore } from '@wordpress/block-editor'; +import { parse } from '@wordpress/blocks'; import { store as coreStore } from '@wordpress/core-data'; import { createRegistry } from '@wordpress/data'; import { store as noticesStore } from '@wordpress/notices'; @@ -13,8 +14,11 @@ import { store as preferencesStore } from '@wordpress/preferences'; */ import * as actions from '../actions'; +import { + restoreRevision, + updateDeviceTypeForViewportState, +} from '../private-actions'; import { store as editorStore } from '..'; -import { unlock } from '../../lock-unlock'; const postId = 44; @@ -68,11 +72,12 @@ describe( 'Post actions', () => { it( 'updates the editor device type for a viewport state', () => { const registry = createRegistryWithStores(); - unlock( - registry.dispatch( editorStore ) - ).updateDeviceTypeForViewportState( { + updateDeviceTypeForViewportState( { viewport: '@mobile', showStateOnCanvas: true, + } )( { + dispatch: registry.dispatch( editorStore ), + registry, } ); expect( registry.select( editorStore ).getDeviceType() ).toBe( @@ -84,11 +89,12 @@ describe( 'Post actions', () => { const registry = createRegistryWithStores(); registry.dispatch( editorStore ).setDeviceType( 'Tablet' ); - unlock( - registry.dispatch( editorStore ) - ).updateDeviceTypeForViewportState( { + updateDeviceTypeForViewportState( { viewport: '@mobile', showStateOnCanvas: false, + } )( { + dispatch: registry.dispatch( editorStore ), + registry, } ); expect( registry.select( editorStore ).getDeviceType() ).toBe( @@ -97,6 +103,74 @@ describe( 'Post actions', () => { } ); } ); + describe( 'restoreRevision()', () => { + it( 'restores parsed blocks with the revision content before saving', async () => { + const revisionContent = [ + '', + '

Restored old revision.

', + '', + ].join( '\n' ); + const revision = { + id: 77, + date: '2026-05-22T20:15:00', + title: { raw: 'Restored title' }, + excerpt: { raw: 'Restored excerpt' }, + content: { raw: revisionContent }, + meta: { key: 'value' }, + }; + const getRevision = jest.fn().mockResolvedValue( revision ); + const createSuccessNotice = jest.fn(); + const dispatch = { + editPost: jest.fn(), + savePost: jest.fn(), + setCurrentRevisionId: jest.fn(), + }; + const registry = { + select: ( store ) => { + if ( store === coreStore ) { + return { + getEntityConfig: () => ( { revisionKey: 'id' } ), + }; + } + }, + resolveSelect: ( store ) => { + if ( store === coreStore ) { + return { getRevision }; + } + }, + dispatch: ( store ) => { + if ( store === noticesStore ) { + return { createSuccessNotice }; + } + }, + }; + const select = { + getCurrentPostId: () => postId, + getCurrentPostType: () => 'post', + }; + + await restoreRevision( revision.id )( { + select, + dispatch, + registry, + } ); + + const edits = dispatch.editPost.mock.calls[ 0 ][ 0 ]; + expect( edits ).toMatchObject( { + content: revisionContent, + excerpt: 'Restored excerpt', + meta: revision.meta, + title: 'Restored title', + } ); + expect( Array.isArray( edits.blocks ) ).toBe( true ); + expect( edits.blocks ).toEqual( parse( revisionContent ) ); + expect( dispatch.savePost ).toHaveBeenCalledWith( { + __unstableIsRevisionRestore: true, + __unstableRevisionRestoreEdits: edits, + } ); + } ); + } ); + describe( 'savePost()', () => { it( 'saves a modified post', async () => { const post = { @@ -176,6 +250,64 @@ describe( 'Post actions', () => { }, ] ); } ); + + it( 'saves explicit revision restore fields', async () => { + const post = { + id: postId, + type: 'post', + title: 'newer title', + content: 'newer content', + excerpt: 'newer excerpt', + status: 'draft', + }; + const revisionRestoreEdits = { + content: 'older content', + excerpt: 'older excerpt', + title: 'older title', + }; + let savedData; + + apiFetch.setFetchHandler( async ( options ) => { + const method = getMethod( options ); + const { path, data } = options; + + if ( + method === 'PUT' && + path.startsWith( `/wp/v2/posts/${ postId }` ) + ) { + savedData = data; + return { ...post, ...data }; + } else if ( + method === 'GET' && + path.startsWith( '/wp/v2/types/post' ) + ) { + return { + json: () => Promise.resolve( {} ), + }; + } + + throw { + code: 'unknown_path', + message: `Unknown path: ${ method } ${ path }`, + }; + } ); + + const registry = createRegistryWithStores(); + + registry + .dispatch( coreStore ) + .receiveEntityRecords( 'postType', 'post', post ); + registry.dispatch( editorStore ).setupEditor( post, { + content: 'local current content', + } ); + + await registry.dispatch( editorStore ).savePost( { + __unstableIsRevisionRestore: true, + __unstableRevisionRestoreEdits: revisionRestoreEdits, + } ); + + expect( savedData ).toMatchObject( revisionRestoreEdits ); + } ); } ); describe( 'autosave()', () => { diff --git a/packages/sync/CODE.md b/packages/sync/CODE.md index a825957f7b35f6..3635ae2e4ddc5b 100644 --- a/packages/sync/CODE.md +++ b/packages/sync/CODE.md @@ -37,7 +37,8 @@ The sync manager (`src/manager.ts`) orchestrates the lifecycle of synced entitie - **`update(objectType, objectId, changes, origin, options)`**: Apply local changes to the entity's CRDT document. The sync config's `applyChangesToCRDTDoc` is called inside a Yjs transaction. - **`unload(objectType, objectId)`**: Disconnect providers, remove observers, and destroy the `Y.Doc`. - **`getAwareness(objectType, objectId)`**: Return the awareness instance for the entity, if one exists. -- **`createPersistedCRDTDoc(objectType, objectId)`**: Serialize the entity's CRDT document for persistence (see "Persistence" below). +- **`applyPersistedCRDTDoc(objectType, objectId, record)`**: Merge a freshly fetched persisted CRDT document into the local `Y.Doc`. +- **`createPersistedCRDTDoc(objectType, objectId, options)`**: Serialize the entity's CRDT document for persistence (see "Persistence" below). - **`undoManager`**: The sync-aware undo manager, lazily created when the first entity is loaded (see "Undo / redo" below). ### Data flow @@ -103,7 +104,7 @@ addFilter( 'sync.providers', 'my-plugin/websocket-provider', ( providers ) => { CRDT documents can be persisted so that a user returning to an entity can restore its CRDT state (including the full edit history needed for proper merging). See `src/utils.ts` for serialization helpers. - **Initialization problem**: Persisting CRDT documents establishes a shared starting point for all peers. This is critical to prevent data loss and ensure proper merging of concurrent edits. -- **Serialization**: The sync manager's `createPersistedCRDTDoc` method returns a serialized `Y.Doc`. The consumer is responsible for storing this string. +- **Serialization**: The sync manager's `createPersistedCRDTDoc` method returns a serialized `Y.Doc`. When the consumer passes the last known persisted document in `options.basePersistedCRDTDoc`, the serialized value includes a base version so the server can reject stale persistence writes. - **Restoration**: On `load`, if the entity's sync config provides `getPersistedCRDTDoc`, the sync manager calls it to retrieve the serialized CRDT document. - **Invalidation**: After restoring, the sync manager compares the CRDT document against the current entity record (via `getChangesFromCRDTDoc`). If they differ (e.g., the server mutated the entity on save, or an out-of-band update occurred), the differences are applied to the CRDT document and a save is triggered to re-persist it. diff --git a/packages/sync/src/manager.ts b/packages/sync/src/manager.ts index 9d72f43dee83a3..fcb0b162626ac1 100644 --- a/packages/sync/src/manager.ts +++ b/packages/sync/src/manager.ts @@ -3,6 +3,7 @@ */ import * as Y from 'yjs'; import type { Awareness } from 'y-protocols/awareness'; +import fastDeepEqual from 'fast-deep-equal/es6/index.js'; /** * Internal dependencies @@ -11,12 +12,14 @@ import { CRDT_RECORD_MAP_KEY, CRDT_STATE_MAP_KEY, CRDT_STATE_MAP_SAVED_AT_KEY as SAVED_AT_KEY, + CRDT_STATE_MAP_SAVED_BY_KEY as SAVED_BY_KEY, LOCAL_SYNC_MANAGER_ORIGIN, } from './config'; import { logPerformanceTiming, passThru } from './performance'; import { getProviderCreators } from './providers'; import type { CollectionHandlers, + CreatePersistedCRDTDocOptions, CRDTDoc, EntityID, ObjectID, @@ -34,6 +37,9 @@ import { createUndoManager } from './undo-manager'; import { createYjsDoc, deserializeCrdtDoc, + getPersistedCrdtDocBaseRecordSnapshot, + getPersistedCrdtDocRecordSnapshot, + getPersistedCrdtDocVersion, initializeYjsDoc, markEntityAsSaved, serializeCrdtDoc, @@ -52,11 +58,159 @@ interface EntityState { handlers: RecordHandlers; objectId: ObjectID; objectType: ObjectType; + remoteKeyVersions: Map< string, number >; + reconcilingRemoteKeys: Set< string >; syncConfig: SyncConfig; unload: () => void; ydoc: CRDTDoc; } +const CRDT_DOC_META_HAS_PROVIDER_SYNCED_REMOTE_STATE = + 'hasProviderSyncedRemoteState'; + +function areUint8ArraysEqual( a: Uint8Array, b: Uint8Array ): boolean { + if ( a.length !== b.length ) { + return false; + } + + return a.every( ( value, index ) => value === b[ index ] ); +} + +function getPersistableCrdtDocState( ydoc: CRDTDoc ) { + const state = ydoc.getMap( CRDT_STATE_MAP_KEY ).toJSON() as Record< + string, + unknown + >; + + delete state[ SAVED_AT_KEY ]; + delete state[ SAVED_BY_KEY ]; + + return { + record: ydoc.getMap( CRDT_RECORD_MAP_KEY ).toJSON(), + state, + }; +} + +function hasPersistableCrdtDocStateChanged( + ydoc: CRDTDoc, + basePersistedCRDTDoc: string | null | undefined +): boolean { + if ( ! basePersistedCRDTDoc ) { + return true; + } + + const baseDoc = deserializeCrdtDoc( basePersistedCRDTDoc ); + if ( ! baseDoc ) { + return true; + } + + try { + return ! fastDeepEqual( + getPersistableCrdtDocState( ydoc ), + getPersistableCrdtDocState( baseDoc ) + ); + } finally { + baseDoc.destroy(); + } +} + +interface ApplyPersistedCrdtDocOptions { + shouldPersist?: boolean; +} + +function getComparableSnapshotValue( value: unknown ): unknown { + if ( + 'object' === typeof value && + null !== value && + ! Array.isArray( value ) && + 'raw' in value + ) { + return ( value as { raw?: unknown } ).raw; + } + + return value; +} + +function filterStaleRecordSnapshotInvalidations( + invalidations: ObjectData, + record: ObjectData, + recordSnapshot: ObjectData | null, + baseRecordSnapshot: ObjectData | null +): ObjectData { + if ( ! recordSnapshot ) { + return invalidations; + } + + return Object.fromEntries( + Object.entries( invalidations ).filter( ( [ key ] ) => { + if ( + ! Object.prototype.hasOwnProperty.call( recordSnapshot, key ) + ) { + return true; + } + + const recordValue = getComparableSnapshotValue( record[ key ] ); + const snapshotValue = getComparableSnapshotValue( + recordSnapshot[ key ] + ); + + if ( fastDeepEqual( recordValue, snapshotValue ) ) { + return true; + } + + if ( + ! baseRecordSnapshot || + ! Object.prototype.hasOwnProperty.call( + baseRecordSnapshot, + key + ) + ) { + return true; + } + + const baseSnapshotValue = getComparableSnapshotValue( + baseRecordSnapshot[ key ] + ); + + // If the persisted snapshot did not change this field, a divergent + // record value can be a stale entity value from the same save cycle. + // Do not let it overwrite the persisted CRDT document. + if ( fastDeepEqual( snapshotValue, baseSnapshotValue ) ) { + return false; + } + + return ! fastDeepEqual( recordValue, baseSnapshotValue ); + } ) + ); +} + +function hasPersistedRecordSnapshotChanged( + basePersistedCRDTDoc: string | null | undefined, + options: CreatePersistedCRDTDocOptions +): boolean { + if ( ! basePersistedCRDTDoc ) { + return false; + } + + if ( + 'baseRecordSnapshot' in options && + ! fastDeepEqual( + getPersistedCrdtDocBaseRecordSnapshot( basePersistedCRDTDoc ), + options.baseRecordSnapshot ?? null + ) + ) { + return true; + } + + return ( + 'recordSnapshot' in options && + ! fastDeepEqual( + getPersistedCrdtDocRecordSnapshot( basePersistedCRDTDoc ), + options.recordSnapshot ?? null + ) + ); +} + /** * Get the entity ID for the given object type and object ID. * @@ -70,6 +224,85 @@ function getEntityId( return `${ objectType }_${ objectId }`; } +function getTopLevelRecordKeysFromEvents( + events: Y.YEvent< any >[] +): string[] { + const keys = new Set< string >(); + + for ( const event of events ) { + const [ key ] = event.path; + if ( 'string' === typeof key ) { + keys.add( key ); + continue; + } + + if ( event instanceof Y.YMapEvent ) { + event.keysChanged.forEach( ( changedKey ) => + keys.add( changedKey ) + ); + } + } + + return [ ...keys ]; +} + +function getScheduledRemoteKeyVersions( + entityState: EntityState | undefined, + changes: Partial< ObjectData > +): Map< string, number > { + const versions = new Map< string, number >(); + + if ( ! entityState ) { + return versions; + } + + Object.keys( changes ).forEach( ( key ) => { + versions.set( key, entityState.remoteKeyVersions.get( key ) ?? 0 ); + } ); + + return versions; +} + +function isUnchangedBaseRecordValue( + key: string, + value: unknown, + options: SyncManagerUpdateOptions +): boolean { + if ( ! options.baseRecord || key === 'blocks' ) { + return false; + } + + if ( ! Object.prototype.hasOwnProperty.call( options.baseRecord, key ) ) { + return false; + } + + return fastDeepEqual( + getComparableSnapshotValue( options.baseRecord[ key ] ), + getComparableSnapshotValue( value ) + ); +} + +function isStaleSaveReconciliationValue( + key: string, + value: unknown, + ydoc: CRDTDoc, + options: SyncManagerUpdateOptions +): boolean { + if ( ! options.isSave || key === 'blocks' ) { + return false; + } + + const recordMap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); + if ( ! recordMap.has( key ) ) { + return false; + } + + return ! fastDeepEqual( + getComparableSnapshotValue( recordMap.get( key ) ), + getComparableSnapshotValue( value ) + ); +} + /** * The sync manager orchestrates the lifecycle of syncing entity records. It * creates Yjs documents, connects to providers, creates awareness instances, @@ -178,33 +411,50 @@ export function createSyncManager( debug = false ): SyncManager { addUndoMeta: debugWrap( handlers.addUndoMeta ), editRecord: debugWrap( handlers.editRecord ), getEditedRecord: debugWrap( handlers.getEditedRecord ), + onUndoStackChange: debugWrap( handlers.onUndoStackChange ), onStatusChange: debugWrap( handlers.onStatusChange ), persistCRDTDoc: debugWrap( handlers.persistCRDTDoc ), refetchRecord: debugWrap( handlers.refetchRecord ), restoreUndoMeta: debugWrap( handlers.restoreUndoMeta ), - - onUndoStackChange: handlers.onUndoStackChange - ? debugWrap( handlers.onUndoStackChange ) - : undefined, }; const ydoc = createYjsDoc( { objectType } ); const recordMap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); const stateMap = ydoc.getMap( CRDT_STATE_MAP_KEY ); const now = Date.now(); - - // Track whether observers have been attached to the maps. + let providerResults: ProviderCreatorResult[] = []; let hasObserversAttached = false; - // Track whether unload ran (possibly while we were awaiting provider - // creation), so the post-await code can destroy any providers that - // were created after unload and bail out. let isEntityUnloaded = false; + let isObservingProviderBootstrapRemoteState = true; + const markProviderSyncedRemoteState = ( + transaction: Y.Transaction + ): void => { + if ( transaction.local ) { + return; + } + + ydoc.meta?.set( + CRDT_DOC_META_HAS_PROVIDER_SYNCED_REMOTE_STATE, + true + ); + }; + const stopObservingProviderBootstrapRemoteState = (): void => { + if ( ! isObservingProviderBootstrapRemoteState ) { + return; + } + + ydoc.off( 'afterTransaction', markProviderSyncedRemoteState ); + isObservingProviderBootstrapRemoteState = false; + }; + + ydoc.on( 'afterTransaction', markProviderSyncedRemoteState ); // Clean up providers and in-memory state when the entity is unloaded. const unload = (): void => { log( 'loadEntity', 'unloading', entityId ); isEntityUnloaded = true; - providerResults?.forEach( ( result ) => result.destroy() ); + stopObservingProviderBootstrapRemoteState(); + providerResults.forEach( ( result ) => result.destroy() ); handlers.onStatusChange( null ); if ( hasObserversAttached ) { recordMap.unobserveDeep( onRecordUpdate ); @@ -220,7 +470,7 @@ export function createSyncManager( debug = false ): SyncManager { // When the CRDT document is updated by an UndoManager or a connection (not // a local origin), update the local store. const onRecordUpdate = ( - _events: Y.YEvent< any >[], + events: Y.YEvent< any >[], transaction: Y.Transaction ): void => { if ( @@ -230,7 +480,27 @@ export function createSyncManager( debug = false ): SyncManager { return; } - void internal.updateEntityRecord( objectType, objectId ); + const remoteChangedKeys = transaction.local + ? [] + : getTopLevelRecordKeysFromEvents( events ); + + const currentEntityState = entityStates.get( entityId ); + if ( currentEntityState ) { + remoteChangedKeys.forEach( ( key ) => { + currentEntityState.remoteKeyVersions.set( + key, + ( currentEntityState.remoteKeyVersions.get( key ) ?? + 0 ) + 1 + ); + currentEntityState.reconcilingRemoteKeys.add( key ); + } ); + } + + void internal.updateEntityRecord( + objectType, + objectId, + remoteChangedKeys + ); }; const onStateMapUpdate = ( @@ -244,11 +514,10 @@ export function createSyncManager( debug = false ): SyncManager { event.keysChanged.forEach( ( key ) => { switch ( key ) { case SAVED_AT_KEY: - const savedAt = stateMap.get( SAVED_AT_KEY ); - if ( 'number' === typeof savedAt && savedAt > now ) { - // Another peer saved the entity. Refetch the - // record so this cache sees server-side save - // mutations. + const newValue = stateMap.get( SAVED_AT_KEY ); + if ( 'number' === typeof newValue && newValue > now ) { + // Another peer has saved the record. Refetch it so that we have + // a correct understanding of our own unsaved edits. log( 'loadEntity', 'refetching record', entityId ); void handlers.refetchRecord().catch( () => {} ); } @@ -265,19 +534,17 @@ export function createSyncManager( debug = false ): SyncManager { const { addUndoMeta, onUndoStackChange, restoreUndoMeta } = handlers; undoManager.addToScope( recordMap, { addUndoMeta, - restoreUndoMeta, onUndoStackChange, + restoreUndoMeta, } ); - // Declare with let before using it in unload closure. - // eslint-disable-next-line prefer-const - let providerResults: ProviderCreatorResult[]; - const entityState: EntityState = { awareness, handlers, objectId, objectType, + remoteKeyVersions: new Map(), + reconcilingRemoteKeys: new Set(), syncConfig, unload, ydoc, @@ -303,29 +570,47 @@ export function createSyncManager( debug = false ): SyncManager { } ) ); - // If unload() or unloadAll() ran while we were awaiting provider - // creation, destroy the just-created providers and bail out before - // attempting to use the connection if ( isEntityUnloaded ) { log( 'loadEntity', 'unloaded during connect, aborting', entityId ); providerResults.forEach( ( result ) => result.destroy() ); return; } - // Initialize the Yjs document with the necessary CRDT state. - initializeYjsDoc( ydoc ); + // Give providers one event loop turn to flush bootstrap updates that can + // be queued immediately after their initial sync signal. Otherwise, stale + // persisted state may be applied before remote peer state is observed. + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); - // Get and apply the persisted CRDT document, if it exists. - // Observers are attached after hydration so the applyUpdateV2 inside - // _applyPersistedCrdtDoc does not trigger _updateEntityRecord with the - // just-loaded state, which would dispatch a redundant editRecord whose - // blocks already match the editor's parsed content. - internal.applyPersistedCrdtDoc( objectType, objectId, record ); + if ( isEntityUnloaded ) { + log( + 'loadEntity', + 'unloaded after bootstrap wait, aborting', + entityId + ); + return; + } - // Attach observers. - recordMap.observeDeep( onRecordUpdate ); - stateMap.observe( onStateMapUpdate ); - hasObserversAttached = true; + try { + // Initialize the Yjs document with the necessary CRDT state. + initializeYjsDoc( ydoc ); + + // Get and apply the persisted CRDT document, if it exists. Observers are + // attached after this load-time CRDT initialization so local hydration + // does not trigger a redundant CRDT-to-store update. + internal.applyPersistedCrdtDoc( objectType, objectId, record ); + + // Attach observers. + recordMap.observeDeep( onRecordUpdate ); + stateMap.observe( onStateMapUpdate ); + hasObserversAttached = true; + + // Reflect CRDT-normalized runtime values, such as hidden table row + // identities, back into the local edited record after it exists in the + // store. + await internal.hydrateRecordFromCrdtDoc( objectType, objectId ); + } finally { + stopObservingProviderBootstrapRemoteState(); + } } /** @@ -363,19 +648,15 @@ export function createSyncManager( debug = false ): SyncManager { const ydoc = createYjsDoc( { collection: true, objectType } ); const stateMap = ydoc.getMap( CRDT_STATE_MAP_KEY ); const now = Date.now(); - - // Track whether observers have been attached to the maps. + let providerResults: ProviderCreatorResult[] = []; let hasObserversAttached = false; - // Track whether unload ran (possibly while we were awaiting provider - // creation), so the post-await code can destroy any providers that - // were created after unload and bail out. let isCollectionUnloaded = false; // Clean up providers and in-memory state when the entity is unloaded. const unload = (): void => { log( 'loadCollection', 'unloading', entityId ); isCollectionUnloaded = true; - providerResults?.forEach( ( result ) => result.destroy() ); + providerResults.forEach( ( result ) => result.destroy() ); handlers.onStatusChange( null ); if ( hasObserversAttached ) { stateMap.unobserve( onStateMapUpdate ); @@ -397,8 +678,7 @@ export function createSyncManager( debug = false ): SyncManager { case SAVED_AT_KEY: const newValue = stateMap.get( SAVED_AT_KEY ); if ( 'number' === typeof newValue && newValue > now ) { - // Another peer has performed a user-facing save that - // may affect the collection. Refetch it so that we + // Another peer has mutated the collection. Refetch it so that we // obtain the updated records. void handlers.refetchRecords().catch( () => {} ); } @@ -410,10 +690,6 @@ export function createSyncManager( debug = false ): SyncManager { // If the sync config supports awareness, create it. const awareness = syncConfig.createAwareness?.( ydoc ); - // Declare with let before using it in unload closure. - // eslint-disable-next-line prefer-const - let providerResults: ProviderCreatorResult[]; - const collectionState: CollectionState = { awareness, handlers, @@ -442,9 +718,6 @@ export function createSyncManager( debug = false ): SyncManager { } ) ); - // If unload() or unloadAll() ran while we were awaiting provider - // creation, destroy the just-created providers and bail out before - // attempting to use the connection if ( isCollectionUnloaded ) { log( 'loadCollection', @@ -474,9 +747,7 @@ export function createSyncManager( debug = false ): SyncManager { const entityId = getEntityId( objectType, objectId ); log( 'unloadEntity', 'unloading', entityId ); entityStates.get( entityId )?.unload(); - updateCRDTDoc( objectType, null, {}, origin, { - isSave: true, - } ); + updateCRDTDoc( objectType, null, {}, origin, { isSave: true } ); } /** @@ -527,12 +798,15 @@ export function createSyncManager( debug = false ): SyncManager { * @param {ObjectType} objectType Object type. * @param {ObjectID} objectId Object ID. * @param {ObjectData} record Entity record representing this object type. + * @param {Object} options Options for applying the persisted CRDT document. */ function _applyPersistedCrdtDoc( objectType: ObjectType, objectId: ObjectID, - record: ObjectData + record: ObjectData, + options: ApplyPersistedCrdtDocOptions = {} ): void { + const { shouldPersist = true } = options; const entityId = getEntityId( objectType, objectId ); const entityState = entityStates.get( entityId ); @@ -551,6 +825,20 @@ export function createSyncManager( debug = false ): SyncManager { ydoc: targetDoc, } = entityState; + if ( + targetDoc.meta?.get( + CRDT_DOC_META_HAS_PROVIDER_SYNCED_REMOTE_STATE + ) + ) { + log( + 'applyPersistedCrdtDoc', + 'provider already applied remote state', + entityId + ); + void internal.updateEntityRecord( objectType, objectId ); + return; + } + // Get the persisted CRDT document, if it exists. const serialized = getPersistedCRDTDoc?.( record ); const tempDoc = serialized ? deserializeCrdtDoc( serialized ) : null; @@ -562,7 +850,9 @@ export function createSyncManager( debug = false ): SyncManager { // calling `syncManager.createPersistedCRDTDoc`. targetDoc.transact( () => { applyChangesToCRDTDoc( targetDoc, record ); - handlers.persistCRDTDoc(); + if ( shouldPersist ) { + handlers.persistCRDTDoc(); + } }, LOCAL_SYNC_MANAGER_ORIGIN ); return; } @@ -589,7 +879,15 @@ export function createSyncManager( debug = false ): SyncManager { // 3. Unsaved changes are synced from a peer _before_ this code runs. We // can't control when (or if) remote changes are synced, so this is a // race condition. - const invalidations = getChangesFromCRDTDoc( tempDoc, record ); + const recordSnapshot = getPersistedCrdtDocRecordSnapshot( serialized ); + const baseRecordSnapshot = + getPersistedCrdtDocBaseRecordSnapshot( serialized ); + const invalidations = filterStaleRecordSnapshotInvalidations( + getChangesFromCRDTDoc( tempDoc, record ), + record, + recordSnapshot, + baseRecordSnapshot + ); const invalidatedKeys = Object.keys( invalidations ); // Destroy the temporary document to prevent leaks. @@ -619,35 +917,124 @@ export function createSyncManager( debug = false ): SyncManager { // `syncManager.createPersistedCRDTDoc`. targetDoc.transact( () => { applyChangesToCRDTDoc( targetDoc, changes ); - handlers.persistCRDTDoc(); + if ( shouldPersist ) { + handlers.persistCRDTDoc(); + } }, LOCAL_SYNC_MANAGER_ORIGIN ); } + /** + * Hydrate the local edited record from the live CRDT document after load-time + * initialization. Some synced fields normalize runtime-only data into the CRDT + * document, such as hidden table row identity symbols, without changing the + * serialized entity content. + * + * @param {ObjectType} objectType Object type. + * @param {ObjectID} objectId Object ID. + */ + async function hydrateRecordFromCrdtDoc( + objectType: ObjectType, + objectId: ObjectID + ): Promise< void > { + const entityId = getEntityId( objectType, objectId ); + const entityState = entityStates.get( entityId ); + + if ( ! entityState ) { + log( 'hydrateRecordFromCrdtDoc', 'no entity state', entityId ); + return; + } + + const { handlers, syncConfig, ydoc } = entityState; + const changes = syncConfig.getChangesFromCRDTDoc( + ydoc, + await handlers.getEditedRecord() + ); + const changedKeys = Object.keys( changes ); + + if ( 0 === changedKeys.length ) { + return; + } + + log( 'hydrateRecordFromCrdtDoc', 'changes', entityId, { + changedKeys, + } ); + handlers.editRecord( changes, { + undoIgnore: true, + __unstableSkipSyncUpdate: true, + } ); + } + /** * Update CRDT document with changes from the local store. * - * @param {ObjectType} objectType Object type. - * @param {ObjectID} objectId Object ID. - * @param {Partial< ObjectData >} changes Updates to make. - * @param {string} origin The source of change. - * @param {SyncManagerUpdateOptions} options Optional flags for the update. - * @param {boolean} options.isSave Whether this update represents a user-facing entity save. - * @param {boolean} options.isNewUndoLevel Whether to create a new undo level for this change. Defaults to false. + * @param {ObjectType} objectType Object type. + * @param {ObjectID} objectId Object ID. + * @param {Partial< ObjectData >} changes Updates to make. + * @param {string} origin The source of change. + * @param {SyncManagerUpdateOptions} options Optional flags for the update. + * @param {boolean} options.isSave Whether this update is part of a save operation. Defaults to false. + * @param {boolean} options.isNewUndoLevel Whether to create a new undo level for this change. Defaults to false. + * @param {Map< string, number >} scheduledRemoteKeyVersions Remote key versions captured when the local update was scheduled. */ function updateCRDTDoc( objectType: ObjectType, objectId: ObjectID | null, changes: Partial< ObjectData >, origin: string, - options: SyncManagerUpdateOptions = {} + options: SyncManagerUpdateOptions = {}, + scheduledRemoteKeyVersions?: Map< string, number > ): void { const { isSave = false, isNewUndoLevel = false } = options; const entityId = getEntityId( objectType, objectId ); const entityState = entityStates.get( entityId ); - const collectionState = collectionStates.get( objectType ); if ( entityState ) { const { syncConfig, ydoc } = entityState; + const remoteKeyVersionsAtUpdate = + scheduledRemoteKeyVersions ?? + getScheduledRemoteKeyVersions( entityState, changes ); + let changesToApply = changes; + + if ( entityState.reconcilingRemoteKeys.size > 0 ) { + changesToApply = Object.fromEntries( + Object.entries( changes ).filter( ( [ key, value ] ) => { + if ( key === 'blocks' ) { + return true; + } + + if ( ! entityState.reconcilingRemoteKeys.has( key ) ) { + return true; + } + + if ( + isStaleSaveReconciliationValue( + key, + value, + ydoc, + options + ) + ) { + return false; + } + + if ( + isUnchangedBaseRecordValue( key, value, options ) + ) { + return false; + } + + return ( + ( entityState.remoteKeyVersions.get( key ) ?? + 0 ) === + ( remoteKeyVersionsAtUpdate.get( key ) ?? 0 ) + ); + } ) + ); + + if ( 0 === Object.keys( changesToApply ).length && ! isSave ) { + return; + } + } // If this is change should create a new undo level, tell the undo // manager to stop capturing and create a new undo group. @@ -660,9 +1047,16 @@ export function createSyncManager( debug = false ): SyncManager { ydoc.transact( () => { log( 'updateCRDTDoc', 'applying changes', entityId, { - changedKeys: Object.keys( changes ), + changedKeys: Object.keys( changesToApply ), } ); - syncConfig.applyChangesToCRDTDoc( ydoc, changes ); + if ( options.baseRecord ) { + syncConfig.applyChangesToCRDTDoc( ydoc, changesToApply, { + baseRecord: options.baseRecord, + ...( isSave ? { isSave } : {} ), + } ); + } else { + syncConfig.applyChangesToCRDTDoc( ydoc, changesToApply ); + } if ( isSave ) { markEntityAsSaved( ydoc ); @@ -670,6 +1064,7 @@ export function createSyncManager( debug = false ): SyncManager { }, origin ); } + const collectionState = collectionStates.get( objectType ); if ( collectionState && isSave ) { collectionState.ydoc.transact( () => { markEntityAsSaved( collectionState.ydoc ); @@ -681,12 +1076,14 @@ export function createSyncManager( debug = false ): SyncManager { * Update the entity record in the local store with changes from the CRDT * document. * - * @param {ObjectType} objectType Object type of record to update. - * @param {ObjectID} objectId Object ID of record to update. + * @param {ObjectType} objectType Object type of record to update. + * @param {ObjectID} objectId Object ID of record to update. + * @param {string[]} preReconciledKeys Keys being reconciled before this update. */ async function _updateEntityRecord( objectType: ObjectType, - objectId: ObjectID + objectId: ObjectID, + preReconciledKeys: string[] = [] ): Promise< void > { const entityId = getEntityId( objectType, objectId ); const entityState = entityStates.get( entityId ); @@ -708,13 +1105,55 @@ export function createSyncManager( debug = false ): SyncManager { const changedKeys = Object.keys( changes ); if ( 0 === changedKeys.length ) { + preReconciledKeys.forEach( ( key ) => + entityState.reconcilingRemoteKeys.delete( key ) + ); return; } log( 'updateEntityRecord', 'changes', entityId, { changedKeys, } ); - handlers.editRecord( changes ); + const keysToReconcile = [ + ...new Set( [ ...preReconciledKeys, ...changedKeys ] ), + ]; + keysToReconcile.forEach( ( key ) => + entityState.reconcilingRemoteKeys.add( key ) + ); + handlers.editRecord( changes, { __unstableSkipSyncUpdate: true } ); + void clearReconciledRemoteKeys( entityState, keysToReconcile ); + } + + async function clearReconciledRemoteKeys( + entityState: EntityState, + keys: string[], + attempt = 0 + ): Promise< void > { + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + const changes = entityState.syncConfig.getChangesFromCRDTDoc( + entityState.ydoc, + await entityState.handlers.getEditedRecord() + ); + + for ( const key of keys ) { + if ( ! Object.prototype.hasOwnProperty.call( changes, key ) ) { + entityState.reconcilingRemoteKeys.delete( key ); + } + } + + if ( + keys.some( ( key ) => + entityState.reconcilingRemoteKeys.has( key ) + ) && + attempt < 5 + ) { + return clearReconciledRemoteKeys( entityState, keys, attempt + 1 ); + } + + keys.forEach( ( key ) => + entityState.reconcilingRemoteKeys.delete( key ) + ); } /** @@ -722,11 +1161,13 @@ export function createSyncManager( debug = false ): SyncManager { * * @param {ObjectType} objectType Object type. * @param {ObjectID} objectId Object ID. + * @param {Object} options Options for creating the persisted document. */ - function createPersistedCRDTDoc( + async function createPersistedCRDTDoc( objectType: ObjectType, - objectId: ObjectID - ): string | null { + objectId: ObjectID, + options: CreatePersistedCRDTDocOptions = {} + ): Promise< string | null > { const entityId = getEntityId( objectType, objectId ); const entityState = entityStates.get( entityId ); @@ -734,18 +1175,181 @@ export function createSyncManager( debug = false ): SyncManager { return null; } - return serializeCrdtDoc( entityState.ydoc ); + // Y.Doc updates are deferred via yieldToEventLoop. Await a promise that + // resolves on the next tick of the event loop so pending updates are flushed + // before we serialize the document. + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + if ( + ! hasPersistableCrdtDocStateChanged( + entityState.ydoc, + options.basePersistedCRDTDoc + ) && + ! hasPersistedRecordSnapshotChanged( + options.basePersistedCRDTDoc, + options + ) + ) { + return options.basePersistedCRDTDoc ?? null; + } + + return serializeCrdtDoc( entityState.ydoc, { + baseVersion: getPersistedCrdtDocVersion( + options.basePersistedCRDTDoc + ), + baseRecordSnapshot: options.baseRecordSnapshot, + recordSnapshot: options.recordSnapshot, + } ); + } + + async function applyPersistedCRDTDoc( + objectType: ObjectType, + objectId: ObjectID, + record: ObjectData + ): Promise< boolean > { + const entityId = getEntityId( objectType, objectId ); + const entityState = entityStates.get( entityId ); + const previousStateVector = entityState?.ydoc + ? Y.encodeStateVector( entityState.ydoc ) + : null; + + internal.applyPersistedCrdtDoc( objectType, objectId, record, { + shouldPersist: false, + } ); + await internal.updateEntityRecord( objectType, objectId ); + + // Applying a persisted document can schedule local store updates. Yield so + // callers that immediately inspect the document see the completed merge. + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + const nextStateVector = entityState?.ydoc + ? Y.encodeStateVector( entityState.ydoc ) + : null; + + return !! ( + previousStateVector && + nextStateVector && + ! areUint8ArraysEqual( previousStateVector, nextStateVector ) + ); + } + + async function hydrateRecordFromPersistedCRDTDoc( + objectType: ObjectType, + objectId: ObjectID, + record: ObjectData + ): Promise< boolean > { + const entityId = getEntityId( objectType, objectId ); + const entityState = entityStates.get( entityId ); + const previousStateVector = entityState?.ydoc + ? Y.encodeStateVector( entityState.ydoc ) + : null; + + if ( ! entityState ) { + log( + 'hydrateRecordFromPersistedCRDTDoc', + 'no entity state', + entityId + ); + return false; + } + + const serialized = + entityState.syncConfig.getPersistedCRDTDoc?.( record ); + const tempDoc = serialized ? deserializeCrdtDoc( serialized ) : null; + + if ( tempDoc ) { + const recordSnapshot = + getPersistedCrdtDocRecordSnapshot( serialized ); + const baseRecordSnapshot = + getPersistedCrdtDocBaseRecordSnapshot( serialized ); + const invalidations = recordSnapshot + ? filterStaleRecordSnapshotInvalidations( + entityState.syncConfig.getChangesFromCRDTDoc( + tempDoc, + record + ), + record, + recordSnapshot, + baseRecordSnapshot + ) + : {}; + const invalidatedKeys = Object.keys( invalidations ); + + if ( invalidatedKeys.length ) { + const changes = invalidatedKeys.reduce< ObjectData >( + ( acc, key ) => + Object.assign( acc, { + [ key ]: record[ key ], + } ), + {} + ); + if ( + invalidatedKeys.includes( 'blocks' ) && + ! ( 'content' in changes ) && + Object.prototype.hasOwnProperty.call( record, 'content' ) + ) { + changes.content = record.content; + } + entityState.ydoc.transact( () => { + entityState.syncConfig.applyChangesToCRDTDoc( + entityState.ydoc, + changes + ); + }, LOCAL_SYNC_MANAGER_ORIGIN ); + } else { + const update = Y.encodeStateAsUpdateV2( tempDoc ); + Y.applyUpdateV2( entityState.ydoc, update ); + } + tempDoc.destroy(); + } else { + log( + 'hydrateRecordFromPersistedCRDTDoc', + 'no persisted doc', + entityId + ); + } + + await internal.hydrateRecordFromCrdtDoc( objectType, objectId ); + + // Hydration can schedule local store updates. Yield so callers that + // immediately inspect the record see the completed merge. + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + const nextStateVector = Y.encodeStateVector( entityState.ydoc ); + + return !! ( + previousStateVector && + ! areUint8ArraysEqual( previousStateVector, nextStateVector ) + ); + } + + function getCRDTRecordData( + objectType: ObjectType, + objectId: ObjectID + ): ObjectData | undefined { + const entityId = getEntityId( objectType, objectId ); + const entityState = entityStates.get( entityId ); + + return entityState?.ydoc.getMap( CRDT_RECORD_MAP_KEY ).toJSON() as + | ObjectData + | undefined; } // Collect internal functions so that they can be wrapped before calling. const internal = { applyPersistedCrdtDoc: debugWrap( _applyPersistedCrdtDoc ), + hydrateRecordFromCrdtDoc: debugWrap( hydrateRecordFromCrdtDoc ), updateEntityRecord: debugWrap( _updateEntityRecord ), }; // Wrap and return the public API. return { + applyPersistedCRDTDoc: debugWrap( applyPersistedCRDTDoc ), createPersistedCRDTDoc: debugWrap( createPersistedCRDTDoc ), + hydrateRecordFromPersistedCRDTDoc: debugWrap( + hydrateRecordFromPersistedCRDTDoc + ), + getCRDTRecordData: debugWrap( getCRDTRecordData ), getAwareness, load: debugWrap( loadEntity ), loadCollection: debugWrap( loadCollection ), diff --git a/packages/sync/src/providers/http-polling/polling-manager.ts b/packages/sync/src/providers/http-polling/polling-manager.ts index e0f720de0985ea..d3eea9b390abfe 100644 --- a/packages/sync/src/providers/http-polling/polling-manager.ts +++ b/packages/sync/src/providers/http-polling/polling-manager.ts @@ -30,6 +30,7 @@ import { DISCONNECT_DIALOG_RETRY_MS, MANUAL_RETRY_INTERVAL_MS, } from './config'; +import { LOCAL_SYNC_MANAGER_ORIGIN } from '../../config'; import { ConnectionError, ConnectionErrorCode } from '../../errors'; import type { ConnectionStatus } from '../../types'; import { @@ -212,6 +213,106 @@ function handleForbiddenError( const roomStates: Map< string, RoomState > = new Map(); +function disconnectRoomForDocumentSizeLimit( + state: RoomState, + updateSizeInBytes: number +): void { + state.log( 'Document size limit exceeded', { + maxUpdateSizeInBytes: MAX_UPDATE_SIZE_IN_BYTES, + updateSizeInBytes, + } ); + + state.onStatusChange( { + status: 'disconnected', + error: new ConnectionError( + ConnectionErrorCode.DOCUMENT_SIZE_LIMIT_EXCEEDED, + 'Document size limit exceeded' + ), + } ); + + // This is an unrecoverable error. Unregister the room to prevent syncing. + unregisterRoom( state.room ); +} + +function getSyncUpdateByteLength( update: SyncUpdate ): number { + return base64ToUint8Array( update.data ).byteLength; +} + +function queueUpdateOrDisconnect( + state: RoomState, + update: SyncUpdate +): boolean { + const updateSizeInBytes = getSyncUpdateByteLength( update ); + + if ( updateSizeInBytes > MAX_UPDATE_SIZE_IN_BYTES ) { + disconnectRoomForDocumentSizeLimit( state, updateSizeInBytes ); + return false; + } + + state.updateQueue.add( update ); + return true; +} + +function queueCompactionUpdate( state: RoomState ): boolean { + const compactionUpdate = state.createCompactionUpdate(); + const compactionUpdateSize = getSyncUpdateByteLength( compactionUpdate ); + + if ( compactionUpdateSize > MAX_UPDATE_SIZE_IN_BYTES ) { + state.log( 'Generated compaction update exceeded document size limit', { + compactionUpdateSize, + maxUpdateSizeInBytes: MAX_UPDATE_SIZE_IN_BYTES, + } ); + return false; + } + + state.updateQueue.clear(); + state.updateQueue.add( compactionUpdate ); + return true; +} + +function queueUpdatesOrDisconnect( + state: RoomState, + updates: SyncUpdate[] +): boolean { + const oversizedUpdate = updates.find( + ( update ) => + getSyncUpdateByteLength( update ) > MAX_UPDATE_SIZE_IN_BYTES + ); + + if ( oversizedUpdate ) { + if ( oversizedUpdate.type === SyncUpdateType.SYNC_STEP_2 ) { + if ( queueCompactionUpdate( state ) ) { + state.log( + 'Generated sync step 2 exceeded document size limit, queueing compaction update instead', + { + syncStep2UpdateSize: + getSyncUpdateByteLength( oversizedUpdate ), + } + ); + } else { + state.log( + 'Generated sync step 2 exceeded document size limit, skipping response', + { + syncStep2UpdateSize: + getSyncUpdateByteLength( oversizedUpdate ), + } + ); + } + + return true; + } + + disconnectRoomForDocumentSizeLimit( + state, + getSyncUpdateByteLength( oversizedUpdate ) + ); + return false; + } + + state.updateQueue.addBulk( updates ); + return true; +} + /** * Create a compaction update by merging existing updates. This preserves * the original operation metadata (client IDs, logical clocks) so that @@ -465,10 +566,21 @@ function handleBeforeUnload(): void { } /** - * Send a disconnect signal for all registered rooms when the page is - * being unloaded. Uses `sendBeacon` so the request survives navigation. + * Send a disconnect signal for all registered rooms when the page is being + * unloaded. A persisted pagehide means the page is entering the back/forward + * cache rather than leaving permanently; keep the rooms registered so a + * restored page can continue polling instead of looking like a disconnected + * collaborator that still has an open editor. + * + * Uses a keepalive request so the request survives navigation. + * + * @param event Page transition event. */ -function handlePageHide(): void { +function handlePageHide( event: PageTransitionEvent ): void { + if ( event.persisted ) { + return; + } + const rooms = Array.from( roomStates.entries() ).map( ( [ room, state ] ) => ( { after: 0, @@ -486,6 +598,19 @@ function handlePageHide(): void { } } +/** + * Resume polling immediately when a page is restored from the back/forward + * cache. Timers can be paused while the page is cached, so waiting for the + * previous timeout can leave the document stale after restore. + * + * @param event Page transition event. + */ +function handlePageShow( event: PageTransitionEvent ): void { + if ( event.persisted ) { + retryNow(); + } +} + /** * Hangle change in visibility state of browser tab. * @@ -770,21 +895,23 @@ function poll(): void { } } - roomState.updateQueue.addBulk( responseUpdates ); + if ( + ! queueUpdatesOrDisconnect( roomState, responseUpdates ) + ) { + return; + } // Respond to compaction requests from server. The server asks only one // client at a time to compact (lowest active client ID). We encode our // full document state to replace all prior updates on the server. if ( room.should_compact ) { roomState.log( 'Server requested compaction update' ); - roomState.updateQueue.clear(); - roomState.updateQueue.add( - roomState.createCompactionUpdate() - ); + queueCompactionUpdate( roomState ); } else if ( room.compaction_request ) { // Deprecated roomState.log( 'Server requested (old) compaction update' ); - roomState.updateQueue.add( + queueUpdateOrDisconnect( + roomState, createDeprecatedCompactionUpdate( room.compaction_request ) @@ -902,7 +1029,10 @@ function poll(): void { if ( room.updates.length > 0 && state.endCursor > 0 ) { state.updateQueue.clear(); - state.updateQueue.add( state.createCompactionUpdate() ); + queueUpdateOrDisconnect( + state, + state.createCompactionUpdate() + ); } else if ( room.updates.length > 0 ) { state.updateQueue.restore( room.updates ); } @@ -1001,7 +1131,10 @@ function registerRoom( { } function onDocUpdate( update: Uint8Array, origin: unknown ): void { - if ( POLLING_MANAGER_ORIGIN === origin ) { + if ( + POLLING_MANAGER_ORIGIN === origin || + LOCAL_SYNC_MANAGER_ORIGIN === origin + ) { return; } @@ -1011,21 +1144,7 @@ function registerRoom( { return; } - state.log( 'Document size limit exceeded', { - maxUpdateSizeInBytes: MAX_UPDATE_SIZE_IN_BYTES, - updateSizeInBytes: update.byteLength, - } ); - - state.onStatusChange( { - status: 'disconnected', - error: new ConnectionError( - ConnectionErrorCode.DOCUMENT_SIZE_LIMIT_EXCEEDED, - 'Document size limit exceeded' - ), - } ); - - // This is an unrecoverable error. Unregister the room to prevent syncing. - unregisterRoom( room ); + disconnectRoomForDocumentSizeLimit( state, update.byteLength ); return; } @@ -1067,6 +1186,7 @@ function registerRoom( { if ( ! areListenersRegistered ) { window.addEventListener( 'beforeunload', handleBeforeUnload ); window.addEventListener( 'pagehide', handlePageHide ); + window.addEventListener( 'pageshow', handlePageShow ); document.addEventListener( 'visibilitychange', handleVisibilityChange ); areListenersRegistered = true; } @@ -1105,6 +1225,7 @@ function unregisterRoom( if ( 0 === roomStates.size && areListenersRegistered ) { window.removeEventListener( 'beforeunload', handleBeforeUnload ); window.removeEventListener( 'pagehide', handlePageHide ); + window.removeEventListener( 'pageshow', handlePageShow ); document.removeEventListener( 'visibilitychange', handleVisibilityChange diff --git a/packages/sync/src/providers/http-polling/test/polling-manager.test.ts b/packages/sync/src/providers/http-polling/test/polling-manager.test.ts index 1e20977000c245..a5d12a639bbf42 100644 --- a/packages/sync/src/providers/http-polling/test/polling-manager.test.ts +++ b/packages/sync/src/providers/http-polling/test/polling-manager.test.ts @@ -9,7 +9,7 @@ import { it, jest, } from '@jest/globals'; -import { type SyncPayload, type SyncResponse } from '../types'; +import { SyncUpdateType, type SyncPayload, type SyncResponse } from '../types'; // Mock all external dependencies before imports. jest.mock( 'yjs', () => ( { @@ -157,6 +157,8 @@ describe( 'polling-manager', () => { typeof import('../utils').postSyncUpdateNonBlocking >; let mockApplyFilters: jest.Mock; + let mockEncoding: jest.Mocked< typeof import('lib0/encoding') >; + let mockYjs: jest.Mocked< typeof import('yjs') >; beforeEach( () => { jest.useFakeTimers(); @@ -169,6 +171,8 @@ describe( 'polling-manager', () => { mockPostSyncUpdateNonBlocking = require( '../utils' ).postSyncUpdateNonBlocking; mockApplyFilters = require( '@wordpress/hooks' ).applyFilters; + mockEncoding = require( 'lib0/encoding' ); + mockYjs = require( 'yjs' ); } ); } ); @@ -277,6 +281,202 @@ describe( 'polling-manager', () => { } ) ); } ); + + it( 'falls back to compaction when a generated sync step 2 update is oversized', async () => { + const onStatusChange = jest.fn(); + const doc = createMockDoc( 1 ); + + mockPostSyncUpdate + .mockResolvedValueOnce( { + rooms: [ + { + room: 'test-room', + end_cursor: 1, + awareness: { 1: {}, 2: {} }, + updates: [ + { + type: SyncUpdateType.SYNC_STEP_1, + data: 'AQ==', + }, + ], + }, + ], + } ) + .mockResolvedValue( syncResponse ); + + pollingManager.registerRoom( { + room: 'test-room', + doc, + awareness: createMockAwareness(), + log: jest.fn(), + onStatusChange, + onSync: jest.fn(), + } ); + + mockEncoding.toUint8Array.mockReturnValueOnce( + new Uint8Array( 11 ) + ); + mockYjs.encodeStateAsUpdateV2.mockReturnValueOnce( + new Uint8Array( 10 ) + ); + + await jest.advanceTimersByTimeAsync( 0 ); + await jest.advanceTimersByTimeAsync( 1000 ); + + expect( onStatusChange ).not.toHaveBeenCalledWith( { + status: 'disconnected', + error: expect.objectContaining( { + code: 'document-size-limit-exceeded', + } ), + } ); + expect( mockPostSyncUpdateNonBlocking ).not.toHaveBeenCalledWith( + expect.objectContaining( { + rooms: expect.arrayContaining( [ + expect.objectContaining( { + room: 'test-room', + awareness: null, + } ), + ] ), + } ) + ); + expect( + ( mockPostSyncUpdate.mock.calls[ 1 ][ 0 ] as SyncPayload ) + .rooms[ 0 ].updates + ).toEqual( [ + expect.objectContaining( { + type: SyncUpdateType.COMPACTION, + } ), + ] ); + } ); + + it( 'skips an oversized sync step 2 response when fallback compaction is oversized', async () => { + const onStatusChange = jest.fn(); + const doc = createMockDoc( 1 ); + + mockPostSyncUpdate + .mockResolvedValueOnce( { + rooms: [ + { + room: 'test-room', + end_cursor: 1, + awareness: { 1: {}, 2: {} }, + updates: [ + { + type: SyncUpdateType.SYNC_STEP_1, + data: 'AQ==', + }, + ], + }, + ], + } ) + .mockResolvedValue( syncResponse ); + + pollingManager.registerRoom( { + room: 'test-room', + doc, + awareness: createMockAwareness(), + log: jest.fn(), + onStatusChange, + onSync: jest.fn(), + } ); + + mockEncoding.toUint8Array.mockReturnValueOnce( + new Uint8Array( 11 ) + ); + mockYjs.encodeStateAsUpdateV2.mockReturnValueOnce( + new Uint8Array( 11 ) + ); + + await jest.advanceTimersByTimeAsync( 0 ); + await jest.advanceTimersByTimeAsync( 1000 ); + + expect( onStatusChange ).not.toHaveBeenCalledWith( { + status: 'disconnected', + error: expect.objectContaining( { + code: 'document-size-limit-exceeded', + } ), + } ); + expect( mockPostSyncUpdateNonBlocking ).not.toHaveBeenCalledWith( + expect.objectContaining( { + rooms: expect.arrayContaining( [ + expect.objectContaining( { + room: 'test-room', + awareness: null, + } ), + ] ), + } ) + ); + const retryPayload = mockPostSyncUpdate.mock + .calls[ 1 ][ 0 ] as SyncPayload; + const retryUpdates = retryPayload.rooms[ 0 ].updates; + expect( retryUpdates ).not.toEqual( + expect.arrayContaining( [ + expect.objectContaining( { + type: SyncUpdateType.SYNC_STEP_2, + } ), + expect.objectContaining( { + type: SyncUpdateType.COMPACTION, + } ), + ] ) + ); + } ); + + it( 'skips an oversized generated compaction update without disconnecting', async () => { + const onStatusChange = jest.fn(); + const doc = createMockDoc( 1 ); + + mockPostSyncUpdate + .mockResolvedValueOnce( { + rooms: [ + { + room: 'test-room', + end_cursor: 1, + awareness: {}, + updates: [], + should_compact: true, + }, + ], + } ) + .mockResolvedValue( syncResponse ); + + pollingManager.registerRoom( { + room: 'test-room', + doc, + awareness: createMockAwareness(), + log: jest.fn(), + onStatusChange, + onSync: jest.fn(), + } ); + + mockYjs.encodeStateAsUpdateV2.mockReturnValueOnce( + new Uint8Array( 11 ) + ); + + await jest.advanceTimersByTimeAsync( 0 ); + await jest.advanceTimersByTimeAsync( 4000 ); + + expect( onStatusChange ).not.toHaveBeenCalledWith( { + status: 'disconnected', + error: expect.objectContaining( { + code: 'document-size-limit-exceeded', + } ), + } ); + expect( mockPostSyncUpdateNonBlocking ).not.toHaveBeenCalledWith( + expect.objectContaining( { + rooms: expect.arrayContaining( [ + expect.objectContaining( { + room: 'test-room', + awareness: null, + } ), + ] ), + } ) + ); + expect( mockPostSyncUpdate ).toHaveBeenCalledTimes( 2 ); + expect( + ( mockPostSyncUpdate.mock.calls[ 1 ][ 0 ] as SyncPayload ) + .rooms[ 0 ].updates + ).toEqual( [] ); + } ); } ); describe( 'connection limits', () => { @@ -583,6 +783,65 @@ describe( 'polling-manager', () => { } ); describe( 'collaborator queue resumption', () => { + it( 'does not publish sync-manager bootstrap updates when queues resume', async () => { + mockPostSyncUpdate.mockResolvedValue( { + rooms: [ + { + room: 'primary-room', + end_cursor: 1, + awareness: { + 1: { collaboratorInfo: { id: 100 } }, + 2: { collaboratorInfo: { id: 200 } }, + }, + updates: [], + }, + ], + } ); + + const doc = createMockDoc( 1 ); + + pollingManager.registerRoom( { + room: 'primary-room', + doc, + awareness: createMockAwareness(), + log: jest.fn(), + onStatusChange: jest.fn(), + onSync: jest.fn(), + } ); + + // First poll detects collaborators and resumes the queue for later polls. + await jest.advanceTimersByTimeAsync( 0 ); + + const onDocUpdate = getOnDocUpdate( doc ); + onDocUpdate( new Uint8Array( [ 1, 2, 3 ] ), 'syncManager' ); + onDocUpdate( new Uint8Array( [ 4, 5, 6 ] ), 'gutenberg' ); + + mockPostSyncUpdate.mockResolvedValue( { + rooms: [ + { + room: 'primary-room', + end_cursor: 2, + awareness: { + 1: { collaboratorInfo: { id: 100 } }, + 2: { collaboratorInfo: { id: 200 } }, + }, + updates: [], + }, + ], + } ); + + await jest.advanceTimersByTimeAsync( 1000 ); + + const secondCallPayload = mockPostSyncUpdate.mock.calls[ 1 ][ 0 ]; + const updates = secondCallPayload.rooms[ 0 ].updates; + const updateData = updates.map( + ( update: { data: string } ) => update.data + ); + + expect( updateData ).toContain( 'BAUG' ); + expect( updateData ).not.toContain( 'AQID' ); + } ); + it( 'resumes non-primary room queues when collaborators are detected on primary room', async () => { // First poll: primary room has collaborators, collection room has none. mockPostSyncUpdate.mockResolvedValue( { @@ -1929,5 +2188,20 @@ describe( 'polling-manager', () => { expect( beaconsSent.every( ( n ) => n <= 10 ) ).toBe( true ); expect( beaconsSent.reduce( ( a, b ) => a + b, 0 ) ).toBe( 21 ); } ); + + it( 'does not send disconnect beacons for persisted pagehide events', async () => { + mockPostSyncUpdate.mockResolvedValue( { rooms: [] } ); + + registerPrimaryAndOverflow( pollingManager, 2 ); + + await jest.advanceTimersByTimeAsync( 0 ); + mockPostSyncUpdateNonBlocking.mockClear(); + + const event = new Event( 'pagehide' ); + Object.defineProperty( event, 'persisted', { value: true } ); + window.dispatchEvent( event ); + + expect( mockPostSyncUpdateNonBlocking ).not.toHaveBeenCalled(); + } ); } ); } ); diff --git a/packages/sync/src/test/manager.ts b/packages/sync/src/test/manager.ts index f0d2e69fb0adb2..e2f3e5c10676fa 100644 --- a/packages/sync/src/test/manager.ts +++ b/packages/sync/src/test/manager.ts @@ -26,6 +26,7 @@ import { } from '../config'; import { getProviderCreators } from '../providers'; import type { + CreatePersistedCRDTDocOptions, CRDTDoc, ObjectData, ProviderCreator, @@ -33,7 +34,7 @@ import type { RecordHandlers, SyncConfig, } from '../types'; -import { serializeCrdtDoc } from '../utils'; +import { getPersistedCrdtDocVersion, serializeCrdtDoc } from '../utils'; // Mock dependencies. jest.mock( '../providers', () => ( { @@ -68,7 +69,14 @@ describe( 'SyncManager', () => { mockGetProviderCreators.mockReturnValue( [ mockProviderCreator ] ); mockSyncConfig = { - applyChangesToCRDTDoc: jest.fn(), + applyChangesToCRDTDoc: jest.fn( + ( ydoc: CRDTDoc, changes: Partial< ObjectData > ) => { + const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); + Object.entries( changes ).forEach( ( [ key, value ] ) => { + ymap.set( key, value ); + } ); + } + ), getChangesFromCRDTDoc: jest.fn( ( ydoc: CRDTDoc, editedRecord: ObjectData ) => { const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); @@ -97,6 +105,7 @@ describe( 'SyncManager', () => { getEditedRecord: jest.fn( async () => Promise.resolve( mockRecord ) ), + onUndoStackChange: jest.fn(), onStatusChange: jest.fn(), persistCRDTDoc: jest.fn(), refetchRecord: jest.fn( async () => Promise.resolve() ), @@ -292,7 +301,8 @@ describe( 'SyncManager', () => { describe( 'persisted CRDT doc behavior', () => { function createPersistedCRDTDoc( - persistedRecord: ObjectData + persistedRecord: ObjectData, + options: CreatePersistedCRDTDocOptions = {} ): string { const persistedDoc = new Y.Doc(); const persistedRecordMap = @@ -303,7 +313,7 @@ describe( 'SyncManager', () => { } ); - return serializeCrdtDoc( persistedDoc ); + return serializeCrdtDoc( persistedDoc, options ); } it( 'applies the current record when no persisted CRDT doc exists', async () => { @@ -325,10 +335,16 @@ describe( 'SyncManager', () => { mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith( expect.any( Y.Doc ), mockRecord ); - // getChangesFromCRDTDoc should not be called since there was no persisted doc. + // The live CRDT doc is read back after initialization so normalized + // runtime values can be reflected into the edited record. expect( mockSyncConfig.getChangesFromCRDTDoc - ).not.toHaveBeenCalled(); + ).toHaveBeenCalledTimes( 1 ); + expect( + mockSyncConfig.getChangesFromCRDTDoc + ).toHaveBeenCalledWith( expect.any( Y.Doc ), mockRecord ); + + expect( mockHandlers.editRecord ).not.toHaveBeenCalled(); // Verify that the CRDT doc was persisted. expect( mockHandlers.persistCRDTDoc ).toHaveBeenCalledTimes( @@ -336,6 +352,113 @@ describe( 'SyncManager', () => { ); } ); + it( 'ignores stale record invalidations covered by persisted record snapshots', async () => { + mockRecord = { + ...mockRecord, + title: 'Base title', + }; + mockSyncConfig = { + ...mockSyncConfig, + getPersistedCRDTDoc: jest.fn( () => + createPersistedCRDTDoc( + { + ...mockRecord, + title: 'Snapshot title', + }, + { + baseRecordSnapshot: { title: 'Base title' }, + recordSnapshot: { title: 'Snapshot title' }, + } + ) + ), + }; + + const manager = createSyncManager(); + + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); + + expect( + mockSyncConfig.applyChangesToCRDTDoc + ).not.toHaveBeenCalled(); + expect( mockHandlers.persistCRDTDoc ).not.toHaveBeenCalled(); + expect( mockHandlers.editRecord ).toHaveBeenCalledWith( + { title: 'Snapshot title' }, + { undoIgnore: true, __unstableSkipSyncUpdate: true } + ); + } ); + + it( 'ignores stale record invalidations when persisted snapshots are unchanged', async () => { + mockRecord = { + ...mockRecord, + title: '', + }; + mockSyncConfig = { + ...mockSyncConfig, + getPersistedCRDTDoc: jest.fn( () => + createPersistedCRDTDoc( + { + ...mockRecord, + title: 'Snapshot title', + }, + { + baseRecordSnapshot: { title: 'Snapshot title' }, + recordSnapshot: { title: 'Snapshot title' }, + } + ) + ), + }; + + const manager = createSyncManager(); + + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); + + expect( + mockSyncConfig.applyChangesToCRDTDoc + ).not.toHaveBeenCalled(); + expect( mockHandlers.persistCRDTDoc ).not.toHaveBeenCalled(); + expect( mockHandlers.editRecord ).toHaveBeenCalledWith( + { title: 'Snapshot title' }, + { undoIgnore: true, __unstableSkipSyncUpdate: true } + ); + } ); + + it( 'hydrates normalized CRDT changes when no persisted CRDT doc exists', async () => { + mockSyncConfig.applyChangesToCRDTDoc = jest.fn( + ( ydoc: CRDTDoc ) => { + const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); + ymap.set( 'title', 'Normalized Title' ); + } + ); + + const manager = createSyncManager(); + + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); + + expect( mockHandlers.editRecord ).toHaveBeenCalledTimes( 1 ); + expect( mockHandlers.editRecord ).toHaveBeenCalledWith( + { title: 'Normalized Title' }, + { undoIgnore: true, __unstableSkipSyncUpdate: true } + ); + } ); + it( 'accepts a valid persisted CRDT doc without applying changes', async () => { mockSyncConfig = { ...mockSyncConfig, @@ -359,19 +482,71 @@ describe( 'SyncManager', () => { mockSyncConfig.applyChangesToCRDTDoc ).not.toHaveBeenCalled(); - // getChangesFromCRDTDoc should be called with the persisted doc and record. + // getChangesFromCRDTDoc should be called with the persisted doc + // and then with the live target doc for runtime hydration. expect( mockSyncConfig.getChangesFromCRDTDoc - ).toHaveBeenCalledTimes( 1 ); + ).toHaveBeenCalledTimes( 2 ); expect( mockSyncConfig.getChangesFromCRDTDoc - ).toHaveBeenCalledWith( expect.any( Y.Doc ), mockRecord ); + ).toHaveBeenNthCalledWith( 1, expect.any( Y.Doc ), mockRecord ); + expect( + mockSyncConfig.getChangesFromCRDTDoc + ).toHaveBeenNthCalledWith( 2, expect.any( Y.Doc ), mockRecord ); // Verify that the CRDT doc was persisted. expect( mockHandlers.editRecord ).not.toHaveBeenCalled(); expect( mockHandlers.persistCRDTDoc ).not.toHaveBeenCalled(); } ); + it( 'does not apply persisted CRDT doc over bootstrap remote state', async () => { + mockRecord = { + ...mockRecord, + title: 'Persisted title', + }; + mockSyncConfig = { + ...mockSyncConfig, + getPersistedCRDTDoc: jest.fn( () => + createPersistedCRDTDoc( mockRecord ) + ), + }; + mockProviderCreator.mockImplementation( async ( { ydoc } ) => { + setTimeout( () => { + const remoteDoc = new Y.Doc(); + remoteDoc + .getMap( CRDT_RECORD_MAP_KEY ) + .set( 'title', 'Remote title' ); + Y.applyUpdateV2( + ydoc, + Y.encodeStateAsUpdateV2( remoteDoc ) + ); + remoteDoc.destroy(); + }, 0 ); + return mockProviderResult; + } ); + + const manager = createSyncManager(); + + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); + + expect( + mockSyncConfig.applyChangesToCRDTDoc + ).not.toHaveBeenCalled(); + expect( mockHandlers.persistCRDTDoc ).not.toHaveBeenCalled(); + expect( mockHandlers.editRecord ).toHaveBeenCalledWith( + { title: 'Remote title' }, + expect.objectContaining( { + __unstableSkipSyncUpdate: true, + } ) + ); + } ); + it( 'applies a persisted CRDT doc with invalidated fields, then applies changes', async () => { mockSyncConfig = { ...mockSyncConfig, @@ -405,112 +580,450 @@ describe( 'SyncManager', () => { mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith( expect.any( Y.Doc ), expectedChanges ); - // getChangesFromCRDTDoc should be called with the persisted doc and record. + // getChangesFromCRDTDoc should be called with the persisted doc + // and then with the live target doc for runtime hydration. expect( mockSyncConfig.getChangesFromCRDTDoc - ).toHaveBeenCalledTimes( 1 ); + ).toHaveBeenCalledTimes( 2 ); expect( mockSyncConfig.getChangesFromCRDTDoc - ).toHaveBeenCalledWith( expect.any( Y.Doc ), mockRecord ); + ).toHaveBeenNthCalledWith( 1, expect.any( Y.Doc ), mockRecord ); + expect( + mockSyncConfig.getChangesFromCRDTDoc + ).toHaveBeenNthCalledWith( 2, expect.any( Y.Doc ), mockRecord ); + + expect( mockHandlers.editRecord ).not.toHaveBeenCalled(); // Verify that the CRDT doc was persisted. expect( mockHandlers.persistCRDTDoc ).toHaveBeenCalledTimes( 1 ); } ); - } ); - } ); - describe( 'unload', () => { - it( 'unloads an entity and destroys its state', async () => { - const manager = createSyncManager(); + it( 'hydrates from a persisted CRDT doc without invalidating missing record fields', async () => { + mockRecord = { + ...mockRecord, + content: 'old content', + }; + mockSyncConfig.getPersistedCRDTDoc = jest.fn( + ( record: ObjectData ) => { + const meta = record.meta as + | { _crdt_document?: string | null } + | undefined; - await manager.load( - mockSyncConfig, - 'post', - '123', - mockRecord, - mockHandlers - ); + return meta?._crdt_document ?? null; + } + ); + const manager = createSyncManager(); - manager.unload( 'post', '123' ); + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); + manager.update( + 'post', + '123', + { content: 'accepted content' }, + LOCAL_EDITOR_ORIGIN + ); + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + const savedCRDTDocument = await manager.createPersistedCRDTDoc( + 'post', + '123' + ); - expect( mockProviderResult.destroy ).toHaveBeenCalled(); - } ); + jest.clearAllMocks(); + mockHandlers.getEditedRecord.mockResolvedValue( mockRecord ); - it( 'does not throw when unloading non-existent entity', () => { - const manager = createSyncManager(); + await manager.hydrateRecordFromPersistedCRDTDoc( + 'post', + '123', + { + id: '123', + meta: { + _crdt_document: savedCRDTDocument, + }, + } + ); - expect( () => { - manager.unload( 'post', '999' ); - } ).not.toThrow(); - } ); + expect( + mockSyncConfig.applyChangesToCRDTDoc + ).not.toHaveBeenCalled(); + expect( mockHandlers.persistCRDTDoc ).not.toHaveBeenCalled(); + expect( mockHandlers.editRecord ).toHaveBeenCalledWith( + { content: 'accepted content' }, + { undoIgnore: true, __unstableSkipSyncUpdate: true } + ); + } ); - it( 'allows reloading after unloading', async () => { - const manager = createSyncManager(); + it( 'does not hydrate save responses from CRDT content invalidated by the saved record snapshot', async () => { + mockRecord = { + ...mockRecord, + content: 'saved content', + }; + mockSyncConfig.getPersistedCRDTDoc = jest.fn( + ( record: ObjectData ) => { + const meta = record.meta as + | { _crdt_document?: string | null } + | undefined; - await manager.load( - mockSyncConfig, - 'post', - '123', - mockRecord, - mockHandlers - ); + return meta?._crdt_document ?? null; + } + ); + const manager = createSyncManager(); - manager.unload( 'post', '123' ); + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); - jest.clearAllMocks(); + const staleCRDTDocument = createPersistedCRDTDoc( + { content: 'stale crdt content' }, + { + baseRecordSnapshot: { content: 'base content' }, + recordSnapshot: { content: 'saved content' }, + } + ); - await manager.load( - mockSyncConfig, - 'post', - '123', - mockRecord, - mockHandlers - ); + jest.clearAllMocks(); + mockSyncConfig.getChangesFromCRDTDoc.mockReturnValueOnce( { + blocks: [ 'stale crdt blocks' ], + } ); + mockHandlers.getEditedRecord.mockResolvedValue( mockRecord ); - expect( - mockSyncConfig.applyChangesToCRDTDoc - ).toHaveBeenCalledTimes( 1 ); - expect( mockProviderCreator ).toHaveBeenCalledTimes( 1 ); - } ); + await manager.hydrateRecordFromPersistedCRDTDoc( + 'post', + '123', + { + id: '123', + content: 'saved content', + meta: { + _crdt_document: staleCRDTDocument, + }, + } + ); - it( 'unloads specific entity without affecting others', async () => { - const manager = createSyncManager(); + expect( + mockSyncConfig.applyChangesToCRDTDoc + ).toHaveBeenCalledWith( expect.any( Y.Doc ), { + blocks: undefined, + content: 'saved content', + } ); + expect( mockHandlers.editRecord ).not.toHaveBeenCalledWith( + { content: 'stale crdt content' }, + expect.anything() + ); + } ); - await manager.load( - mockSyncConfig, - 'post', - '123', - mockRecord, - mockHandlers - ); + it( 'reuses the base persisted CRDT doc when only save metadata changed', async () => { + const manager = createSyncManager(); - await manager.load( - mockSyncConfig, - 'post', - '456', - mockRecord, - mockHandlers - ); + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); - manager.unload( 'post', '123' ); + const basePersistedDoc = await manager.createPersistedCRDTDoc( + 'post', + '123' + ); + expect( basePersistedDoc ).toBeTruthy(); - // Only one provider should be destroyed - expect( mockProviderResult.destroy ).toHaveBeenCalledTimes( 1 ); + manager.update( 'post', '123', {}, LOCAL_EDITOR_ORIGIN, { + isSave: true, + } ); - // Should still be able to update the other entity - jest.clearAllMocks(); - manager.update( 'post', '456', { title: 'Updated' }, 'local' ); + const nextPersistedDoc = await manager.createPersistedCRDTDoc( + 'post', + '123', + { + basePersistedCRDTDoc: basePersistedDoc, + } + ); + const awareness = manager.getAwareness< Awareness >( + 'post', + '123' + ); + const stateMap = awareness?.doc.getMap( CRDT_STATE_MAP_KEY ); - // Wait a tick for any async follow-up work. - await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + expect( stateMap?.get( SAVED_AT_KEY ) ).toEqual( + expect.any( Number ) + ); + expect( stateMap?.get( SAVED_BY_KEY ) ).toEqual( + expect.any( Number ) + ); + expect( nextPersistedDoc ).toBe( basePersistedDoc ); + } ); - expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalled(); - } ); + it( 'reuses the base persisted CRDT doc when durable state is unchanged', async () => { + const manager = createSyncManager(); - it( 'clears the undo manager after unloading all entities', async () => { - const manager = createSyncManager(); + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); + + const basePersistedDoc = await manager.createPersistedCRDTDoc( + 'post', + '123' + ); + expect( basePersistedDoc ).toBeTruthy(); + + const nextPersistedDoc = await manager.createPersistedCRDTDoc( + 'post', + '123', + { + basePersistedCRDTDoc: basePersistedDoc, + } + ); + + expect( nextPersistedDoc ).toBe( basePersistedDoc ); + } ); + + it( 'serializes a new persisted CRDT doc when only record snapshots changed', async () => { + const manager = createSyncManager(); + + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); + + const basePersistedDoc = await manager.createPersistedCRDTDoc( + 'post', + '123' + ); + expect( basePersistedDoc ).toBeTruthy(); + + const nextPersistedDoc = await manager.createPersistedCRDTDoc( + 'post', + '123', + { + basePersistedCRDTDoc: basePersistedDoc, + baseRecordSnapshot: { title: 'Base title' }, + recordSnapshot: { title: 'Snapshot title' }, + } + ); + + expect( nextPersistedDoc ).not.toBe( basePersistedDoc ); + expect( JSON.parse( nextPersistedDoc! ) ).toEqual( + expect.objectContaining( { + baseRecordSnapshot: { title: 'Base title' }, + recordSnapshot: { title: 'Snapshot title' }, + } ) + ); + } ); + + it( 'serializes a new persisted CRDT doc when record data changed', async () => { + const manager = createSyncManager(); + + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); + + const basePersistedDoc = await manager.createPersistedCRDTDoc( + 'post', + '123' + ); + expect( basePersistedDoc ).toBeTruthy(); + + manager.update( + 'post', + '123', + { title: 'Changed title' }, + LOCAL_EDITOR_ORIGIN + ); + + const nextPersistedDoc = await manager.createPersistedCRDTDoc( + 'post', + '123', + { + basePersistedCRDTDoc: basePersistedDoc, + } + ); + + expect( nextPersistedDoc ).not.toBe( basePersistedDoc ); + expect( JSON.parse( nextPersistedDoc! ).baseVersion ).toBe( + getPersistedCrdtDocVersion( basePersistedDoc ) + ); + } ); + + it( 'hydrates from a persisted CRDT doc stored in meta without invalidating missing record fields', async () => { + mockRecord = { + ...mockRecord, + content: 'old content', + }; + mockSyncConfig.getPersistedCRDTDoc = jest.fn( + ( record: ObjectData ) => { + if ( + ! record.meta || + typeof record.meta !== 'object' + ) { + return null; + } + + const meta = record.meta as + | { _crdt_document?: string | null } + | undefined; + + return meta?._crdt_document ?? null; + } + ); + const manager = createSyncManager(); + + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); + manager.update( + 'post', + '123', + { content: 'accepted content' }, + LOCAL_EDITOR_ORIGIN + ); + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + const savedCRDTDocument = await manager.createPersistedCRDTDoc( + 'post', + '123' + ); + + jest.clearAllMocks(); + mockHandlers.getEditedRecord.mockResolvedValue( mockRecord ); + + await manager.hydrateRecordFromPersistedCRDTDoc( + 'post', + '123', + { + id: '123', + meta: { + _crdt_document: savedCRDTDocument, + }, + } + ); + + expect( + mockSyncConfig.applyChangesToCRDTDoc + ).not.toHaveBeenCalled(); + expect( mockHandlers.persistCRDTDoc ).not.toHaveBeenCalled(); + expect( mockHandlers.editRecord ).toHaveBeenCalledWith( + { content: 'accepted content' }, + { undoIgnore: true, __unstableSkipSyncUpdate: true } + ); + } ); + } ); + } ); + + describe( 'unload', () => { + it( 'unloads an entity and destroys its state', async () => { + const manager = createSyncManager(); + + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); + + manager.unload( 'post', '123' ); + + expect( mockProviderResult.destroy ).toHaveBeenCalled(); + } ); + + it( 'does not throw when unloading non-existent entity', () => { + const manager = createSyncManager(); + + expect( () => { + manager.unload( 'post', '999' ); + } ).not.toThrow(); + } ); + + it( 'allows reloading after unloading', async () => { + const manager = createSyncManager(); + + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); + + manager.unload( 'post', '123' ); + + jest.clearAllMocks(); + + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); + + expect( + mockSyncConfig.applyChangesToCRDTDoc + ).toHaveBeenCalledTimes( 1 ); + expect( mockProviderCreator ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'unloads specific entity without affecting others', async () => { + const manager = createSyncManager(); + + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); + + await manager.load( + mockSyncConfig, + 'post', + '456', + mockRecord, + mockHandlers + ); + + manager.unload( 'post', '123' ); + + // Only one provider should be destroyed + expect( mockProviderResult.destroy ).toHaveBeenCalledTimes( 1 ); + + // Should still be able to update the other entity + jest.clearAllMocks(); + manager.update( 'post', '456', { title: 'Updated' }, 'local' ); + + // Wait a tick for any async follow-up work. + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalled(); + } ); + + it( 'clears the undo manager after unloading all entities', async () => { + const manager = createSyncManager(); await manager.load( mockSyncConfig, @@ -712,9 +1225,12 @@ describe( 'SyncManager', () => { await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); expect( handlers.editRecord ).toHaveBeenCalledTimes( 1 ); - expect( handlers.editRecord ).toHaveBeenCalledWith( { - remoteField: 'Remote value', - } ); + expect( handlers.editRecord ).toHaveBeenCalledWith( + { + remoteField: 'Remote value', + }, + { __unstableSkipSyncUpdate: true } + ); } ); it( 'does not update when entity is not loaded', async () => { @@ -976,12 +1492,17 @@ describe( 'SyncManager', () => { // Simulate a remote change. const remoteDoc = new Y.Doc(); + Y.applyUpdateV2( + remoteDoc, + Y.encodeStateAsUpdateV2( capturedDoc as unknown as Y.Doc ) + ); + const remoteStateVector = Y.encodeStateVector( remoteDoc ); remoteDoc .getMap( CRDT_RECORD_MAP_KEY ) - .set( 'title', 'Title from remote peer' ); + .set( 'remoteOnly', 'Value from remote peer' ); Y.applyUpdateV2( capturedDoc as unknown as Y.Doc, - Y.encodeStateAsUpdateV2( remoteDoc ) + Y.encodeStateAsUpdateV2( remoteDoc, remoteStateVector ) ); remoteDoc.destroy(); @@ -989,9 +1510,12 @@ describe( 'SyncManager', () => { await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); expect( mockHandlers.editRecord ).toHaveBeenCalledTimes( 1 ); - expect( mockHandlers.editRecord ).toHaveBeenCalledWith( { - title: 'Title from remote peer', - } ); + expect( mockHandlers.editRecord ).toHaveBeenCalledWith( + { + remoteOnly: 'Value from remote peer', + }, + { __unstableSkipSyncUpdate: true } + ); } ); it( 'refetches the entity record when a remote save updates save metadata', async () => { @@ -1032,6 +1556,448 @@ describe( 'SyncManager', () => { expect( mockHandlers.refetchRecord ).toHaveBeenCalledTimes( 1 ); } ); + it( 'allows local same-key updates scheduled while remote updates are reconciling', async () => { + let capturedDoc: Y.Doc | null = null; + mockProviderCreator.mockImplementation( async ( { ydoc } ) => { + capturedDoc = ydoc; + return mockProviderResult; + } ); + mockSyncConfig.applyChangesToCRDTDoc = jest.fn( + ( ydoc: CRDTDoc, changes: Partial< ObjectData > ) => { + const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); + Object.entries( changes ).forEach( ( [ key, value ] ) => + ymap.set( key, value ) + ); + } + ); + + const manager = createSyncManager(); + + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); + + mockSyncConfig.applyChangesToCRDTDoc.mockClear(); + mockHandlers.editRecord.mockClear(); + + const remoteDoc = new Y.Doc(); + Y.applyUpdateV2( + remoteDoc, + Y.encodeStateAsUpdateV2( capturedDoc as unknown as Y.Doc ) + ); + const remoteStateVector = Y.encodeStateVector( remoteDoc ); + remoteDoc + .getMap( CRDT_RECORD_MAP_KEY ) + .set( 'title', 'Title from remote peer' ); + Y.applyUpdateV2( + capturedDoc as unknown as Y.Doc, + Y.encodeStateAsUpdateV2( remoteDoc, remoteStateVector ) + ); + remoteDoc.destroy(); + + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + expect( mockHandlers.editRecord ).toHaveBeenCalledWith( + { + title: 'Title from remote peer', + }, + { __unstableSkipSyncUpdate: true } + ); + + manager.update( + 'post', + '123', + { + content: 'Local content edit', + title: mockRecord.title, + }, + LOCAL_EDITOR_ORIGIN + ); + + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith( + capturedDoc as unknown as Y.Doc, + { + content: 'Local content edit', + title: mockRecord.title, + } + ); + } ); + + it( 'applies local keys before the edited record lookup resolves', async () => { + let capturedDoc: Y.Doc | null = null; + mockProviderCreator.mockImplementation( async ( { ydoc } ) => { + capturedDoc = ydoc; + return mockProviderResult; + } ); + mockSyncConfig.applyChangesToCRDTDoc = jest.fn( + ( ydoc: CRDTDoc, changes: Partial< ObjectData > ) => { + const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); + Object.entries( changes ).forEach( ( [ key, value ] ) => + ymap.set( key, value ) + ); + } + ); + + const manager = createSyncManager(); + + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); + + mockSyncConfig.applyChangesToCRDTDoc.mockClear(); + mockHandlers.editRecord.mockClear(); + + let resolveEditedRecord!: ( record: ObjectData ) => void; + const editedRecordPromise = new Promise< ObjectData >( + ( resolve ) => { + resolveEditedRecord = resolve; + } + ); + mockHandlers.getEditedRecord.mockImplementationOnce( + () => editedRecordPromise + ); + + manager.update( + 'post', + '123', + { + content: 'Local content edit', + title: mockRecord.title, + }, + LOCAL_EDITOR_ORIGIN + ); + + const remoteDoc = new Y.Doc(); + Y.applyUpdateV2( + remoteDoc, + Y.encodeStateAsUpdateV2( capturedDoc as unknown as Y.Doc ) + ); + const remoteStateVector = Y.encodeStateVector( remoteDoc ); + remoteDoc + .getMap( CRDT_RECORD_MAP_KEY ) + .set( 'title', 'Title from remote peer' ); + Y.applyUpdateV2( + capturedDoc as unknown as Y.Doc, + Y.encodeStateAsUpdateV2( remoteDoc, remoteStateVector ) + ); + remoteDoc.destroy(); + + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith( + capturedDoc as unknown as Y.Doc, + { + content: 'Local content edit', + title: mockRecord.title, + } + ); + + mockHandlers.getEditedRecord.mockImplementation( async () => ( { + ...mockRecord, + content: 'Local content edit', + title: 'Title from remote peer', + } ) ); + resolveEditedRecord( { + ...mockRecord, + content: 'Local content edit', + } ); + + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + expect( mockHandlers.editRecord ).toHaveBeenCalledWith( + { + title: 'Title from remote peer', + }, + { __unstableSkipSyncUpdate: true } + ); + } ); + + it( 'filters unchanged base-record keys while remote updates are reconciling', async () => { + let capturedDoc: Y.Doc | null = null; + mockProviderCreator.mockImplementation( async ( { ydoc } ) => { + capturedDoc = ydoc; + return mockProviderResult; + } ); + mockSyncConfig.applyChangesToCRDTDoc = jest.fn( + ( ydoc: CRDTDoc, changes: Partial< ObjectData > ) => { + const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); + Object.entries( changes ).forEach( ( [ key, value ] ) => + ymap.set( key, value ) + ); + } + ); + + const manager = createSyncManager(); + + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); + + mockSyncConfig.applyChangesToCRDTDoc.mockClear(); + mockHandlers.editRecord.mockClear(); + + let resolveEditedRecord!: ( record: ObjectData ) => void; + const editedRecordPromise = new Promise< ObjectData >( + ( resolve ) => { + resolveEditedRecord = resolve; + } + ); + mockHandlers.getEditedRecord.mockImplementationOnce( + () => editedRecordPromise + ); + + const remoteDoc = new Y.Doc(); + Y.applyUpdateV2( + remoteDoc, + Y.encodeStateAsUpdateV2( capturedDoc as unknown as Y.Doc ) + ); + const remoteStateVector = Y.encodeStateVector( remoteDoc ); + remoteDoc + .getMap( CRDT_RECORD_MAP_KEY ) + .set( 'title', 'Title from remote peer' ); + Y.applyUpdateV2( + capturedDoc as unknown as Y.Doc, + Y.encodeStateAsUpdateV2( remoteDoc, remoteStateVector ) + ); + remoteDoc.destroy(); + + manager.update( + 'post', + '123', + { + content: 'Local content edit', + title: mockRecord.title, + }, + LOCAL_EDITOR_ORIGIN, + { baseRecord: mockRecord } + ); + + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith( + capturedDoc as unknown as Y.Doc, + { + content: 'Local content edit', + }, + { + baseRecord: mockRecord, + } + ); + + mockHandlers.getEditedRecord.mockImplementation( async () => ( { + ...mockRecord, + content: 'Local content edit', + title: 'Title from remote peer', + } ) ); + resolveEditedRecord( { + ...mockRecord, + content: 'Local content edit', + } ); + + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + expect( mockHandlers.editRecord ).toHaveBeenCalledWith( + { + title: 'Title from remote peer', + }, + { __unstableSkipSyncUpdate: true } + ); + } ); + + it( 'filters stale save-response keys while remote updates are reconciling', async () => { + let capturedDoc: Y.Doc | null = null; + mockProviderCreator.mockImplementation( async ( { ydoc } ) => { + capturedDoc = ydoc; + return mockProviderResult; + } ); + mockSyncConfig.applyChangesToCRDTDoc = jest.fn( + ( ydoc: CRDTDoc, changes: Partial< ObjectData > ) => { + const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); + Object.entries( changes ).forEach( ( [ key, value ] ) => + ymap.set( key, value ) + ); + } + ); + + const manager = createSyncManager(); + + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); + + mockSyncConfig.applyChangesToCRDTDoc.mockClear(); + mockHandlers.editRecord.mockClear(); + + let resolveEditedRecord!: ( record: ObjectData ) => void; + const editedRecordPromise = new Promise< ObjectData >( + ( resolve ) => { + resolveEditedRecord = resolve; + } + ); + mockHandlers.getEditedRecord.mockImplementationOnce( + () => editedRecordPromise + ); + + const remoteDoc = new Y.Doc(); + Y.applyUpdateV2( + remoteDoc, + Y.encodeStateAsUpdateV2( capturedDoc as unknown as Y.Doc ) + ); + const remoteStateVector = Y.encodeStateVector( remoteDoc ); + remoteDoc + .getMap( CRDT_RECORD_MAP_KEY ) + .set( 'title', 'Title from remote peer' ); + Y.applyUpdateV2( + capturedDoc as unknown as Y.Doc, + Y.encodeStateAsUpdateV2( remoteDoc, remoteStateVector ) + ); + remoteDoc.destroy(); + + manager.update( + 'post', + '123', + { + title: mockRecord.title, + }, + LOCAL_EDITOR_ORIGIN, + { isSave: true } + ); + + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith( + capturedDoc as unknown as Y.Doc, + {} + ); + expect( + ( capturedDoc as unknown as Y.Doc ) + .getMap( CRDT_RECORD_MAP_KEY ) + .get( 'title' ) + ).toBe( 'Title from remote peer' ); + expect( + ( capturedDoc as unknown as Y.Doc ) + .getMap( CRDT_STATE_MAP_KEY ) + .get( SAVED_AT_KEY ) + ).toEqual( expect.any( Number ) ); + + mockHandlers.getEditedRecord.mockImplementation( async () => ( { + ...mockRecord, + title: 'Title from remote peer', + } ) ); + resolveEditedRecord( { + ...mockRecord, + } ); + + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + expect( mockHandlers.editRecord ).toHaveBeenCalledWith( + { + title: 'Title from remote peer', + }, + { __unstableSkipSyncUpdate: true } + ); + } ); + + it( 'allows local same-key updates scheduled after remote reconciliation starts', async () => { + let capturedDoc: Y.Doc | null = null; + mockProviderCreator.mockImplementation( async ( { ydoc } ) => { + capturedDoc = ydoc; + return mockProviderResult; + } ); + mockSyncConfig.applyChangesToCRDTDoc = jest.fn( + ( ydoc: CRDTDoc, changes: Partial< ObjectData > ) => { + const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); + Object.entries( changes ).forEach( ( [ key, value ] ) => + ymap.set( key, value ) + ); + } + ); + + const manager = createSyncManager(); + + await manager.load( + mockSyncConfig, + 'post', + '123', + mockRecord, + mockHandlers + ); + + mockSyncConfig.applyChangesToCRDTDoc.mockClear(); + mockHandlers.editRecord.mockClear(); + + let resolveEditedRecord!: ( record: ObjectData ) => void; + const editedRecordPromise = new Promise< ObjectData >( + ( resolve ) => { + resolveEditedRecord = resolve; + } + ); + mockHandlers.getEditedRecord.mockImplementationOnce( + () => editedRecordPromise + ); + + const remoteDoc = new Y.Doc(); + Y.applyUpdateV2( + remoteDoc, + Y.encodeStateAsUpdateV2( capturedDoc as unknown as Y.Doc ) + ); + const remoteStateVector = Y.encodeStateVector( remoteDoc ); + remoteDoc + .getMap( CRDT_RECORD_MAP_KEY ) + .set( 'title', 'Title from remote peer' ); + Y.applyUpdateV2( + capturedDoc as unknown as Y.Doc, + Y.encodeStateAsUpdateV2( remoteDoc, remoteStateVector ) + ); + remoteDoc.destroy(); + + manager.update( + 'post', + '123', + { + title: 'Local title after remote peer', + }, + LOCAL_EDITOR_ORIGIN + ); + + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + + expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith( + capturedDoc as unknown as Y.Doc, + { + title: 'Local title after remote peer', + } + ); + + mockHandlers.getEditedRecord.mockImplementation( async () => ( { + ...mockRecord, + title: 'Local title after remote peer', + } ) ); + resolveEditedRecord( { + ...mockRecord, + } ); + + await new Promise( ( resolve ) => setTimeout( resolve, 0 ) ); + } ); + it( 'does not edit the local record for local transactions', async () => { // Capture the Y.Doc from provider creator. let capturedDoc: Y.Doc | null = null; diff --git a/packages/sync/src/test/utils.ts b/packages/sync/src/test/utils.ts index 009175ce20548d..ed1ca3a6c5d25e 100644 --- a/packages/sync/src/test/utils.ts +++ b/packages/sync/src/test/utils.ts @@ -10,6 +10,7 @@ import { describe, expect, it, beforeEach } from '@jest/globals'; */ import { createYjsDoc, + getPersistedCrdtDocVersion, initializeYjsDoc, markEntityAsSaved, serializeCrdtDoc, @@ -142,8 +143,38 @@ describe( 'utils', () => { const parsed = JSON.parse( serialized ); expect( parsed ).toHaveProperty( 'document' ); + expect( parsed ).toHaveProperty( 'version' ); expect( typeof parsed.document ).toBe( 'string' ); + expect( typeof parsed.version ).toBe( 'string' ); expect( parsed.document.length ).toBeGreaterThan( 0 ); + expect( parsed.version ).toBe( + getPersistedCrdtDocVersion( serialized ) + ); + } ); + + it( 'serializes the base version of the known server document', () => { + const baseDoc = createYjsDoc(); + baseDoc.getMap( 'testMap' ).set( 'title', 'Base Title' ); + const baseSerialized = serializeCrdtDoc( baseDoc ); + const baseVersion = getPersistedCrdtDocVersion( baseSerialized ); + + const serialized = serializeCrdtDoc( testDoc, { baseVersion } ); + const parsed = JSON.parse( serialized ); + + expect( parsed.baseVersion ).toBe( baseVersion ); + } ); + + it( 'changes the version when the document changes', () => { + const firstSerialized = serializeCrdtDoc( testDoc ); + const firstVersion = + getPersistedCrdtDocVersion( firstSerialized ); + + testDoc.getMap( 'testMap' ).set( 'title', 'Changed Title' ); + const secondSerialized = serializeCrdtDoc( testDoc ); + const secondVersion = + getPersistedCrdtDocVersion( secondSerialized ); + + expect( secondVersion ).not.toBe( firstVersion ); } ); } ); diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index c87b13b3ee4814..347b80b47a1f49 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -119,34 +119,36 @@ export interface CollectionHandlers { } export interface SyncManagerUpdateOptions { - // Whether this update represents a user-facing entity save. + baseRecord?: ObjectData; isSave?: boolean; isNewUndoLevel?: boolean; } -export interface SyncUndoStackState { - hasRedo: boolean; - hasUndo: boolean; +export interface CreatePersistedCRDTDocOptions { + basePersistedCRDTDoc?: string | null; + baseRecordSnapshot?: ObjectData | null; + recordSnapshot?: ObjectData | null; } export interface RecordHandlers { addUndoMeta: ( ydoc: Y.Doc, meta: Map< string, any > ) => void; editRecord: ( data: Partial< ObjectData >, - options?: { undoIgnore?: boolean } + options?: { undoIgnore?: boolean; __unstableSkipSyncUpdate?: boolean } ) => void; getEditedRecord: () => Promise< ObjectData >; + onUndoStackChange: ( state: SyncUndoStackState ) => void; onStatusChange: OnStatusChangeCallback; persistCRDTDoc: () => void; refetchRecord: () => Promise< void >; restoreUndoMeta: ( ydoc: Y.Doc, meta: Map< string, any > ) => void; - onUndoStackChange?: ( state: SyncUndoStackState ) => void; } export interface SyncConfig { applyChangesToCRDTDoc: ( ydoc: Y.Doc, - changes: Partial< ObjectData > + changes: Partial< ObjectData >, + options?: SyncManagerUpdateOptions ) => void; createAwareness?: ( ydoc: Y.Doc, @@ -157,18 +159,33 @@ export interface SyncConfig { editedRecord: ObjectData ) => ObjectData; getPersistedCRDTDoc?: ( record: ObjectData ) => string | null; - shouldSync?: ( - objectType: ObjectType, - objectId: ObjectID | null - ) => boolean; - supportsPersistence?: boolean; -} + shouldSync?: ( + objectType: ObjectType, + objectId: ObjectID | null + ) => boolean; + supportsPersistence?: boolean; + } export interface SyncManager { + applyPersistedCRDTDoc: ( + objectType: ObjectType, + objectId: ObjectID, + record: ObjectData + ) => Promise< boolean >; createPersistedCRDTDoc: ( + objectType: ObjectType, + objectId: ObjectID, + options?: CreatePersistedCRDTDocOptions + ) => Promise< string | null >; + hydrateRecordFromPersistedCRDTDoc: ( + objectType: ObjectType, + objectId: ObjectID, + record: ObjectData + ) => Promise< boolean >; + getCRDTRecordData: ( objectType: ObjectType, objectId: ObjectID - ) => string | null; + ) => ObjectData | undefined; getAwareness: < State extends Awareness >( objectType: ObjectType, objectId: ObjectID @@ -203,8 +220,13 @@ export interface SyncUndoManager extends WPUndoManager< ObjectData > { ymap: Y.Map< any >, handlers: Pick< RecordHandlers, - 'addUndoMeta' | 'restoreUndoMeta' | 'onUndoStackChange' + 'addUndoMeta' | 'onUndoStackChange' | 'restoreUndoMeta' > ) => void; stopCapturing: () => void; } + +export interface SyncUndoStackState { + hasRedo: boolean; + hasUndo: boolean; +} diff --git a/packages/sync/src/utils.ts b/packages/sync/src/utils.ts index 8aeae08f5953c3..420a3b0ad0b7ee 100644 --- a/packages/sync/src/utils.ts +++ b/packages/sync/src/utils.ts @@ -15,12 +15,27 @@ import { CRDT_STATE_MAP_SAVED_BY_KEY as SAVED_BY_KEY, CRDT_STATE_MAP_VERSION_KEY as VERSION_KEY, } from './config'; -import type { CRDTDoc } from './types'; +import type { CRDTDoc, ObjectData } from './types'; // An object representation of CRDT document metadata. type DocumentMeta = Record< string, DocumentMetaValue >; type DocumentMetaValue = boolean | number | string; +interface SerializedCrdtDoc { + baseVersion?: string; + baseRecordSnapshot?: ObjectData; + document: string; + recordSnapshot?: ObjectData; + updateId?: number; + version?: string; +} + +interface SerializeCrdtDocOptions { + baseVersion?: string | null; + baseRecordSnapshot?: ObjectData | null; + recordSnapshot?: ObjectData | null; +} + /** * Creates a new Y.Doc instance with the given document metadata. * @@ -67,18 +82,123 @@ function pseudoRandomID(): number { return Math.floor( Math.random() * 1000000000 ); } -export function serializeCrdtDoc( crdtDoc: CRDTDoc ): string { - return JSON.stringify( { - document: buffer.toBase64( Y.encodeStateAsUpdateV2( crdtDoc ) ), +function toUint32Hex( value: number ): string { + return ( value >>> 0 ).toString( 16 ).padStart( 8, '0' ); +} + +function getPersistedCrdtDocDocumentVersion( document: string ): string { + let hashA = 0x811c9dc5; + let hashB = 0x811c9dc5 ^ 0x9e3779b9; + + for ( let i = 0; i < document.length; i++ ) { + const charCode = document.charCodeAt( i ); + hashA = Math.imul( hashA ^ charCode, 0x01000193 ); + hashB = Math.imul( hashB ^ charCode ^ ( i & 0xff ), 0x01000193 ); + } + + return `document:${ document.length }:${ toUint32Hex( + hashA + ) }${ toUint32Hex( hashB ) }`; +} + +function parseSerializedCrdtDoc( + serializedCrdtDoc: string +): SerializedCrdtDoc | null { + try { + const parsed = JSON.parse( serializedCrdtDoc ); + + if ( typeof parsed?.document !== 'string' ) { + return null; + } + + return parsed; + } catch { + return null; + } +} + +function isObjectData( value: unknown ): value is ObjectData { + return ( + 'object' === typeof value && null !== value && ! Array.isArray( value ) + ); +} + +export function getPersistedCrdtDocVersion( + serializedCrdtDoc: string | null | undefined +): string | null { + if ( ! serializedCrdtDoc ) { + return null; + } + + const parsed = parseSerializedCrdtDoc( serializedCrdtDoc ); + return parsed + ? getPersistedCrdtDocDocumentVersion( parsed.document ) + : null; +} + +export function getPersistedCrdtDocRecordSnapshot( + serializedCrdtDoc: string | null | undefined +): ObjectData | null { + if ( ! serializedCrdtDoc ) { + return null; + } + + const parsed = parseSerializedCrdtDoc( serializedCrdtDoc ); + return parsed && isObjectData( parsed.recordSnapshot ) + ? parsed.recordSnapshot + : null; +} + +export function getPersistedCrdtDocBaseRecordSnapshot( + serializedCrdtDoc: string | null | undefined +): ObjectData | null { + if ( ! serializedCrdtDoc ) { + return null; + } + + const parsed = parseSerializedCrdtDoc( serializedCrdtDoc ); + return parsed && isObjectData( parsed.baseRecordSnapshot ) + ? parsed.baseRecordSnapshot + : null; +} + +export function serializeCrdtDoc( + crdtDoc: CRDTDoc, + options: SerializeCrdtDocOptions = {} +): string { + const document = buffer.toBase64( Y.encodeStateAsUpdateV2( crdtDoc ) ); + const serialized: SerializedCrdtDoc = { + document, updateId: pseudoRandomID(), // helps with debugging - } ); + version: getPersistedCrdtDocDocumentVersion( document ), + }; + + if ( options.baseVersion ) { + serialized.baseVersion = options.baseVersion; + } + + if ( options.baseRecordSnapshot ) { + serialized.baseRecordSnapshot = options.baseRecordSnapshot; + } + + if ( options.recordSnapshot ) { + serialized.recordSnapshot = options.recordSnapshot; + } + + return JSON.stringify( serialized ); } export function deserializeCrdtDoc( serializedCrdtDoc: string ): CRDTDoc | null { try { - const { document } = JSON.parse( serializedCrdtDoc ); + const parsed = parseSerializedCrdtDoc( serializedCrdtDoc ); + + if ( ! parsed ) { + return null; + } + + const { document } = parsed; // Mark this document as from persistence. const docMeta: DocumentMeta = { diff --git a/phpunit/tests/collaboration/persistedCrdtDocumentMeta.php b/phpunit/tests/collaboration/persistedCrdtDocumentMeta.php new file mode 100644 index 00000000000000..62607b2cec5032 --- /dev/null +++ b/phpunit/tests/collaboration/persistedCrdtDocumentMeta.php @@ -0,0 +1,115 @@ +post->create(); + } + + public static function wpTearDownAfterClass() { + wp_delete_post( self::$post_id, true ); + } + + public function set_up() { + parent::set_up(); + delete_post_meta( self::$post_id, '_crdt_document' ); + } + + /** + * Creates a persisted CRDT document meta value. + * + * @param string $document Document payload. + * @param string|null $base_version Optional base version. + * @return string Persisted CRDT document meta value. + */ + private function create_crdt_document_meta_value( string $document, ?string $base_version = null ): string { + $value = array( + 'document' => $document, + 'updateId' => 123, + ); + + if ( null !== $base_version ) { + $value['baseVersion'] = $base_version; + } + + return wp_json_encode( $value ); + } + + public function test_allows_update_when_base_version_matches_current_document(): void { + $meta_key = '_crdt_document'; + $current_value = $this->create_crdt_document_meta_value( 'current-document' ); + $this->assertNotFalse( update_post_meta( self::$post_id, $meta_key, $current_value ) ); + + $base_version = gutenberg_get_persisted_crdt_document_version( $current_value ); + $next_value = $this->create_crdt_document_meta_value( 'next-document', $base_version ); + + $this->assertNotFalse( update_post_meta( self::$post_id, $meta_key, $next_value ) ); + $this->assertSame( $next_value, get_post_meta( self::$post_id, $meta_key, true ) ); + } + + public function test_rejects_update_when_base_version_is_stale(): void { + $meta_key = '_crdt_document'; + $current_value = $this->create_crdt_document_meta_value( 'current-document' ); + $old_value = $this->create_crdt_document_meta_value( 'old-document' ); + $this->assertNotFalse( update_post_meta( self::$post_id, $meta_key, $current_value ) ); + + $stale_base_version = gutenberg_get_persisted_crdt_document_version( $old_value ); + $stale_value = $this->create_crdt_document_meta_value( 'stale-document', $stale_base_version ); + + $this->assertFalse( update_post_meta( self::$post_id, $meta_key, $stale_value ) ); + $this->assertSame( $current_value, get_post_meta( self::$post_id, $meta_key, true ) ); + } + + public function test_rejects_update_without_base_version_when_current_document_differs(): void { + $meta_key = '_crdt_document'; + $current_value = $this->create_crdt_document_meta_value( 'current-document' ); + $stale_value = $this->create_crdt_document_meta_value( 'stale-document' ); + $this->assertNotFalse( update_post_meta( self::$post_id, $meta_key, $current_value ) ); + + $this->assertFalse( update_post_meta( self::$post_id, $meta_key, $stale_value ) ); + $this->assertSame( $current_value, get_post_meta( self::$post_id, $meta_key, true ) ); + } + + public function test_restore_revision_removes_persisted_crdt_document_meta(): void { + $post_id = self::factory()->post->create( + array( + 'post_content' => '

Original content

', + 'post_title' => 'Original title', + ) + ); + + $original_value = $this->create_crdt_document_meta_value( 'original-document' ); + $this->assertNotFalse( update_post_meta( $post_id, '_crdt_document', $original_value ) ); + + $revision_id = wp_save_post_revision( $post_id ); + $this->assertIsInt( $revision_id ); + $this->assertGreaterThan( 0, $revision_id ); + + wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => '

New content

', + 'post_title' => 'New title', + ) + ); + + $base_version = gutenberg_get_persisted_crdt_document_version( $original_value ); + $new_value = $this->create_crdt_document_meta_value( 'new-document', $base_version ); + $this->assertNotFalse( update_post_meta( $post_id, '_crdt_document', $new_value ) ); + + $this->assertIsInt( wp_restore_post_revision( $revision_id ) ); + + $this->assertSame( '', get_post_meta( $post_id, '_crdt_document', true ) ); + + wp_delete_post( $post_id, true ); + } +} diff --git a/phpunit/tests/collaboration/wpHttpPollingSyncServer.php b/phpunit/tests/collaboration/wpHttpPollingSyncServer.php index 8aa66b793f8a18..9cd24bcf954274 100644 --- a/phpunit/tests/collaboration/wpHttpPollingSyncServer.php +++ b/phpunit/tests/collaboration/wpHttpPollingSyncServer.php @@ -1160,4 +1160,136 @@ public function test_sync_rooms_are_isolated() { // Room 2 should have no updates. $this->assertEmpty( $data['rooms'][1]['updates'] ); } + + public function test_sync_limits_bloated_secondary_room_response_without_blocking_primary_room() { + wp_set_current_user( self::$editor_id ); + + $primary_room = $this->get_post_room(); + $secondary_room = 'root/site'; + $primary_update = array( + 'client_id' => 2, + 'type' => 'update', + 'data' => base64_encode( 'primary update' ), + ); + + $large_update_data = str_repeat( 'x', 900 * 1024 ); + $secondary_updates = array(); + for ( $i = 0; $i < 20; $i++ ) { + $secondary_updates[] = array( + 'client_id' => 2, + 'type' => 'update', + 'data' => $large_update_data, + ); + } + + $storage = new class( $primary_room, $primary_update, $secondary_room, $secondary_updates ) implements WP_Sync_Storage { + private array $cursors = array(); + private array $rooms; + private array $update_counts = array(); + + public function __construct( string $primary_room, array $primary_update, string $secondary_room, array $secondary_updates ) { + $this->rooms = array( + $primary_room => array( $primary_update ), + $secondary_room => $secondary_updates, + ); + } + + public function add_update( string $room, $update ): bool { + $this->rooms[ $room ][] = $update; + return true; + } + + public function get_awareness_state( string $room ): array { + unset( $room ); + return array(); + } + + public function get_cursor( string $room ): int { + return $this->cursors[ $room ] ?? 0; + } + + public function get_update_count( string $room ): int { + return $this->update_counts[ $room ] ?? count( $this->rooms[ $room ] ?? array() ); + } + + public function get_updates_after_cursor( string $room, int $cursor, ?int $max_update_bytes = null ): array { + $updates = $this->rooms[ $room ] ?? array(); + $this->update_counts[ $room ] = count( $updates ); + $this->cursors[ $room ] = $cursor; + $selected = array(); + $selected_update_storage_bytes = 0; + $last_selected_update_cursor = $cursor; + + foreach ( $updates as $index => $update ) { + $update_cursor = $index + 1; + if ( $update_cursor <= $cursor ) { + continue; + } + + $update_storage_bytes = strlen( wp_json_encode( $update ) ); + if ( null !== $max_update_bytes && $max_update_bytes <= 0 ) { + break; + } + + if ( + null !== $max_update_bytes && + $selected_update_storage_bytes + $update_storage_bytes > $max_update_bytes + ) { + break; + } + + $selected[] = $update; + $selected_update_storage_bytes += $update_storage_bytes; + $last_selected_update_cursor = $update_cursor; + } + + $this->cursors[ $room ] = $last_selected_update_cursor; + return $selected; + } + + public function remove_updates_before_cursor( string $room, int $cursor ): bool { + unset( $room, $cursor ); + return true; + } + + public function set_awareness_state( string $room, array $awareness ): bool { + unset( $room, $awareness ); + return true; + } + }; + + $server = new WP_HTTP_Polling_Sync_Server( $storage ); + $request = new WP_REST_Request( 'POST', '/wp-sync/v1/updates' ); + $request->set_param( + 'rooms', + array( + $this->build_room( $primary_room, 1, 0 ), + $this->build_room( $secondary_room, 1, 0 ), + ) + ); + + $response = $server->handle_request( $request ); + $this->assertInstanceOf( WP_REST_Response::class, $response ); + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + + $this->assertSame( $primary_room, $data['rooms'][0]['room'] ); + $this->assertSame( + array( + 'data' => $primary_update['data'], + 'type' => 'update', + ), + $data['rooms'][0]['updates'][0] + ); + $this->assertSame( $secondary_room, $data['rooms'][1]['room'] ); + $this->assertGreaterThan( 0, count( $data['rooms'][1]['updates'] ) ); + $this->assertLessThan( count( $secondary_updates ), count( $data['rooms'][1]['updates'] ) ); + $this->assertGreaterThan( 0, $data['rooms'][1]['end_cursor'] ); + $this->assertLessThan( count( $secondary_updates ), $data['rooms'][1]['end_cursor'] ); + $this->assertLessThanOrEqual( + WP_HTTP_Polling_Sync_Server::MAX_RESPONSE_BODY_SIZE, + strlen( wp_json_encode( $data ) ) + ); + } } diff --git a/phpunit/tests/collaboration/wpSyncPostMetaStorage.php b/phpunit/tests/collaboration/wpSyncPostMetaStorage.php index 0157baa10abd17..39615a5f4be8d8 100644 --- a/phpunit/tests/collaboration/wpSyncPostMetaStorage.php +++ b/phpunit/tests/collaboration/wpSyncPostMetaStorage.php @@ -492,6 +492,59 @@ public function test_duplicate_awareness_rows_coalesces_on_latest_row() { $this->assertSame( array( 'name' => 'Current' ), $awareness[0] ); } + public function test_limited_update_fetch_advances_cursor_to_returned_window() { + global $wpdb; + + $storage = new WP_Sync_Post_Meta_Storage(); + $room = $this->get_room() . ':bounded-fetch'; + + $updates = array( + array( + 'client_id' => 1, + 'type' => 'update', + 'data' => base64_encode( 'first' ), + ), + array( + 'client_id' => 2, + 'type' => 'update', + 'data' => base64_encode( 'second' ), + ), + array( + 'client_id' => 3, + 'type' => 'update', + 'data' => base64_encode( 'third' ), + ), + ); + + foreach ( $updates as $update ) { + $this->assertTrue( $storage->add_update( $room, $update ) ); + } + + $lineages = $this->get_storage_post_lineages( $room ); + $this->assertCount( 1, $lineages ); + + $first_row_size = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT CHAR_LENGTH(meta_value) FROM {$wpdb->postmeta} + WHERE post_id = %d AND meta_key = %s + ORDER BY meta_id ASC + LIMIT 1", + $lineages[0]->ID, + WP_Sync_Post_Meta_Storage::SYNC_UPDATE_META_KEY + ) + ); + + $first_page = $storage->get_updates_after_cursor( $room, 0, $first_row_size ); + $cursor = $storage->get_cursor( $room ); + + $this->assertSame( array( $updates[0] ), $first_page ); + $this->assertGreaterThan( 0, $cursor ); + + $remaining = $storage->get_updates_after_cursor( $room, $cursor, PHP_INT_MAX ); + + $this->assertSame( array_slice( $updates, 1 ), $remaining ); + } + /* * Race-condition tests. * diff --git a/test/e2e/config/global-setup.ts b/test/e2e/config/global-setup.ts index 6b86f67e9531c1..765db9c9555c89 100644 --- a/test/e2e/config/global-setup.ts +++ b/test/e2e/config/global-setup.ts @@ -14,6 +14,26 @@ import { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; */ import { setupRtcWebSocketProvider } from './rtc-websocket-setup'; +function isMissingPluginError( error: unknown, slug: string ) { + const message = + error instanceof Error ? error.message : String( error ?? '' ); + + return message.includes( `The plugin "${ slug }" isn't installed` ); +} + +async function deactivatePluginIfInstalled( + requestUtils: RequestUtils, + slug: string +) { + try { + await requestUtils.deactivatePlugin( slug ); + } catch ( error ) { + if ( ! isMissingPluginError( error, slug ) ) { + throw error; + } + } +} + async function globalSetup( config: FullConfig ) { const { storageState, baseURL } = config.projects[ 0 ].use; const storageStatePath = @@ -35,7 +55,8 @@ async function globalSetup( config: FullConfig ) { requestUtils.activateTheme( 'twentytwentyone' ), // Disable this test plugin as it's conflicting with some of the tests. // We already have reduced motion enabled and Playwright will wait for most of the animations anyway. - requestUtils.deactivatePlugin( + deactivatePluginIfInstalled( + requestUtils, 'gutenberg-test-plugin-disables-the-css-animations' ), requestUtils.deleteAllPosts(), diff --git a/test/e2e/config/rtc-websocket-setup.ts b/test/e2e/config/rtc-websocket-setup.ts index 56fa78a337119c..5f90459256b549 100644 --- a/test/e2e/config/rtc-websocket-setup.ts +++ b/test/e2e/config/rtc-websocket-setup.ts @@ -21,6 +21,26 @@ import type { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; const PROVIDER_PLUGIN = 'gutenberg-test-plugin-rtc-websocket-provider'; +function isMissingPluginError( error: unknown, slug: string ) { + const message = + error instanceof Error ? error.message : String( error ?? '' ); + + return message.includes( `The plugin "${ slug }" isn't installed` ); +} + +async function deactivatePluginIfInstalled( + requestUtils: RequestUtils, + slug: string +) { + try { + await requestUtils.deactivatePlugin( slug ); + } catch ( error ) { + if ( ! isMissingPluginError( error, slug ) ) { + throw error; + } + } +} + function getProviderPluginDir() { return path.resolve( __dirname, @@ -107,7 +127,7 @@ export async function setupRtcWebSocketProvider( const enabled = process.env.GUTENBERG_RTC_TEST_WS_PROVIDER === '1'; if ( ! enabled ) { - await requestUtils.deactivatePlugin( PROVIDER_PLUGIN ); + await deactivatePluginIfInstalled( requestUtils, PROVIDER_PLUGIN ); return; } diff --git a/test/e2e/playwright.rtc-websocket.config.ts b/test/e2e/playwright.rtc-websocket.config.ts index 74a557b66d8cf1..e0ba3ea72effa3 100644 --- a/test/e2e/playwright.rtc-websocket.config.ts +++ b/test/e2e/playwright.rtc-websocket.config.ts @@ -38,8 +38,12 @@ if ( Array.isArray( baseConfig.testIgnore ) ) { } else if ( baseConfig.testIgnore ) { baseTestIgnore.push( baseConfig.testIgnore ); } -const testIgnore = baseTestIgnore.filter( - ( ignore ) => ignore !== '**/specs/editor/collaboration/websocket-only/**' +const rtcTestIgnore = baseTestIgnore.filter( + ( pattern ) => + ! ( + typeof pattern === 'string' && + pattern === '**/specs/editor/collaboration/websocket-only/**' + ) ); const config = defineConfig( { @@ -51,7 +55,10 @@ const config = defineConfig( { // errors that surface via the polling pipeline) live under // `http-only/` and are excluded here. testMatch: '**/specs/editor/collaboration/**/collaboration-*.spec.ts', - testIgnore: [ ...testIgnore, '**/specs/editor/collaboration/http-only/**' ], + testIgnore: [ + ...rtcTestIgnore, + '**/specs/editor/collaboration/http-only/**', + ], webServer: [ ...baseWebServer, { diff --git a/test/e2e/specs/editor/collaboration/collaboration-draft-reopens-blank.spec.ts b/test/e2e/specs/editor/collaboration/collaboration-draft-reopens-blank.spec.ts new file mode 100644 index 00000000000000..ee1b9edf3ccfd5 --- /dev/null +++ b/test/e2e/specs/editor/collaboration/collaboration-draft-reopens-blank.spec.ts @@ -0,0 +1,206 @@ +/** + * WordPress dependencies + */ +import { + test, + expect, + type RequestUtils, +} from '@wordpress/e2e-test-utils-playwright'; + +/** + * Internal dependencies + */ +import CollaborationUtils, { + setCollaboration, +} from './fixtures/collaboration-utils'; + +type RestField = { raw?: string; rendered?: string } | string; +type RestPost = { + content?: RestField; + id: number; + status: string; + title?: RestField; +}; + +function rawField( field?: RestField ): string { + if ( ! field ) { + return ''; + } + + return typeof field === 'string' + ? field + : field.raw ?? field.rendered ?? ''; +} + +async function getCurrentPostId( page: { + evaluate: < T >( callback: () => T ) => Promise< T >; +} ): Promise< number > { + return page.evaluate( () => + ( window as any ).wp.data.select( 'core/editor' ).getCurrentPostId() + ); +} + +async function waitForEditorReady( page: { + waitForFunction: ( + callback: () => boolean, + arg?: unknown, + options?: { timeout?: number } + ) => Promise< unknown >; +} ) { + await page.waitForFunction( + () => + ( window as any )._wpCollaborationEnabled === true && + !! ( window as any ).wp?.data && + !! ( window as any ).wp?.blocks, + undefined, + { timeout: 30_000 } + ); +} + +async function insertPostContent( { + editor, + marker, + page, + title, +}: { + editor: any; + marker: string; + page: any; + title: string; +} ) { + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( title ); + await editor.canvas + .getByRole( 'button', { name: 'Add default block' } ) + .click(); + await page.keyboard.type( marker ); +} + +async function getPost( + requestUtils: RequestUtils, + postId: number +): Promise< RestPost > { + return requestUtils.rest< RestPost >( { + path: `/wp/v2/posts/${ postId }`, + params: { + context: 'edit', + }, + } ); +} + +async function assertNoConnectionModal( page: any ) { + await expect( + page.getByRole( 'dialog', { name: 'Connection lost' } ) + ).toBeHidden(); + await expect( + page.getByRole( 'dialog', { name: 'Connection expired' } ) + ).toBeHidden(); + await expect( + page.getByRole( 'dialog', { name: 'Too many editors connected' } ) + ).toBeHidden(); +} + +test.describe( 'Collaboration - same-user saved draft reopen loss', () => { + test( 'control: saved new draft content is visible after reopening without another same-account window', async ( { + admin, + editor, + page, + requestUtils, + } ) => { + test.setTimeout( 90_000 ); + + await setCollaboration( requestUtils, true ); + + const title = `Single window saved draft ${ Date.now() }`; + const marker = `single-window-saved-draft-marker-${ Date.now() }`; + + await admin.createNewPost( { postType: 'post' } ); + await waitForEditorReady( page ); + const postId = await getCurrentPostId( page ); + + await insertPostContent( { editor, marker, page, title } ); + await editor.saveDraft(); + + const savedPost = await getPost( requestUtils, postId ); + expect( savedPost.status ).toBe( 'draft' ); + expect( rawField( savedPost.title ) ).toContain( title ); + expect( rawField( savedPost.content ) ).toContain( marker ); + + await admin.visitAdminPage( + 'post.php', + `post=${ postId }&action=edit` + ); + await waitForEditorReady( page ); + + await expect( + editor.canvas.getByText( marker, { exact: true } ) + ).toBeVisible( { timeout: 30_000 } ); + } ); + + test( 'keeps saved new-draft content visible after a same-account auto-draft window was opened before saving', async ( { + admin, + editor, + page, + requestUtils, + } ) => { + test.setTimeout( 120_000 ); + + await setCollaboration( requestUtils, true ); + + const title = `Same user saved draft ${ Date.now() }`; + const marker = `same-user-saved-draft-marker-${ Date.now() }`; + const utils = new CollaborationUtils( { + admin, + editor, + page, + requestUtils, + } ); + + try { + await admin.createNewPost( { postType: 'post' } ); + await waitForEditorReady( page ); + const postId = await getCurrentPostId( page ); + + await utils.joinCurrentUserSession( postId ); + await assertNoConnectionModal( page ); + await assertNoConnectionModal( utils.page2 ); + + await insertPostContent( { editor, marker, page, title } ); + await editor.saveDraft(); + + const savedPost = await getPost( requestUtils, postId ); + expect( savedPost.status ).toBe( 'draft' ); + expect( rawField( savedPost.title ) ).toContain( title ); + expect( rawField( savedPost.content ) ).toContain( marker ); + + await page.reload( { waitUntil: 'domcontentloaded' } ); + await waitForEditorReady( page ); + await assertNoConnectionModal( page ); + + await admin.visitAdminPage( + 'edit.php', + new URLSearchParams( { + post_status: 'draft', + post_type: 'post', + s: title, + } ).toString() + ); + await expect( + page.getByRole( 'link', { exact: true, name: title } ) + ).toBeVisible( { timeout: 30_000 } ); + + await admin.visitAdminPage( + 'post.php', + `post=${ postId }&action=edit` + ); + await waitForEditorReady( page ); + + await expect( + editor.canvas.getByText( marker, { exact: true } ) + ).toBeVisible( { timeout: 30_000 } ); + } finally { + await utils.teardown(); + } + } ); +} ); diff --git a/test/e2e/specs/editor/collaboration/collaboration-nested-awareness-selection.spec.ts b/test/e2e/specs/editor/collaboration/collaboration-nested-awareness-selection.spec.ts index 2ead9c2e10b74d..4f5e4f85d37132 100644 --- a/test/e2e/specs/editor/collaboration/collaboration-nested-awareness-selection.spec.ts +++ b/test/e2e/specs/editor/collaboration/collaboration-nested-awareness-selection.spec.ts @@ -2,6 +2,189 @@ * Internal dependencies */ import { test, expect } from './fixtures'; +import type CollaborationUtils from './fixtures/collaboration-utils'; + +type Editor = import('@wordpress/e2e-test-utils-playwright').Editor; +type Page = import('@playwright/test').Page; + +const TWO_ROW_TABLE_CONTENT = + '\n' + + '
' + + '' + + '' + + '
AlphaBeta
GammaDelta
\n' + + ''; + +const TABLE_WITH_CAPTION_CONTENT = + '\n' + + '
' + + '' + + '
AlphaBeta
Caption target
\n' + + ''; + +async function expectTableBlockLoaded( + collaborationUtils: CollaborationUtils +) { + await expect + .poll( () => collaborationUtils.editor2.getBlocks(), { + timeout: 10000, + } ) + .toMatchObject( [ + { + name: 'core/table', + }, + ] ); +} + +async function getBodyCellTexts( editor: Editor ) { + return editor.canvas + .getByRole( 'textbox', { name: 'Body cell text' } ) + .evaluateAll( ( cells ) => + cells.map( ( cell ) => cell.textContent?.trim() ) + ); +} + +function getBodyCellAttributeKey( index: number ) { + return `body.${ Math.floor( index / 2 ) }.cells.${ index % 2 }.content`; +} + +function getBodyCellRichText( editor: Editor, index: number ) { + return editor.canvas.locator( + `[data-wp-block-attribute-key="${ getBodyCellAttributeKey( index ) }"]` + ); +} + +async function placeCursorAtEndOfCell( { + editor, + page, + index, +}: { + editor: Editor; + page: Page; + index: number; +} ) { + const cell = getBodyCellRichText( editor, index ); + + await cell.click(); + await page.keyboard.press( 'End' ); +} + +async function deleteTableRow( { + editor, + page, + index, +}: { + editor: Editor; + page: Page; + index: number; +} ) { + await editor.canvas + .getByRole( 'textbox', { name: 'Body cell text' } ) + .nth( index ) + .click(); + await editor.clickBlockToolbarButton( 'Edit table' ); + await page.getByRole( 'menuitem', { name: 'Delete row' } ).click(); +} + +async function insertTableRowBefore( { + editor, + page, + index, +}: { + editor: Editor; + page: Page; + index: number; +} ) { + await editor.canvas + .getByRole( 'textbox', { name: 'Body cell text' } ) + .nth( index ) + .click(); + await editor.clickBlockToolbarButton( 'Edit table' ); + await page.getByRole( 'menuitem', { name: 'Insert row before' } ).click(); +} + +async function expectRemoteCursorInsideCell( page: Page, cellIndex: number ) { + const editorFrame = page.frameLocator( 'iframe[name="editor-canvas"]' ); + const cursor = editorFrame.locator( '.collaborators-overlay-user-cursor' ); + + await expect + .poll( () => cursor.count(), { timeout: 15000 } ) + .toBeGreaterThan( 0 ); + + const cursorBox = await cursor.first().boundingBox(); + if ( ! cursorBox ) { + throw new Error( 'Collaborator cursor bounding box not available' ); + } + expect( cursorBox.height ).toBeGreaterThan( 0 ); + + const remoteCell = editorFrame + .locator( 'role=textbox[name="Body cell text"i]' ) + .nth( cellIndex ); + const cellBox = await remoteCell.boundingBox(); + if ( ! cellBox ) { + throw new Error( 'Remote target cell bounding box not available' ); + } + + const cursorCenterX = cursorBox.x + cursorBox.width / 2; + const cursorCenterY = cursorBox.y + cursorBox.height / 2; + const tolerance = 4; + + expect( cursorCenterX ).toBeGreaterThanOrEqual( cellBox.x - tolerance ); + expect( cursorCenterX ).toBeLessThanOrEqual( + cellBox.x + cellBox.width + tolerance + ); + expect( cursorCenterY ).toBeGreaterThanOrEqual( cellBox.y - tolerance ); + expect( cursorCenterY ).toBeLessThanOrEqual( + cellBox.y + cellBox.height + tolerance + ); +} + +async function getSelectionAttributeKeys( page: Page ) { + return page.evaluate( () => { + const blockEditor = window.wp.data.select( 'core/block-editor' ); + return { + start: blockEditor.getSelectionStart()?.attributeKey ?? null, + end: blockEditor.getSelectionEnd()?.attributeKey ?? null, + }; + } ); +} + +async function dragBetweenCells( { + editor, + page, + startIndex, + endIndex, +}: { + editor: Editor; + page: Page; + startIndex: number; + endIndex: number; +} ) { + const startCell = getBodyCellRichText( editor, startIndex ); + const endCell = getBodyCellRichText( editor, endIndex ); + + await startCell.scrollIntoViewIfNeeded(); + await endCell.scrollIntoViewIfNeeded(); + + const startBox = await startCell.boundingBox(); + const endBox = await endCell.boundingBox(); + + if ( ! startBox || ! endBox ) { + throw new Error( 'Could not resolve table cell bounding boxes' ); + } + + await page.mouse.move( + startBox.x + startBox.width * 0.75, + startBox.y + startBox.height / 2 + ); + await page.mouse.down(); + await page.mouse.move( + endBox.x + endBox.width * 0.75, + endBox.y + endBox.height / 2, + { steps: 12 } + ); + await page.mouse.up(); +} test.describe( 'Collaboration - Nested Awareness Selection', () => { test( 'cursor in a table cell appears in the same cell for another user', async ( { @@ -14,28 +197,14 @@ test.describe( 'Collaboration - Nested Awareness Selection', () => { title: 'Nested Awareness Selection Test', status: 'draft', date_gmt: new Date().toISOString(), - content: - '\n' + - '
' + - '' + - '' + - '
AlphaBeta
GammaDelta
\n' + - '', + content: TWO_ROW_TABLE_CONTENT, } ); await collaborationUtils.openCollaborativeSession( post.id ); const { page2 } = collaborationUtils; - await expect - .poll( () => collaborationUtils.editor2.getBlocks(), { - timeout: 10000, - } ) - .toMatchObject( [ - { - name: 'core/table', - }, - ] ); + await expectTableBlockLoaded( collaborationUtils ); // Target the last cell — row 1, column 1 ("Delta"), which is nth=3 // in a 2x2 grid. Picking the last cell maximizes distance from the @@ -102,4 +271,180 @@ test.describe( 'Collaboration - Nested Awareness Selection', () => { cellBox.y + cellBox.height ); } ); + + test( 'cursor follows a table cell after another user deletes a preceding row', async ( { + collaborationUtils, + requestUtils, + editor, + page, + } ) => { + const post = await requestUtils.createPost( { + title: 'Nested Awareness Selection Row Delete Test', + status: 'draft', + date_gmt: new Date().toISOString(), + content: TWO_ROW_TABLE_CONTENT, + } ); + + await collaborationUtils.openCollaborativeSession( post.id ); + + const { editor2, page2 } = collaborationUtils; + + await expectTableBlockLoaded( collaborationUtils ); + + await placeCursorAtEndOfCell( { editor, page, index: 3 } ); + + await expect + .poll( + () => + page.evaluate( + () => + window.wp.data + .select( 'core/block-editor' ) + .getSelectionStart()?.attributeKey ?? '' + ), + { timeout: 5000 } + ) + .toBe( 'body.1.cells.1.content' ); + + await expectRemoteCursorInsideCell( page2, 3 ); + + await deleteTableRow( { editor: editor2, page: page2, index: 0 } ); + + await collaborationUtils.waitForConvergence( { timeout: 15000 } ); + + await expect + .poll( () => getBodyCellTexts( editor2 ), { timeout: 10000 } ) + .toEqual( [ 'Gamma', 'Delta' ] ); + + await expectRemoteCursorInsideCell( page2, 1 ); + } ); + + test( 'cursor follows a table cell after another user inserts a preceding row', async ( { + collaborationUtils, + requestUtils, + editor, + page, + } ) => { + const post = await requestUtils.createPost( { + title: 'Nested Awareness Selection Row Insert Test', + status: 'draft', + date_gmt: new Date().toISOString(), + content: TWO_ROW_TABLE_CONTENT, + } ); + + await collaborationUtils.openCollaborativeSession( post.id ); + + const { editor2, page2 } = collaborationUtils; + + await expectTableBlockLoaded( collaborationUtils ); + + await placeCursorAtEndOfCell( { editor, page, index: 3 } ); + + await expect + .poll( () => getSelectionAttributeKeys( page ), { timeout: 5000 } ) + .toMatchObject( { + start: 'body.1.cells.1.content', + end: 'body.1.cells.1.content', + } ); + + await expectRemoteCursorInsideCell( page2, 3 ); + + await insertTableRowBefore( { + editor: editor2, + page: page2, + index: 3, + } ); + + await collaborationUtils.waitForConvergence( { timeout: 15000 } ); + + await expect + .poll( () => getBodyCellTexts( editor2 ), { timeout: 10000 } ) + .toEqual( [ 'Alpha', 'Beta', '', '', 'Gamma', 'Delta' ] ); + + await expectRemoteCursorInsideCell( page2, 5 ); + } ); + + test( 'mouse drag selection across table cells preserves distinct attribute keys', async ( { + collaborationUtils, + requestUtils, + editor, + page, + } ) => { + const post = await requestUtils.createPost( { + title: 'Nested Awareness Selection Cross Cell Drag Test', + status: 'draft', + date_gmt: new Date().toISOString(), + content: TWO_ROW_TABLE_CONTENT, + } ); + + await collaborationUtils.openCollaborativeSession( post.id ); + + await expectTableBlockLoaded( collaborationUtils ); + + await dragBetweenCells( { + editor, + page, + startIndex: 1, + endIndex: 3, + } ); + + await expect + .poll( () => getSelectionAttributeKeys( page ), { timeout: 5000 } ) + .toMatchObject( { + start: 'body.0.cells.1.content', + end: 'body.1.cells.1.content', + } ); + } ); + + test( 'cursor in a table caption disappears when another user removes the caption', async ( { + collaborationUtils, + requestUtils, + editor, + page, + } ) => { + const post = await requestUtils.createPost( { + title: 'Nested Awareness Selection Caption Delete Test', + status: 'draft', + date_gmt: new Date().toISOString(), + content: TABLE_WITH_CAPTION_CONTENT, + } ); + + await collaborationUtils.openCollaborativeSession( post.id ); + + const { editor2, page2 } = collaborationUtils; + + await expectTableBlockLoaded( collaborationUtils ); + + await editor.canvas + .getByRole( 'textbox', { name: 'Table caption text' } ) + .click(); + await page.keyboard.press( 'End' ); + + await expect + .poll( () => getSelectionAttributeKeys( page ), { timeout: 5000 } ) + .toMatchObject( { + start: 'caption', + end: 'caption', + } ); + + const editorFrame = page2.frameLocator( + 'iframe[name="editor-canvas"]' + ); + const cursor = editorFrame.locator( + '.collaborators-overlay-user-cursor' + ); + + await expect + .poll( () => cursor.count(), { timeout: 15000 } ) + .toBeGreaterThan( 0 ); + + await editor2.canvas + .getByRole( 'textbox', { name: 'Table caption text' } ) + .click(); + await editor2.clickBlockToolbarButton( 'Remove caption' ); + + await collaborationUtils.waitForConvergence( { timeout: 15000 } ); + + await expect.poll( () => cursor.count(), { timeout: 10000 } ).toBe( 0 ); + } ); } ); diff --git a/test/e2e/specs/editor/collaboration/collaboration-same-user-stale-content-overwrite.spec.ts b/test/e2e/specs/editor/collaboration/collaboration-same-user-stale-content-overwrite.spec.ts new file mode 100644 index 00000000000000..fe260e79f06e63 --- /dev/null +++ b/test/e2e/specs/editor/collaboration/collaboration-same-user-stale-content-overwrite.spec.ts @@ -0,0 +1,656 @@ +/** + * External dependencies + */ +import type { BrowserContext, Page, Request } from '@playwright/test'; + +/** + * WordPress dependencies + */ +import type { + Admin, + Editor, + RequestUtils, +} from '@wordpress/e2e-test-utils-playwright'; + +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +const BASE_URL = process.env.WP_BASE_URL || 'http://localhost:8889'; +const ADMIN_USER = process.env.WP_USERNAME || 'admin'; +const ADMIN_PASSWORD = process.env.WP_PASSWORD || 'password'; +const DELAYED_CONTROL_MS = 12000; + +type RestField = { raw?: string; rendered?: string } | string; +type RestPost = { + content?: RestField; + id: number; +}; + +type SyncEvidence = { + count: number; + rooms: Set< string >; +}; + +type SaveTraceEntry = { + label: string; + method: string; + requestPostData: string; + requestAt: string; + responseContentRaw?: string; + responseAt?: string; + responseStatus?: number; + url: string; +}; + +type SaveSummary = { + label: string; + requestHasA: boolean; + requestHasB: boolean; + responseHasA: boolean; + responseHasB: boolean; + responseStatus?: number; +}; + +type ScenarioResult = { + attempt: number; + beforeBSaveHadA: boolean; + bSave?: SaveSummary; + finalContent: string; + finalHasA: boolean; + finalHasB: boolean; + markerA: string; + markerB: string; + postId: number; + room: string; + rtc: { + collaborationEnabled: boolean; + scripts: string[]; + }; + stalePreconditionExercised: boolean; + syncA: { + count: number; + rooms: string[]; + }; + syncB: { + count: number; + rooms: string[]; + }; +}; + +function paragraphMarkup( content: string ) { + return `

${ content }

`; +} + +function rawField( field?: RestField ): string { + if ( ! field ) { + return ''; + } + return typeof field === 'string' + ? field + : field.raw ?? field.rendered ?? ''; +} + +function sleep( ms: number ) { + return new Promise( ( resolve ) => setTimeout( resolve, ms ) ); +} + +function syncObserver( page: Page ): SyncEvidence { + const state: SyncEvidence = { count: 0, rooms: new Set() }; + + page.on( 'response', async ( response ) => { + if ( + ! response.url().includes( 'wp-sync' ) || + response.status() !== 200 + ) { + return; + } + state.count += 1; + try { + const payload = await response.json(); + for ( const room of payload?.rooms ?? [] ) { + if ( room?.room ) { + state.rooms.add( room.room ); + } + } + } catch {} + } ); + + return state; +} + +function isPostSaveRequest( request: Request, postId: number ) { + const url = request.url(); + const method = request.method(); + return ( + ( method === 'POST' || method === 'PUT' ) && + ( url.includes( `/wp/v2/posts/${ postId }` ) || + url.includes( `rest_route=%2Fwp%2Fv2%2Fposts%2F${ postId }` ) ) + ); +} + +function attachSaveTrace( page: Page, label: string, postId: number ) { + const entries: SaveTraceEntry[] = []; + + page.on( 'request', ( request ) => { + if ( ! isPostSaveRequest( request, postId ) ) { + return; + } + entries.push( { + label, + method: request.method(), + requestPostData: request.postData() ?? '', + requestAt: new Date().toISOString(), + url: request.url(), + } ); + } ); + + page.on( 'response', async ( response ) => { + const request = response.request(); + if ( ! isPostSaveRequest( request, postId ) ) { + return; + } + const entry = entries + .slice() + .reverse() + .find( + ( item ) => + item.url === request.url() && + item.method === request.method() && + item.responseStatus === undefined + ); + if ( ! entry ) { + return; + } + entry.responseStatus = response.status(); + entry.responseAt = new Date().toISOString(); + try { + const body = await response.json(); + entry.responseContentRaw = body?.content?.raw ?? ''; + } catch { + entry.responseContentRaw = ''; + } + } ); + + return entries; +} + +function requestContent( entry: SaveTraceEntry ) { + try { + return String( JSON.parse( entry.requestPostData )?.content ?? '' ); + } catch {} + return new URLSearchParams( entry.requestPostData ).get( 'content' ) ?? ''; +} + +function summarizeSave( + entries: SaveTraceEntry[], + markerA: string, + markerB: string, + label: string +): SaveSummary | undefined { + const candidates = entries + .filter( ( entry ) => entry.label === label ) + .map( ( entry ) => { + const requestBody = requestContent( entry ); + const responseBody = String( entry.responseContentRaw ?? '' ); + return { + label, + requestHasA: requestBody.includes( markerA ), + requestHasB: requestBody.includes( markerB ), + responseHasA: responseBody.includes( markerA ), + responseHasB: responseBody.includes( markerB ), + responseStatus: entry.responseStatus, + }; + } ); + + return ( + candidates + .slice() + .reverse() + .find( ( entry ) => entry.requestHasA || entry.requestHasB ) ?? + candidates.at( -1 ) + ); +} + +async function waitForEditorReady( page: Page, postId: number ) { + await page.waitForFunction( + ( id ) => + ( window as any )._wpCollaborationEnabled === true && + ( window as any ).wp?.data && + ( window as any ).wp?.blocks && + ( window as any ).wp.data + .select( 'core/editor' ) + .getCurrentPostId() === Number( id ) && + ( window as any ).wp.data + .select( 'core' ) + .hasFinishedResolution( 'getEntityRecord', [ + 'postType', + 'post', + Number( id ), + ] ) && + ! ( window as any ).wp.data.select( 'core/editor' ).isSavingPost(), + postId, + { timeout: 30000 } + ); + await page.waitForFunction( + () => document.querySelector( 'iframe[name="editor-canvas"]' ), + undefined, + { timeout: 30000 } + ); +} + +async function openPrimaryEditor( + admin: Admin, + editor: Editor, + page: Page, + postId: number +) { + await admin.visitAdminPage( 'post.php', `post=${ postId }&action=edit` ); + await editor.setPreferences( 'core/edit-post', { + welcomeGuide: false, + fullscreenMode: false, + } ); + await waitForEditorReady( page, postId ); +} + +async function openSameAdminEditor( admin: Admin, postId: number ) { + const context = await admin.browser.newContext( { + baseURL: BASE_URL, + } ); + const page = await context.newPage(); + + try { + await page.goto( '/wp-login.php' ); + await page.locator( '#user_login' ).fill( ADMIN_USER ); + await page.locator( '#user_pass' ).fill( ADMIN_PASSWORD ); + await page.getByRole( 'button', { name: 'Log In' } ).click(); + await page.waitForURL( '**/wp-admin/**' ); + + await page.goto( `/wp-admin/post.php?post=${ postId }&action=edit` ); + await page.evaluate( () => { + ( window as any ).wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-post', 'welcomeGuide', false ); + ( window as any ).wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-post', 'fullscreenMode', false ); + } ); + await waitForEditorReady( page, postId ); + return { context, page }; + } catch ( error ) { + await context.close(); + throw error; + } +} + +async function waitForMutualDiscovery( pageA: Page, pageB: Page ) { + await Promise.all( + [ pageA, pageB ].map( ( page ) => + page + .getByRole( 'button', { name: /Collaborators list/ } ) + .waitFor( { timeout: 30000 } ) + ) + ); +} + +async function waitForSyncRoom( + state: SyncEvidence, + room: string, + label: string +) { + await expect + .poll( + () => ( { + count: state.count, + hasRoom: state.rooms.has( room ), + rooms: Array.from( state.rooms ), + } ), + { + message: `${ label } should poll ${ room } via /wp-sync`, + timeout: 35000, + } + ) + .toMatchObject( { + hasRoom: true, + } ); +} + +async function collectRtcEvidence( page: Page ) { + return page.evaluate( () => ( { + collaborationEnabled: + ( window as any )._wpCollaborationEnabled === true, + scripts: Array.from( document.scripts ) + .map( ( script ) => script.src ) + .filter( + ( src ) => + src.includes( '/build/scripts/sync/' ) || + src.includes( '/build/scripts/core-data/' ) || + src.includes( '/build/scripts/editor/' ) || + src.includes( '/build/scripts/edit-post/' ) + ), + } ) ); +} + +function editorFrame( page: Page ) { + const frame = page.frame( { name: 'editor-canvas' } ); + if ( ! frame ) { + throw new Error( 'Editor iframe is not available.' ); + } + return frame; +} + +async function appendParagraphWithKeyboard( page: Page, marker: string ) { + const frame = editorFrame( page ); + const editable = frame + .locator( '[data-type="core/paragraph"][contenteditable="true"]' ) + .first(); + await editable.waitFor( { state: 'visible', timeout: 30000 } ); + + await editable.click(); + await page.keyboard.press( 'End' ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( marker ); + await page.waitForFunction( + ( expected ) => + ( window as any ).wp.data + .select( 'core/block-editor' ) + .getBlocks() + .some( ( block: { attributes?: { content?: string } } ) => + String( block.attributes?.content ?? '' ).includes( + expected + ) + ), + marker, + { timeout: 7000 } + ); + await page.waitForFunction( + ( expected ) => + String( + ( window as any ).wp.data + .select( 'core/editor' ) + .getEditedPostContent() + ).includes( expected ), + marker, + { timeout: 7000 } + ); +} + +async function saveDraftWithToolbar( page: Page ) { + const button = page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { name: /^Save draft$/ } ); + await button.waitFor( { state: 'visible', timeout: 30000 } ); + await page.waitForFunction( + () => + ( window as any ).wp.data + .select( 'core/editor' ) + .isEditedPostDirty(), + undefined, + { timeout: 30000 } + ); + await button.click(); + await page.waitForFunction( + () => + ! ( window as any ).wp.data.select( 'core/editor' ).isSavingPost(), + undefined, + { timeout: 30000 } + ); +} + +async function editorHasText( page: Page, marker: string ) { + return page.evaluate( + ( expected ) => + ( window as any ).wp.data + .select( 'core/block-editor' ) + .getBlocks() + .some( ( block: { attributes?: { content?: string } } ) => + String( block.attributes?.content ?? '' ).includes( + expected + ) + ), + marker + ); +} + +async function waitForServerText( + requestUtils: RequestUtils, + postId: number, + marker: string +) { + await expect + .poll( + async () => + rawField( + ( + await requestUtils.rest< RestPost >( { + path: `/wp/v2/posts/${ postId }?context=edit`, + } ) + ).content + ), + { timeout: 30000 } + ) + .toContain( marker ); +} + +async function getPersistedContent( + requestUtils: RequestUtils, + postId: number +) { + return rawField( + ( + await requestUtils.rest< RestPost >( { + path: `/wp/v2/posts/${ postId }?context=edit`, + } ) + ).content + ); +} + +async function runSameAccountScenario( { + admin, + attempt, + delayBeforeBSaveMs, + editor, + page, + requestUtils, + requireStaleBeforeBSave, +}: { + admin: Admin; + attempt: number; + delayBeforeBSaveMs: number; + editor: Editor; + page: Page; + requestUtils: RequestUtils; + requireStaleBeforeBSave: boolean; +} ): Promise< ScenarioResult > { + const markerA = `rtc-a-${ Date.now() }-${ attempt }`; + const markerB = `rtc-b-${ Date.now() }-${ attempt }`; + const post = await requestUtils.createPost( { + title: `Same-account stale save ${ Date.now() }`, + status: 'draft', + date_gmt: new Date().toISOString(), + content: paragraphMarkup( 'Initial body.' ), + } ); + const postId = post.id; + const room = `postType/post:${ postId }`; + let secondaryContext: BrowserContext | undefined; + + try { + await openPrimaryEditor( admin, editor, page, postId ); + const joined = await openSameAdminEditor( admin, postId ); + secondaryContext = joined.context; + const pageB = joined.page; + + const syncA = syncObserver( page ); + const syncB = syncObserver( pageB ); + const saveTraceA = attachSaveTrace( page, 'A', postId ); + const saveTraceB = attachSaveTrace( pageB, 'B', postId ); + + await waitForMutualDiscovery( page, pageB ); + await Promise.all( [ + waitForSyncRoom( syncA, room, 'Window A' ), + waitForSyncRoom( syncB, room, 'Window B' ), + ] ); + const rtc = await collectRtcEvidence( page ); + + await appendParagraphWithKeyboard( page, markerA ); + await saveDraftWithToolbar( page ); + await waitForServerText( requestUtils, postId, markerA ); + + if ( delayBeforeBSaveMs > 0 ) { + await sleep( delayBeforeBSaveMs ); + } + + const beforeBSaveHadA = await editorHasText( pageB, markerA ); + if ( requireStaleBeforeBSave && beforeBSaveHadA ) { + return { + attempt, + beforeBSaveHadA, + finalContent: '', + finalHasA: false, + finalHasB: false, + markerA, + markerB, + postId, + room, + rtc, + stalePreconditionExercised: false, + syncA: { + count: syncA.count, + rooms: Array.from( syncA.rooms ), + }, + syncB: { + count: syncB.count, + rooms: Array.from( syncB.rooms ), + }, + }; + } + + await appendParagraphWithKeyboard( pageB, markerB ); + await saveDraftWithToolbar( pageB ); + await waitForServerText( requestUtils, postId, markerB ); + + const finalContent = await getPersistedContent( requestUtils, postId ); + const combinedTrace = [ ...saveTraceA, ...saveTraceB ]; + return { + attempt, + beforeBSaveHadA, + bSave: summarizeSave( combinedTrace, markerA, markerB, 'B' ), + finalContent, + finalHasA: finalContent.includes( markerA ), + finalHasB: finalContent.includes( markerB ), + markerA, + markerB, + postId, + room, + rtc, + stalePreconditionExercised: true, + syncA: { + count: syncA.count, + rooms: Array.from( syncA.rooms ), + }, + syncB: { + count: syncB.count, + rooms: Array.from( syncB.rooms ), + }, + }; + } finally { + await secondaryContext?.close(); + } +} + +async function runUntilStalePrecondition( + options: Omit< + Parameters< typeof runSameAccountScenario >[ 0 ], + 'attempt' + >, + maxAttempts: number +) { + let lastResult: ScenarioResult | undefined; + for ( let attempt = 1; attempt <= maxAttempts; attempt++ ) { + lastResult = await runSameAccountScenario( { + ...options, + attempt, + } ); + if ( lastResult.stalePreconditionExercised ) { + return lastResult; + } + } + throw new Error( + `Could not exercise a stale same-account editor window after ${ maxAttempts } attempts. Last result: ${ JSON.stringify( + lastResult + ) }` + ); +} + +test.describe( 'Collaboration - same-user stale content overwrite', () => { + test( 'preserves content saved by another same-account window before polling catches up', async ( { + admin, + collaborationUtils, + editor, + page, + requestUtils, + }, testInfo ) => { + test.setTimeout( 180000 ); + void collaborationUtils; + + const result = await runUntilStalePrecondition( + { + admin, + delayBeforeBSaveMs: 0, + editor, + page, + requestUtils, + requireStaleBeforeBSave: true, + }, + 6 + ); + + await testInfo.attach( 'same-account-stale-save-trace', { + body: JSON.stringify( result, null, 2 ), + contentType: 'application/json', + } ); + + expect( result.rtc.collaborationEnabled ).toBe( true ); + expect( result.syncA.rooms ).toContain( result.room ); + expect( result.syncB.rooms ).toContain( result.room ); + expect( result.beforeBSaveHadA ).toBe( false ); + expect( result.bSave?.requestHasB ).toBe( true ); + expect( result.finalHasA ).toBe( true ); + expect( result.finalHasB ).toBe( true ); + } ); + + test( 'preserves both edits after the stale window receives polling updates', async ( { + admin, + collaborationUtils, + editor, + page, + requestUtils, + }, testInfo ) => { + test.setTimeout( 150000 ); + void collaborationUtils; + + const result = await runSameAccountScenario( { + admin, + attempt: 1, + delayBeforeBSaveMs: DELAYED_CONTROL_MS, + editor, + page, + requestUtils, + requireStaleBeforeBSave: false, + } ); + + await testInfo.attach( 'same-account-delayed-control-trace', { + body: JSON.stringify( result, null, 2 ), + contentType: 'application/json', + } ); + + expect( result.rtc.collaborationEnabled ).toBe( true ); + expect( result.syncA.rooms ).toContain( result.room ); + expect( result.syncB.rooms ).toContain( result.room ); + expect( result.beforeBSaveHadA ).toBe( true ); + expect( result.bSave?.requestHasB ).toBe( true ); + expect( result.finalHasA ).toBe( true ); + expect( result.finalHasB ).toBe( true ); + } ); +} ); diff --git a/test/e2e/specs/editor/collaboration/collaboration-stress.spec.ts b/test/e2e/specs/editor/collaboration/collaboration-stress.spec.ts index d317effcd2593a..fa5347c3d05f1e 100644 --- a/test/e2e/specs/editor/collaboration/collaboration-stress.spec.ts +++ b/test/e2e/specs/editor/collaboration/collaboration-stress.spec.ts @@ -122,21 +122,16 @@ async function expectSharedListReady( editors: Editor[], expectedOrder: string[] ) { - const renderedItems = await Promise.all( - editors.map( ( ed ) => getRenderedListItems( ed ) ) - ); - for ( const items of renderedItems ) { - expect( items.map( ( item ) => item.content ) ).toEqual( - expectedOrder + await expect( async () => { + const renderedItems = await Promise.all( + editors.map( ( ed ) => getRenderedListItems( ed ) ) ); - } - - const firstClientIds = renderedItems[ 0 ].map( ( item ) => item.clientId ); - for ( const items of renderedItems.slice( 1 ) ) { - expect( items.map( ( item ) => item.clientId ) ).toEqual( - firstClientIds - ); - } + for ( const items of renderedItems ) { + expect( items.map( ( item ) => item.content ) ).toEqual( + expectedOrder + ); + } + } ).toPass( { timeout: 10_000 } ); } function bol( items: string[] ): string { @@ -365,6 +360,8 @@ test.describe( 'Collaboration - Stress Test', () => { editor, page, } ) => { + test.setTimeout( 600_000 ); + // Create the two additional test users. for ( const user of STRESS_USERS ) { await requestUtils.createUser( user ); @@ -389,7 +386,7 @@ test.describe( 'Collaboration - Stress Test', () => { // ── Phase 2 — User 2 (Editor) joins ───────────────────── const { page: page2, editor: editor2 } = await collaborationUtils.joinUser( post.id, STRESS_USERS[ 0 ] ); - await collaborationUtils.waitForMutualDiscovery(); + await collaborationUtils.waitForMutualDiscovery( { timeout: 45_000 } ); // Admin types a new paragraph after the "Conclusion" heading. await typeNewParagraphAfterHeading( @@ -424,7 +421,7 @@ test.describe( 'Collaboration - Stress Test', () => { await page.reload( { waitUntil: 'load' } ); await collaborationUtils.waitForCollaborationReady( page ); - await collaborationUtils.waitForMutualDiscovery(); + await collaborationUtils.waitForMutualDiscovery( { timeout: 45_000 } ); // ── Phase 4 — Two users type in the same paragraph ────── // Uses insertText (single input event) instead of keyboard.type @@ -613,30 +610,25 @@ test.describe( 'Collaboration - Stress Test', () => { // ── Phase 2 — Concurrent list-item moves ──────────────── // User 1 moves "Item Beta" down; User 2 moves "Item Epsilon" up. // The items are well-separated so the moves don't conflict. - await Promise.all( [ - ( async () => { - await editor.canvas - .getByText( 'Item Beta', { exact: true } ) - .click(); - await editor.showBlockToolbar(); - await page - .locator( - 'role=toolbar[name="Block tools"i] >> role=button[name="Move down"i]' - ) - .click(); - } )(), - ( async () => { - await editor2.canvas - .getByText( 'Item Epsilon', { exact: true } ) - .click(); - await editor2.showBlockToolbar(); - await page2 - .locator( - 'role=toolbar[name="Block tools"i] >> role=button[name="Move up"i]' - ) - .click(); - } )(), - ] ); + await editor.canvas.getByText( 'Item Beta', { exact: true } ).click(); + await editor.showBlockToolbar(); + + await editor2.canvas + .getByText( 'Item Epsilon', { exact: true } ) + .click(); + await editor2.showBlockToolbar(); + + const moveBetaDown = page.locator( + 'role=toolbar[name="Block tools"i] >> role=button[name="Move down"i]' + ); + const moveEpsilonUp = page2.locator( + 'role=toolbar[name="Block tools"i] >> role=button[name="Move up"i]' + ); + + await expect( moveBetaDown ).toBeVisible(); + await expect( moveEpsilonUp ).toBeVisible(); + + await Promise.all( [ moveBetaDown.click(), moveEpsilonUp.click() ] ); // Verify both moves on both users: Beta after Gamma, // Epsilon before Delta. diff --git a/test/e2e/specs/editor/collaboration/collaboration-table-duplicates.spec.ts b/test/e2e/specs/editor/collaboration/collaboration-table-duplicates.spec.ts new file mode 100644 index 00000000000000..c7cf446abd3fed --- /dev/null +++ b/test/e2e/specs/editor/collaboration/collaboration-table-duplicates.spec.ts @@ -0,0 +1,292 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +type Editor = import('@wordpress/e2e-test-utils-playwright').Editor; +type Page = import('@playwright/test').Page; + +const TABLE_POST_CONTENT = ` +
anchor
same
same
+`; +const EDITED_SECOND_DUPLICATE = 'edited-second-duplicate'; + +async function getPersistedContent( + requestUtils: { + rest: < T >( options: { + path: string; + params?: Record< string, string >; + } ) => Promise< T >; + }, + postId: number +): Promise< string > { + const post = await requestUtils.rest< { + content: string | { raw?: string; rendered?: string }; + } >( { + path: `/wp/v2/posts/${ postId }`, + params: { context: 'edit' }, + } ); + + return typeof post.content === 'string' + ? post.content + : post.content.raw ?? post.content.rendered ?? ''; +} + +async function getRevisionContents( + requestUtils: { + rest: < T >( options: { + path: string; + params?: Record< string, string >; + } ) => Promise< T >; + }, + postId: number +): Promise< string[] > { + const revisions = await requestUtils.rest< + Array< { content?: string | { raw?: string; rendered?: string } } > + >( { + path: `/wp/v2/posts/${ postId }/revisions`, + params: { context: 'edit' }, + } ); + + return revisions.map( ( revision ) => { + if ( typeof revision.content === 'string' ) { + return revision.content; + } + + return revision.content?.raw ?? revision.content?.rendered ?? ''; + } ); +} + +async function getTableBodyCellContents( editor: Editor ) { + return editor.canvas + .getByRole( 'textbox', { name: 'Body cell text' } ) + .evaluateAll( ( cells ) => + cells.map( ( cell ) => cell.textContent?.trim() ) + ); +} + +async function editTableCell( { + editor, + page, + index, + content, +}: { + editor: Editor; + page: Page; + index: number; + content: string; +} ) { + await editor.canvas + .getByRole( 'textbox', { name: 'Body cell text' } ) + .nth( index ) + .click(); + await page.keyboard.press( 'ControlOrMeta+a' ); + await page.keyboard.type( content ); +} + +async function deleteTableRow( { + editor, + page, + index, +}: { + editor: Editor; + page: Page; + index: number; +} ) { + await editor.canvas + .getByRole( 'textbox', { name: 'Body cell text' } ) + .nth( index ) + .click(); + await editor.clickBlockToolbarButton( 'Edit table' ); + await page.getByRole( 'menuitem', { name: 'Delete row' } ).click(); +} + +async function selectTableCellText( { + editor, + page, + index, +}: { + editor: Editor; + page: Page; + index: number; +} ) { + await editor.canvas + .getByRole( 'textbox', { name: 'Body cell text' } ) + .nth( index ) + .click(); + await page.keyboard.press( 'ControlOrMeta+a' ); +} + +async function openDeleteRowMenu( { + editor, + page, + index, +}: { + editor: Editor; + page: Page; + index: number; +} ) { + await editor.canvas + .getByRole( 'textbox', { name: 'Body cell text' } ) + .nth( index ) + .click(); + await editor.clickBlockToolbarButton( 'Edit table' ); + await expect( + page.getByRole( 'menuitem', { name: 'Delete row' } ) + ).toBeVisible(); +} + +test.describe( 'Collaboration - duplicate table rows', () => { + test( 'preserves a later duplicate row edit when the earlier duplicate row is deleted', async ( { + collaborationUtils, + requestUtils, + editor, + page, + } ) => { + test.setTimeout( 45_000 ); + + const post = await requestUtils.createPost( { + title: 'Duplicate table row collaboration repro', + status: 'draft', + content: TABLE_POST_CONTENT, + date_gmt: new Date().toISOString(), + } ); + + await collaborationUtils.openCollaborativeSession( post.id ); + const { editor2, page2 } = collaborationUtils; + + await expect + .poll( () => getTableBodyCellContents( editor ), { + timeout: 10_000, + } ) + .toEqual( [ 'anchor', 'same', 'same' ] ); + await expect + .poll( () => getTableBodyCellContents( editor2 ), { + timeout: 10_000, + } ) + .toEqual( [ 'anchor', 'same', 'same' ] ); + + await Promise.all( [ + editTableCell( { + content: EDITED_SECOND_DUPLICATE, + editor, + index: 2, + page, + } ), + deleteTableRow( { + editor: editor2, + index: 1, + page: page2, + } ), + ] ); + + await Promise.all( [ + collaborationUtils.waitForSyncCycle( page, 5 ), + collaborationUtils.waitForSyncCycle( page2, 5 ), + ] ); + + await expect + .poll( () => getTableBodyCellContents( editor ), { + timeout: 10_000, + } ) + .toEqual( [ 'anchor', EDITED_SECOND_DUPLICATE ] ); + await expect + .poll( () => getTableBodyCellContents( editor2 ), { + timeout: 10_000, + } ) + .toEqual( [ 'anchor', EDITED_SECOND_DUPLICATE ] ); + } ); + + test( 'saves the later duplicate row edit into revisions when another user deletes the earlier duplicate row', async ( { + collaborationUtils, + requestUtils, + editor, + page, + } ) => { + test.setTimeout( 60_000 ); + + const post = await requestUtils.createPost( { + title: 'Duplicate table row body revision loss', + status: 'draft', + content: TABLE_POST_CONTENT, + date_gmt: new Date().toISOString(), + } ); + + await collaborationUtils.openCollaborativeSession( post.id ); + const { editor2, page2 } = collaborationUtils; + + await expect + .poll( () => getTableBodyCellContents( editor ), { + timeout: 10_000, + } ) + .toEqual( [ 'anchor', 'same', 'same' ] ); + await expect + .poll( () => getTableBodyCellContents( editor2 ), { + timeout: 10_000, + } ) + .toEqual( [ 'anchor', 'same', 'same' ] ); + + await selectTableCellText( { + editor, + index: 2, + page, + } ); + await openDeleteRowMenu( { + editor: editor2, + index: 1, + page: page2, + } ); + + await page.keyboard.type( EDITED_SECOND_DUPLICATE ); + await expect + .poll( () => getTableBodyCellContents( editor ), { + timeout: 5_000, + } ) + .toContain( EDITED_SECOND_DUPLICATE ); + await page2.getByRole( 'menuitem', { name: 'Delete row' } ).click(); + + await Promise.all( [ + collaborationUtils.waitForSyncCycle( page, 5, { + timeout: 20_000, + } ), + collaborationUtils.waitForSyncCycle( page2, 5, { + timeout: 20_000, + } ), + ] ); + await expect + .poll( () => getTableBodyCellContents( editor ), { + timeout: 10_000, + } ) + .toEqual( [ 'anchor', EDITED_SECOND_DUPLICATE ] ); + await expect + .poll( () => getTableBodyCellContents( editor2 ), { + timeout: 10_000, + } ) + .toEqual( [ 'anchor', EDITED_SECOND_DUPLICATE ] ); + + const editorCellsBeforeSave = await getTableBodyCellContents( editor ); + await editor.saveDraft(); + + expect( { + editorCellsBeforeSave, + persistedContent: await getPersistedContent( + requestUtils, + post.id + ), + revisionContents: await getRevisionContents( + requestUtils, + post.id + ), + } ).toEqual( { + editorCellsBeforeSave: expect.arrayContaining( [ + EDITED_SECOND_DUPLICATE, + ] ), + persistedContent: expect.stringContaining( + EDITED_SECOND_DUPLICATE + ), + revisionContents: expect.arrayContaining( [ + expect.stringContaining( EDITED_SECOND_DUPLICATE ), + ] ), + } ); + } ); +} ); diff --git a/test/e2e/specs/editor/collaboration/collaboration-table-stale-snapshot.spec.ts b/test/e2e/specs/editor/collaboration/collaboration-table-stale-snapshot.spec.ts new file mode 100644 index 00000000000000..1130eab2c9f120 --- /dev/null +++ b/test/e2e/specs/editor/collaboration/collaboration-table-stale-snapshot.spec.ts @@ -0,0 +1,164 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +const ONE_COLUMN_TABLE = ` +
+`; + +const TWO_COLUMN_TABLE = ` +
A1B1
A2B2
+`; + +async function getBodyCellTexts( editor: any ): Promise< string[] > { + const texts = await editor.canvas + .locator( 'role=textbox[name="Body cell text"i]' ) + .allTextContents(); + return texts.map( ( text: string ) => text.replace( /\uFEFF/g, '' ) ); +} + +async function typeInBodyCell( + page: any, + editor: any, + index: number, + text: string +) { + const cell = editor.canvas + .locator( 'role=textbox[name="Body cell text"i]' ) + .nth( index ); + await cell.click(); + await page.keyboard.type( text ); +} + +async function replaceHtmlModeText( editor: any, text: string ) { + const htmlEditor = editor.canvas.locator( + 'textarea.block-editor-block-list__block-html-textarea' + ); + await htmlEditor.click(); + await htmlEditor.fill( text ); +} + +test.describe( 'Collaboration - table stale snapshots', () => { + test( 'preserves a remotely inserted table row when another user edits a stale HTML snapshot', async ( { + collaborationUtils, + requestUtils, + editor, + } ) => { + const post = await requestUtils.createPost( { + title: 'RTC table stale HTML snapshot', + status: 'draft', + date_gmt: new Date().toISOString(), + content: TWO_COLUMN_TABLE, + } ); + await collaborationUtils.openCollaborativeSession( post.id ); + + const { page2, editor2 } = collaborationUtils; + + await expect + .poll( () => getBodyCellTexts( editor ), { timeout: 10_000 } ) + .toEqual( [ 'A1', 'B1', 'A2', 'B2' ] ); + await expect + .poll( () => getBodyCellTexts( editor2 ), { timeout: 10_000 } ) + .toEqual( [ 'A1', 'B1', 'A2', 'B2' ] ); + + await editor.canvas + .locator( 'role=textbox[name="Body cell text"i]' ) + .nth( 0 ) + .click(); + await editor.clickBlockOptionsMenuItem( 'Edit as HTML' ); + const userAHtml = editor.canvas.locator( + 'textarea.block-editor-block-list__block-html-textarea' + ); + await expect( userAHtml ).toHaveValue( /A1/ ); + + await editor2.canvas + .locator( 'role=textbox[name="Body cell text"i]' ) + .nth( 0 ) + .click(); + await editor2.clickBlockToolbarButton( 'Edit table' ); + await page2 + .getByRole( 'menuitem', { name: 'Insert row after' } ) + .click(); + await typeInBodyCell( page2, editor2, 2, 'A-new' ); + await typeInBodyCell( page2, editor2, 3, 'B-new' ); + + await expect + .poll( () => getBodyCellTexts( editor2 ), { timeout: 10_000 } ) + .toEqual( [ 'A1', 'B1', 'A-new', 'B-new', 'A2', 'B2' ] ); + + const staleHtml = await userAHtml.inputValue(); + await replaceHtmlModeText( + editor, + staleHtml.replace( 'A1', 'A1 local HTML edit' ) + ); + await expect( userAHtml ).toHaveValue( /A1 local HTML edit/ ); + await editor.clickBlockOptionsMenuItem( 'Edit visually' ); + + const expectedCells = [ + 'A1 local HTML edit', + 'B1', + 'A-new', + 'B-new', + 'A2', + 'B2', + ]; + + await expect + .poll( () => getBodyCellTexts( editor ), { timeout: 15_000 } ) + .toEqual( expectedCells ); + await expect + .poll( () => getBodyCellTexts( editor2 ), { timeout: 15_000 } ) + .toEqual( expectedCells ); + } ); + + test( 'preserves a remotely appended table row when another user edits a different cell', async ( { + collaborationUtils, + requestUtils, + page, + editor, + } ) => { + const post = await requestUtils.createPost( { + title: 'RTC table stale append', + status: 'draft', + date_gmt: new Date().toISOString(), + content: ONE_COLUMN_TABLE, + } ); + await collaborationUtils.openCollaborativeSession( post.id ); + + const { page2, editor2 } = collaborationUtils; + + await expect + .poll( () => getBodyCellTexts( editor ), { timeout: 10_000 } ) + .toEqual( [ '', '' ] ); + await expect + .poll( () => getBodyCellTexts( editor2 ), { timeout: 10_000 } ) + .toEqual( [ '', '' ] ); + + const userAReceivesSync = page.waitForResponse( + ( response ) => + response.url().includes( 'wp-sync' ) && + response.status() === 200, + { timeout: 15_000 } + ); + + await editor2.canvas + .locator( 'role=textbox[name="Body cell text"i]' ) + .nth( 1 ) + .click(); + await editor2.clickBlockToolbarButton( 'Edit table' ); + await page2 + .getByRole( 'menuitem', { name: 'Insert row after' } ) + .click(); + + await userAReceivesSync; + await typeInBodyCell( page, editor, 0, 'local-A1' ); + + await expect + .poll( () => getBodyCellTexts( editor ), { timeout: 15_000 } ) + .toEqual( [ 'local-A1', '', '' ] ); + await expect + .poll( () => getBodyCellTexts( editor2 ), { timeout: 15_000 } ) + .toEqual( [ 'local-A1', '', '' ] ); + } ); +} ); diff --git a/test/e2e/specs/editor/collaboration/fixtures/collaboration-utils.ts b/test/e2e/specs/editor/collaboration/fixtures/collaboration-utils.ts index 9f6df2f8ebd5fa..0b9a8bee1bd6fe 100644 --- a/test/e2e/specs/editor/collaboration/fixtures/collaboration-utils.ts +++ b/test/e2e/specs/editor/collaboration/fixtures/collaboration-utils.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import type { Page, BrowserContext } from '@playwright/test'; +import type { Page, BrowserContext, Route } from '@playwright/test'; /** * WordPress dependencies @@ -36,10 +36,12 @@ interface NormalizedBlock { interface NormalizedCollaborativeState { blocks: NormalizedBlock[]; + crdtDocument: string | null; title: string; } type CleanupUsersMode = 'all' | 'tracked' | 'none'; +type SyncFaultRouteHandler = ( route: Route ) => Promise< void >; export const SECOND_USER: UserCredentials = { username: 'collaborator', @@ -52,6 +54,7 @@ export const SECOND_USER: UserCredentials = { const BASE_URL = process.env.WP_BASE_URL || 'http://localhost:8889'; const USE_TEST_WS_PROVIDER = process.env.GUTENBERG_RTC_TEST_WS_PROVIDER === '1'; +const SYNC_ROUTE_PATTERN = '**/*wp-sync*'; export default class CollaborationUtils { private admin: Admin; @@ -60,6 +63,7 @@ export default class CollaborationUtils { private requestUtils: RequestUtils; private primaryPage: Page; private sessions: UserSession[] = []; + private syncFaultRoutes = new WeakMap< Page, SyncFaultRouteHandler >(); private trackedUserIds: number[] = []; constructor( { @@ -113,14 +117,48 @@ export default class CollaborationUtils { ? { storageState: { cookies: [], origins: [] } } : {} ), } ); - const newPage = await context.newPage(); - // Log in via the WordPress login form. - await newPage.goto( '/wp-login.php' ); - await newPage.locator( '#user_login' ).fill( user.username ); - await newPage.locator( '#user_pass' ).fill( user.password ); - await newPage.getByRole( 'button', { name: 'Log In' } ).click(); - await newPage.waitForURL( '**/wp-admin/**' ); + let newPage: Page | undefined; + + try { + // Authenticate through the context request API so browser cookies are + // ready before the editor page opens. + const loginPageResponse = await context.request.get( + '/wp-login.php', + { + failOnStatusCode: true, + } + ); + await loginPageResponse.dispose(); + + const loginResponse = await context.request.post( '/wp-login.php', { + failOnStatusCode: true, + form: { + log: user.username, + pwd: user.password, + 'wp-submit': 'Log In', + redirect_to: `${ BASE_URL }/wp-admin/`, + testcookie: '1', + }, + } ); + const loginUrl = loginResponse.url(); + await loginResponse.dispose(); + + if ( loginUrl.includes( '/wp-login.php' ) ) { + throw new Error( + `Failed to authenticate collaborator user ${ user.username }.` + ); + } + } catch { + newPage = await context.newPage(); + await newPage.goto( '/wp-login.php' ); + await newPage.locator( '#user_login' ).fill( user.username ); + await newPage.locator( '#user_pass' ).fill( user.password ); + await newPage.getByRole( 'button', { name: 'Log In' } ).click(); + await newPage.waitForURL( '**/wp-admin/**' ); + } + + newPage ??= await context.newPage(); // Navigate to the post editor. await newPage.goto( `/wp-admin/post.php?post=${ postId }&action=edit` ); @@ -152,6 +190,58 @@ export default class CollaborationUtils { return { page: newPage, editor: newEditor }; } + /** + * Open the same post in a new browser context using the primary user's + * authenticated session. This models a second same-account editor window. + * + * @param postId The post ID to open. + * @return The joined page and editor. + */ + async joinCurrentUserSession( + postId: number + ): Promise< { page: Page; editor: Editor } > { + const context = await this.admin.browser.newContext( { + baseURL: BASE_URL, + storageState: await this.primaryPage.context().storageState(), + } ); + const newPage = await context.newPage(); + + await newPage.goto( `/wp-admin/post.php?post=${ postId }&action=edit` ); + await newPage.waitForFunction( + () => window?.wp?.data && window?.wp?.blocks, + undefined, + { timeout: 30000 } + ); + await newPage.evaluate( () => { + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-post', 'welcomeGuide', false ); + window.wp.data + .dispatch( 'core/preferences' ) + .set( 'core/edit-post', 'fullscreenMode', false ); + } ); + + const newEditor = new Editor( { page: newPage } ); + + await this.waitForCollaborationReady( newPage ); + + this.sessions.push( { + user: { + username: 'current-user', + email: '', + firstName: '', + lastName: '', + password: '', + roles: [], + }, + context, + page: newPage, + editor: newEditor, + } ); + + return { page: newPage, editor: newEditor }; + } + /** * Wait for all current participants (primary + joined users) to * discover each other via the awareness protocol, then wait for @@ -164,11 +254,9 @@ export default class CollaborationUtils { async waitForMutualDiscovery( { timeout }: { timeout?: number } = {} ) { const pages = this.allPages; const resolvedTimeout = timeout ?? 10000 + pages.length * 2500; + const roomName = await this.getCurrentPostRoomName( this.primaryPage ); if ( USE_TEST_WS_PROVIDER ) { - const roomName = await this.getCurrentPostRoomName( - this.primaryPage - ); await Promise.all( pages.map( ( pg ) => this.waitForTestWebSocketAwarenessPeerCount( @@ -192,11 +280,12 @@ export default class CollaborationUtils { await Promise.all( pages.map( ( pg ) => - pg - .getByRole( 'button', { - name: /Collaborators list/, - } ) - .waitFor( { timeout: resolvedTimeout } ) + this.waitForAwarenessPeerCount( + pg, + pages.length, + resolvedTimeout, + roomName + ) ) ); await Promise.all( @@ -227,6 +316,58 @@ export default class CollaborationUtils { ); } + /** + * Wait until the sync transport reports the expected number of clients in + * the requested room's awareness payload. + * + * Some repros exercise lower-level sync behavior before the rendered + * collaborator presence UI has enough display metadata to show the + * "Collaborators list" button. The transport-level awareness count is the + * synchronization gate these repros actually need. + * + * @param page The Playwright page to wait on. + * @param expectedPeerCount Expected number of awareness clients. + * @param timeout Maximum wait time in ms. + * @param roomName Optional room name to require. + */ + async waitForAwarenessPeerCount( + page: Page, + expectedPeerCount: number, + timeout: number, + roomName?: string + ) { + await page.waitForResponse( + async ( response ) => { + if ( + ! response.url().includes( 'wp-sync' ) || + response.status() !== 200 + ) { + return false; + } + + const body = await response.json().catch( () => null ); + return ( + body?.rooms?.some( + ( room: { + room?: string; + awareness?: Record< string, unknown >; + } ) => + ( ! roomName || room.room === roomName ) && + room.awareness && + Object.keys( room.awareness ).length >= + expectedPeerCount + ) ?? false + ); + }, + { timeout } + ); + } + + /** + * Return the collaboration room name for the current post. + * + * @param page The Playwright page to read from. + */ async getCurrentPostRoomName( page: Page ): Promise< string > { const postId = await page.evaluate( () => @@ -347,19 +488,21 @@ export default class CollaborationUtils { } /** - * Read the _crdt_document meta value for the current post. + * Read the _crdt_document meta value from the currently loaded entity record. * * @param page The Playwright page to evaluate on. */ async getCrdtDocument( page: Page ): Promise< string | null > { - return page.evaluate( async () => { + return page.evaluate( () => { const postId = ( window as any ).wp.data .select( 'core/editor' ) .getCurrentPostId(); - const post = await ( window as any ).wp.apiFetch( { - path: `/wp/v2/posts/${ postId }?context=edit`, - } ); - return post?.meta?._crdt_document ?? null; + return ( + ( window as any ).wp.data + .select( 'core' ) + .getEntityRecord( 'postType', 'post', postId )?.meta + ?._crdt_document ?? null + ); } ); } @@ -436,62 +579,139 @@ export default class CollaborationUtils { } } + async failNextSyncRequest( page: Page, status = 503 ) { + const responseStatus = + Number.isInteger( status ) && status >= 400 ? status : 503; + + await this.interceptNextSyncRequest( page, async ( route ) => { + await route.fulfill( { + status: responseStatus, + contentType: 'application/json', + body: JSON.stringify( { + code: 'rtc_fuzz_sync_failure', + message: 'Injected sync failure from RTC fuzz harness.', + data: { + status: responseStatus, + }, + } ), + } ); + } ); + } + + async delayNextSyncRequest( page: Page, delayMs: number ) { + const boundedDelayMs = + Number.isFinite( delayMs ) && delayMs > 0 ? delayMs : 0; + + await this.interceptNextSyncRequest( page, async ( route ) => { + await new Promise( ( resolve ) => + setTimeout( resolve, boundedDelayMs ) + ); + await route.continue(); + } ); + } + + private async interceptNextSyncRequest( + page: Page, + handleRoute: SyncFaultRouteHandler + ) { + const previousRoute = this.syncFaultRoutes.get( page ); + if ( previousRoute ) { + await page.unroute( SYNC_ROUTE_PATTERN, previousRoute ); + } + + let consumed = false; + const routeHandler = async ( route: Route ) => { + if ( consumed ) { + await route.continue(); + return; + } + + consumed = true; + this.syncFaultRoutes.delete( page ); + await page.unroute( SYNC_ROUTE_PATTERN, routeHandler ); + await handleRoute( route ); + }; + + this.syncFaultRoutes.set( page, routeHandler ); + await page.route( SYNC_ROUTE_PATTERN, routeHandler ); + } + /** * Returns a normalized view of the current collaborative editor state for * equality checks across participants. * - * @param page The page to inspect. + * @param page The page to inspect. + * @param [options] Optional settings. + * @param [options.includeCrdtDocument] Whether to include the persisted + * _crdt_document in the returned state. */ async getNormalizedPostState( - page: Page + page: Page, + { includeCrdtDocument = false }: { includeCrdtDocument?: boolean } = {} ): Promise< NormalizedCollaborativeState > { - return page.evaluate( () => { - const normalizeBlocks = ( - blockTree: Array< { - attributes?: Record< string, unknown >; - innerBlocks?: Array< unknown >; - name: string; - } > - ): NormalizedBlock[] => - blockTree.map( ( block ) => ( { - name: block.name, - attributes: JSON.parse( - JSON.stringify( block.attributes ?? {} ) - ), - innerBlocks: normalizeBlocks( - ( block.innerBlocks ?? [] ) as Array< { - attributes?: Record< string, unknown >; - innerBlocks?: Array< unknown >; - name: string; - } > - ), - } ) ); - - const blocks = ( window as any ).wp.data - .select( 'core/block-editor' ) - .getBlocks(); - - return { - title: - ( window as any ).wp.data - .select( 'core/editor' ) - .getEditedPostAttribute( 'title' ) ?? '', - blocks: normalizeBlocks( blocks ), - }; - } ); + return page.evaluate( + ( { includePersistedDoc } ) => { + const normalizeBlocks = ( + blockTree: Array< { + attributes?: Record< string, unknown >; + innerBlocks?: Array< unknown >; + name: string; + } > + ): NormalizedBlock[] => + blockTree.map( ( block ) => ( { + name: block.name, + attributes: JSON.parse( + JSON.stringify( block.attributes ?? {} ) + ), + innerBlocks: normalizeBlocks( + ( block.innerBlocks ?? [] ) as Array< { + attributes?: Record< string, unknown >; + innerBlocks?: Array< unknown >; + name: string; + } > + ), + } ) ); + + const postId = ( window as any ).wp.data + .select( 'core/editor' ) + .getCurrentPostId(); + const record = ( window as any ).wp.data + .select( 'core' ) + .getEntityRecord( 'postType', 'post', postId ); + const blocks = ( window as any ).wp.data + .select( 'core/block-editor' ) + .getBlocks(); + + return { + title: + ( window as any ).wp.data + .select( 'core/editor' ) + .getEditedPostAttribute( 'title' ) ?? '', + blocks: normalizeBlocks( blocks ), + crdtDocument: includePersistedDoc + ? record?.meta?._crdt_document ?? null + : null, + }; + }, + { includePersistedDoc: includeCrdtDocument } + ); } /** * Wait until all tracked pages converge on the same normalized editor state. * - * @param [options] Optional settings. - * @param [options.pages] Specific pages to compare. - * @param [options.timeout] Maximum wait time in ms. + * @param [options] Optional settings. + * @param [options.includeCrdtDocument] Whether convergence should also + * include the persisted CRDT document. + * @param [options.pages] Specific pages to compare. + * @param [options.timeout] Maximum wait time in ms. */ async waitForConvergence( { + includeCrdtDocument = false, pages = this.allPages, timeout = 15000, }: { + includeCrdtDocument?: boolean; pages?: Page[]; timeout?: number; } = {} ): Promise< NormalizedCollaborativeState > { @@ -500,13 +720,31 @@ export default class CollaborationUtils { while ( Date.now() < deadline ) { lastStates = await Promise.all( - pages.map( ( page ) => this.getNormalizedPostState( page ) ) + pages.map( ( page ) => + this.getNormalizedPostState( page, { + includeCrdtDocument, + } ) + ) ); - const serializedFirstState = JSON.stringify( lastStates[ 0 ] ); - const isSettled = lastStates.every( - ( state ) => JSON.stringify( state ) === serializedFirstState + const comparableStates = lastStates.map( ( state ) => ( { + ...state, + crdtDocument: includeCrdtDocument + ? Boolean( state.crdtDocument ) + : state.crdtDocument, + } ) ); + const serializedFirstState = JSON.stringify( + comparableStates[ 0 ] ); + const allHaveCrdtDocument = + ! includeCrdtDocument || + lastStates.every( ( state ) => !! state.crdtDocument ); + const isSettled = + allHaveCrdtDocument && + comparableStates.every( + ( state ) => + JSON.stringify( state ) === serializedFirstState + ); if ( isSettled ) { return lastStates[ 0 ]; diff --git a/test/e2e/specs/editor/collaboration/websocket-only/collaboration-same-user-title-reload-loss.spec.ts b/test/e2e/specs/editor/collaboration/websocket-only/collaboration-same-user-title-reload-loss.spec.ts new file mode 100644 index 00000000000000..0448381e377471 --- /dev/null +++ b/test/e2e/specs/editor/collaboration/websocket-only/collaboration-same-user-title-reload-loss.spec.ts @@ -0,0 +1,90 @@ +/** + * Internal dependencies + */ +import { test, expect } from '../fixtures'; +import type { UserCredentials } from '../fixtures/collaboration-utils'; +import type CollaborationUtils from '../fixtures/collaboration-utils'; + +const ADMIN_USER: UserCredentials = { + username: process.env.WP_USERNAME ?? 'admin', + email: 'wordpress@example.com', + firstName: 'Admin', + lastName: 'User', + password: process.env.WP_PASSWORD ?? 'password', + roles: [ 'administrator' ], +}; + +async function waitForSameUserSession( + collaborationUtils: CollaborationUtils +) { + await Promise.all( + collaborationUtils.allPages.map( ( page ) => + collaborationUtils.waitForEntityReadyAndSaveSettled( page, { + timeout: 20_000, + } ) + ) + ); + await Promise.all( + collaborationUtils.allPages.map( ( page ) => + collaborationUtils.waitForSyncCycle( page, 2, { timeout: 20_000 } ) + ) + ); +} + +async function getEditedTitle( page: { + evaluate: < T >( callback: () => T ) => Promise< T >; +} ): Promise< string > { + return page.evaluate( () => + ( window as any ).wp.data + .select( 'core/editor' ) + .getEditedPostAttribute( 'title' ) + ); +} + +test.describe( 'Collaboration - same-user title reload loss', () => { + test( 'keeps an unsaved same-user title in a reloaded browser session', async ( { + collaborationUtils, + editor, + page, + requestUtils, + } ) => { + test.setTimeout( 90_000 ); + + const customerTitle = 'RTC same-user unsaved title before reload'; + + const post = await requestUtils.createPost( { + title: 'RTC same-user reload initial', + status: 'draft', + date_gmt: new Date().toISOString(), + content: + '

Initial body.

', + } ); + + await collaborationUtils.openPost( post.id ); + await collaborationUtils.joinUser( post.id, ADMIN_USER ); + await waitForSameUserSession( collaborationUtils ); + const { editor2, page2 } = collaborationUtils; + + await editor.canvas + .getByRole( 'textbox', { name: 'Add title' } ) + .fill( customerTitle ); + await expect + .poll( () => getEditedTitle( page2 ), { timeout: 20_000 } ) + .toBe( customerTitle ); + + await editor2.canvas + .getByRole( 'document', { name: 'Block: Paragraph' } ) + .click(); + await page2.keyboard.press( 'End' ); + await page2.keyboard.press( 'Enter' ); + await page2.keyboard.type( 'same user reload companion edit' ); + + await page2.reload( { waitUntil: 'domcontentloaded' } ); + await waitForSameUserSession( collaborationUtils ); + + await expect + .poll( () => getEditedTitle( page2 ), { timeout: 20_000 } ) + .toBe( customerTitle ); + expect( await getEditedTitle( page ) ).toBe( customerTitle ); + } ); +} ); diff --git a/test/e2e/specs/editor/collaboration/websocket-only/collaboration-table-followups.spec.ts b/test/e2e/specs/editor/collaboration/websocket-only/collaboration-table-followups.spec.ts new file mode 100644 index 00000000000000..19808414d3d7d9 --- /dev/null +++ b/test/e2e/specs/editor/collaboration/websocket-only/collaboration-table-followups.spec.ts @@ -0,0 +1,160 @@ +/** + * External dependencies + */ +import type { Page } from '@playwright/test'; + +/** + * WordPress dependencies + */ +import type { Editor } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Internal dependencies + */ +import { test, expect } from '../fixtures'; +import type { UserCredentials } from '../fixtures/collaboration-utils'; + +const COLLABORATOR: UserCredentials = { + username: 'table_collaborator', + email: 'table_collaborator@example.com', + firstName: 'Table', + lastName: 'Collaborator', + password: 'password', + roles: [ 'editor' ], +}; + +async function tableCells( editor: Editor ) { + return editor.canvas.getByRole( 'textbox', { name: 'Body cell text' } ); +} + +async function typeCell( + editor: Editor, + page: Page, + index: number, + text: string +) { + await ( await tableCells( editor ) ).nth( index ).click(); + await page.keyboard.press( 'ControlOrMeta+a' ); + await page.keyboard.type( text ); +} + +async function clickTableMenuItem( editor: Editor, page: Page, name: string ) { + await editor.clickBlockToolbarButton( 'Edit table' ); + await page.getByRole( 'menuitem', { name } ).click(); +} + +async function getVisibleTable( editor: Editor ): Promise< string[][] > { + return editor.canvas + .locator( '[data-type="core/table"] tbody tr' ) + .evaluateAll( ( rows ) => + rows.map( ( row ) => + Array.from( row.querySelectorAll( 'td, th' ) ).map( ( cell ) => + ( cell.textContent || '' ).trim() + ) + ) + ); +} + +async function expectVisibleTables( + editorA: Editor, + editorB: Editor, + expected: string[][] +) { + await expect + .poll( () => getVisibleTable( editorA ), { timeout: 15_000 } ) + .toEqual( expected ); + await expect + .poll( () => getVisibleTable( editorB ), { timeout: 15_000 } ) + .toEqual( expected ); +} + +function expectSavedTableContent( content: string ) { + expect( content.match( //g ) ).toHaveLength( 2 ); + expect( content ).toContain( 'anchor' ); + expect( content ).toContain( 'edited-duplicateextra' ); + expect( content ).not.toContain( 'same' ); +} + +function tableContent( rows: string[][] ) { + const body = rows + .map( + ( row ) => + `${ row + .map( ( cell ) => `${ cell }` ) + .join( '' ) }` + ) + .join( '' ); + + return `\n
${ body }
\n`; +} + +test.describe( 'Collaboration - WebSocket table follow-ups', () => { + // eslint-disable-next-line playwright/expect-expect + test( 'preserves a remote-edited duplicate table row when another user deletes the earlier duplicate row', async ( { + collaborationUtils, + editor, + page, + requestUtils, + } ) => { + test.setTimeout( 90_000 ); + + await requestUtils.createUser( COLLABORATOR ); + const post = await requestUtils.createPost( { + title: 'RTC table duplicate row follow-up', + content: tableContent( [ [ 'anchor' ], [ 'same' ] ] ), + status: 'draft', + date_gmt: new Date().toISOString(), + } ); + + await collaborationUtils.openPost( post.id ); + const { editor: editor2, page: page2 } = + await collaborationUtils.joinUser( post.id, COLLABORATOR ); + await collaborationUtils.waitForMutualDiscovery(); + await expectVisibleTables( editor, editor2, [ + [ 'anchor' ], + [ 'same' ], + ] ); + + await ( await tableCells( editor ) ).nth( 1 ).click(); + await clickTableMenuItem( editor, page, 'Insert row after' ); + await typeCell( editor, page, 2, 'same' ); + await collaborationUtils.waitForMutualDiscovery(); + await expectVisibleTables( editor, editor2, [ + [ 'anchor' ], + [ 'same' ], + [ 'same' ], + ] ); + + await ( await tableCells( editor ) ).nth( 1 ).click(); + await typeCell( editor2, page2, 2, 'edited-duplicate' ); + await ( await tableCells( editor2 ) ).nth( 2 ).click(); + await clickTableMenuItem( editor2, page2, 'Insert column after' ); + await typeCell( editor2, page2, 5, 'extra' ); + await clickTableMenuItem( editor, page, 'Delete row' ); + await collaborationUtils.waitForMutualDiscovery(); + + await expectVisibleTables( editor, editor2, [ + [ 'anchor', '' ], + [ 'edited-duplicate', 'extra' ], + ] ); + + await editor2.saveDraft(); + const savedPost = await requestUtils.rest< { + content: { raw: string }; + } >( { + path: `/wp/v2/posts/${ post.id }`, + params: { context: 'edit' }, + } ); + expectSavedTableContent( savedPost.content.raw ); + + await page.reload( { waitUntil: 'load' } ); + await collaborationUtils.waitForEntityReadyAndSaveSettled( page ); + await page2.reload( { waitUntil: 'load' } ); + await collaborationUtils.waitForEntityReadyAndSaveSettled( page2 ); + + await expectVisibleTables( editor, editor2, [ + [ 'anchor', '' ], + [ 'edited-duplicate', 'extra' ], + ] ); + } ); +} ); From 1d55c5444db40834a329726d5fc4538d02992155 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Thu, 25 Jun 2026 15:54:46 -0700 Subject: [PATCH 2/4] fixup! RTC: Accumulated fixes from fuzz testing --- lib/compat/wordpress-7.1/collaboration.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/compat/wordpress-7.1/collaboration.php b/lib/compat/wordpress-7.1/collaboration.php index 07356cea482209..d49d037ef2e13f 100644 --- a/lib/compat/wordpress-7.1/collaboration.php +++ b/lib/compat/wordpress-7.1/collaboration.php @@ -264,10 +264,9 @@ function gutenberg_validate_persisted_crdt_document_base_version( int $post_id, * @param int $object_id Post ID. * @param string $meta_key Meta key. * @param mixed $meta_value Meta value. - * @param mixed $prev_value Previous meta value. * @return null|bool Whether to short-circuit the update. */ - function gutenberg_prevent_stale_crdt_document_meta_update( $check, int $object_id, string $meta_key, $meta_value, $prev_value ) { + function gutenberg_prevent_stale_crdt_document_meta_update( $check, int $object_id, string $meta_key, $meta_value ) { if ( null !== $check || '_crdt_document' !== $meta_key ) { return $check; } @@ -279,7 +278,7 @@ function gutenberg_prevent_stale_crdt_document_meta_update( $check, int $object_ return $check; } - add_filter( 'update_post_metadata', 'gutenberg_prevent_stale_crdt_document_meta_update', 10, 5 ); + add_filter( 'update_post_metadata', 'gutenberg_prevent_stale_crdt_document_meta_update', 10, 4 ); } if ( ! function_exists( 'gutenberg_prevent_stale_crdt_document_meta_add' ) ) { @@ -292,11 +291,9 @@ function gutenberg_prevent_stale_crdt_document_meta_update( $check, int $object_ * @param null|bool $check Whether to short-circuit the add. * @param int $object_id Post ID. * @param string $meta_key Meta key. - * @param mixed $meta_value Meta value. - * @param bool $unique Whether only one value may exist. * @return null|bool Whether to short-circuit the add. */ - function gutenberg_prevent_stale_crdt_document_meta_add( $check, int $object_id, string $meta_key, $meta_value, bool $unique ) { + function gutenberg_prevent_stale_crdt_document_meta_add( $check, int $object_id, string $meta_key ) { if ( null !== $check || '_crdt_document' !== $meta_key ) { return $check; } @@ -314,7 +311,7 @@ function gutenberg_prevent_stale_crdt_document_meta_add( $check, int $object_id, return $check; } - add_filter( 'add_post_metadata', 'gutenberg_prevent_stale_crdt_document_meta_add', 10, 5 ); + add_filter( 'add_post_metadata', 'gutenberg_prevent_stale_crdt_document_meta_add', 10, 3 ); } if ( ! function_exists( 'gutenberg_reject_stale_crdt_document_rest_update' ) ) { From 8f1c164948828188ecfbfa7720e436d56ce361c5 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Thu, 25 Jun 2026 16:16:11 -0700 Subject: [PATCH 3/4] fixup! RTC: Accumulated fixes from fuzz testing --- .../wordpress-7.1/class-wp-http-polling-sync-server.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/compat/wordpress-7.1/class-wp-http-polling-sync-server.php b/lib/compat/wordpress-7.1/class-wp-http-polling-sync-server.php index c25a193609e5c9..fb7a54e3289981 100644 --- a/lib/compat/wordpress-7.1/class-wp-http-polling-sync-server.php +++ b/lib/compat/wordpress-7.1/class-wp-http-polling-sync-server.php @@ -54,7 +54,7 @@ class WP_HTTP_Polling_Sync_Server { /** * Maximum target size (in bytes) of the response body. * - * @since 7.0.0 + * @since 7.1.0 * @var int */ const MAX_RESPONSE_BODY_SIZE = 16 * MB_IN_BYTES; @@ -70,7 +70,7 @@ class WP_HTTP_Polling_Sync_Server { /** * Maximum number of rooms allowed per request. * - * @since 7.1.0 + * @since 7.0.0 * @var int */ const MAX_ROOMS_PER_REQUEST = 50; From a3a196f92937ffd45b944b8c88361bb780d67224 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 25 Jun 2026 23:57:32 +0000 Subject: [PATCH 4/4] fixup! RTC: Accumulated fixes from fuzz testing --- packages/core-data/src/entities.js | 18 ++++++------- packages/core-data/src/utils/crdt-blocks.ts | 18 ++++--------- .../test/crdt-stale-top-level-blocks.test.ts | 27 ------------------- packages/sync/src/test/utils.ts | 3 +-- packages/sync/src/types.ts | 12 ++++----- tools/eslint/config.mjs | 9 +++++++ 6 files changed, 30 insertions(+), 57 deletions(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index d9cec08822a1ff..71f7f1053eb6e4 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -1023,15 +1023,15 @@ async function loadPostTypeEntities() { : DEFAULT_ENTITY_KEY, }; - /** - * @type {import('@wordpress/sync').SyncConfig} - */ - entity.syncConfig = { - // Save a CRDT document with this entity. - supportsPersistence: true, - - shouldSync: () => - ! window._wpCollaborationDisabledPostTypes?.includes( name ), + /** + * @type {import('@wordpress/sync').SyncConfig} + */ + entity.syncConfig = { + // Save a CRDT document with this entity. + supportsPersistence: true, + + shouldSync: () => + ! window._wpCollaborationDisabledPostTypes?.includes( name ), /** * Apply changes from the local editor to the local CRDT document so diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index beb3539b127b96..62219899abcc8e 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -945,15 +945,10 @@ function mergeBlockIntoYBlock( yblock.set( key, yInnerBlocks ); } - mergeCrdtBlocks( - yInnerBlocks, - value ?? [], - attributeCursor, - { - ...options, - baseBlocks: baseBlock?.innerBlocks, - } - ); + mergeCrdtBlocks( yInnerBlocks, value ?? [], attributeCursor, { + ...options, + baseBlocks: baseBlock?.innerBlocks, + } ); break; } @@ -962,10 +957,7 @@ function mergeBlockIntoYBlock( break; } - if ( - baseBlock && - fastDeepEqual( baseBlock.clientId, value ) - ) { + if ( baseBlock && fastDeepEqual( baseBlock.clientId, value ) ) { break; } diff --git a/packages/core-data/src/utils/test/crdt-stale-top-level-blocks.test.ts b/packages/core-data/src/utils/test/crdt-stale-top-level-blocks.test.ts index 621eafd35791ff..cfdf253a4e5654 100644 --- a/packages/core-data/src/utils/test/crdt-stale-top-level-blocks.test.ts +++ b/packages/core-data/src/utils/test/crdt-stale-top-level-blocks.test.ts @@ -493,33 +493,6 @@ describe( 'stale top-level block snapshots', () => { remoteDoc.destroy(); } ); - it( 'applies a local suffix append when the explicit base differs from current blocks', () => { - const baseBlocks = [ - paragraph( 'canonicalized', 'Alpha' ), - paragraph( 'unchanged', 'Beta' ), - ]; - const currentBlocks = [ - paragraph( 'canonicalized', 'Alpha canonicalized' ), - paragraph( 'unchanged', 'Beta' ), - ]; - const blocksWithLocalAppend = [ - ...baseBlocks, - paragraph( 'checkpoint-paragraph', 'Checkpoint paragraph' ), - paragraph( 'checkpoint-search', 'Checkpoint search' ), - ]; - - mergeCrdtBlocks( yblocks, currentBlocks, null ); - mergeCrdtBlocks( yblocks, blocksWithLocalAppend, null, baseBlocks ); - mergeCrdtBlocks( yblocks, blocksWithLocalAppend, null, baseBlocks ); - - expect( contentsOf( yblocks ) ).toEqual( [ - 'Alpha canonicalized', - 'Beta', - 'Checkpoint paragraph', - 'Checkpoint search', - ] ); - } ); - it( 'derives post content from merged blocks instead of stale serialized content', () => { const initialBlocks = [ paragraph( 'local-edited', 'Alpha' ), diff --git a/packages/sync/src/test/utils.ts b/packages/sync/src/test/utils.ts index ed1ca3a6c5d25e..c24c935f236bd5 100644 --- a/packages/sync/src/test/utils.ts +++ b/packages/sync/src/test/utils.ts @@ -166,8 +166,7 @@ describe( 'utils', () => { it( 'changes the version when the document changes', () => { const firstSerialized = serializeCrdtDoc( testDoc ); - const firstVersion = - getPersistedCrdtDocVersion( firstSerialized ); + const firstVersion = getPersistedCrdtDocVersion( firstSerialized ); testDoc.getMap( 'testMap' ).set( 'title', 'Changed Title' ); const secondSerialized = serializeCrdtDoc( testDoc ); diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 347b80b47a1f49..6a2b09fc7b92d1 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -159,12 +159,12 @@ export interface SyncConfig { editedRecord: ObjectData ) => ObjectData; getPersistedCRDTDoc?: ( record: ObjectData ) => string | null; - shouldSync?: ( - objectType: ObjectType, - objectId: ObjectID | null - ) => boolean; - supportsPersistence?: boolean; - } + shouldSync?: ( + objectType: ObjectType, + objectId: ObjectID | null + ) => boolean; + supportsPersistence?: boolean; +} export interface SyncManager { applyPersistedCRDTDoc: ( diff --git a/tools/eslint/config.mjs b/tools/eslint/config.mjs index 7abd88c7f084c0..24b5fd4cccc5c9 100644 --- a/tools/eslint/config.mjs +++ b/tools/eslint/config.mjs @@ -891,6 +891,15 @@ export default dedupePlugins( [ }, }, + // Override: CRDT document version hashing intentionally uses bitwise + // operators to produce stable 32-bit hash components. + { + files: [ 'packages/sync/src/utils.ts' ], + rules: { + 'no-bitwise': 'off', + }, + }, + // Override: typings — global type declarations require `var` and define // the globals that wp-global-usage warns about. {