From db2733970efb92359a6af38731ab04b0524b88c1 Mon Sep 17 00:00:00 2001 From: Gutenberg Repository Automation Date: Wed, 20 Aug 2025 18:27:11 +0000 Subject: [PATCH 01/99] Bump plugin version to 21.5.0-rc.1 --- gutenberg.php | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/gutenberg.php b/gutenberg.php index 199f231a386ac9..0dca34add104ac 100644 --- a/gutenberg.php +++ b/gutenberg.php @@ -5,7 +5,7 @@ * Description: Printing since 1440. This is the development plugin for the block editor, site editor, and other future WordPress core functionality. * Requires at least: 6.7 * Requires PHP: 7.2 - * Version: 21.4.0 + * Version: 21.5.0-rc.1 * Author: Gutenberg Team * Text Domain: gutenberg * diff --git a/package-lock.json b/package-lock.json index 2f4240b00ac51a..8a7734a4fec2b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gutenberg", - "version": "21.4.0", + "version": "21.5.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gutenberg", - "version": "21.4.0", + "version": "21.5.0-rc.1", "hasInstallScript": true, "license": "GPL-2.0-or-later", "workspaces": [ diff --git a/package.json b/package.json index 9b2bc94bc2da66..066d4a5d1fda1b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "21.4.0", + "version": "21.5.0-rc.1", "private": true, "description": "A new WordPress editor experience.", "author": "The WordPress Contributors", From f2c4cc01564e353278d4b7aeedb5b17ce4abfe0e Mon Sep 17 00:00:00 2001 From: Gutenberg Repository Automation Date: Wed, 20 Aug 2025 18:40:32 +0000 Subject: [PATCH 02/99] Update changelog files --- packages/a11y/CHANGELOG.md | 2 ++ packages/a11y/package.json | 2 +- packages/annotations/CHANGELOG.md | 2 ++ packages/annotations/package.json | 2 +- packages/api-fetch/CHANGELOG.md | 2 ++ packages/api-fetch/package.json | 2 +- packages/autop/CHANGELOG.md | 2 ++ packages/autop/package.json | 2 +- packages/babel-plugin-import-jsx-pragma/CHANGELOG.md | 2 ++ packages/babel-plugin-import-jsx-pragma/package.json | 2 +- packages/babel-plugin-makepot/CHANGELOG.md | 2 ++ packages/babel-plugin-makepot/package.json | 2 +- packages/babel-preset-default/CHANGELOG.md | 2 ++ packages/babel-preset-default/package.json | 2 +- packages/base-styles/CHANGELOG.md | 2 ++ packages/base-styles/package.json | 2 +- packages/blob/CHANGELOG.md | 2 ++ packages/blob/package.json | 2 +- packages/block-directory/CHANGELOG.md | 2 ++ packages/block-directory/package.json | 2 +- packages/block-editor/CHANGELOG.md | 2 ++ packages/block-editor/package.json | 2 +- packages/block-library/CHANGELOG.md | 2 ++ packages/block-library/package.json | 2 +- packages/block-serialization-default-parser/CHANGELOG.md | 2 ++ packages/block-serialization-default-parser/package.json | 2 +- packages/block-serialization-spec-parser/CHANGELOG.md | 2 ++ packages/block-serialization-spec-parser/package.json | 2 +- packages/blocks/CHANGELOG.md | 2 ++ packages/blocks/package.json | 2 +- packages/browserslist-config/CHANGELOG.md | 2 ++ packages/browserslist-config/package.json | 2 +- packages/commands/CHANGELOG.md | 2 ++ packages/commands/package.json | 2 +- packages/components/CHANGELOG.md | 2 ++ packages/components/package.json | 2 +- packages/compose/CHANGELOG.md | 2 ++ packages/compose/package.json | 2 +- packages/core-commands/CHANGELOG.md | 2 ++ packages/core-commands/package.json | 2 +- packages/core-data/CHANGELOG.md | 2 ++ packages/core-data/package.json | 2 +- packages/create-block-interactive-template/package.json | 2 +- packages/create-block-tutorial-template/CHANGELOG.md | 2 ++ packages/create-block-tutorial-template/package.json | 2 +- packages/create-block/CHANGELOG.md | 2 ++ packages/create-block/package.json | 2 +- packages/customize-widgets/CHANGELOG.md | 2 ++ packages/customize-widgets/package.json | 2 +- packages/data-controls/CHANGELOG.md | 2 ++ packages/data-controls/package.json | 2 +- packages/data/CHANGELOG.md | 2 ++ packages/data/package.json | 2 +- packages/dataviews/CHANGELOG.md | 2 ++ packages/dataviews/package.json | 2 +- packages/date/CHANGELOG.md | 2 ++ packages/date/package.json | 2 +- packages/dependency-extraction-webpack-plugin/CHANGELOG.md | 2 ++ packages/dependency-extraction-webpack-plugin/package.json | 2 +- packages/deprecated/CHANGELOG.md | 2 ++ packages/deprecated/package.json | 2 +- packages/docgen/CHANGELOG.md | 2 ++ packages/docgen/package.json | 2 +- packages/dom-ready/CHANGELOG.md | 2 ++ packages/dom-ready/package.json | 2 +- packages/dom/CHANGELOG.md | 2 ++ packages/dom/package.json | 2 +- packages/e2e-test-utils-playwright/CHANGELOG.md | 2 ++ packages/e2e-test-utils-playwright/package.json | 2 +- packages/e2e-test-utils/CHANGELOG.md | 2 ++ packages/e2e-test-utils/package.json | 2 +- packages/e2e-tests/CHANGELOG.md | 2 ++ packages/e2e-tests/package.json | 2 +- packages/edit-post/CHANGELOG.md | 2 ++ packages/edit-post/package.json | 2 +- packages/edit-site/CHANGELOG.md | 2 ++ packages/edit-site/package.json | 2 +- packages/edit-widgets/CHANGELOG.md | 2 ++ packages/edit-widgets/package.json | 2 +- packages/editor/CHANGELOG.md | 2 ++ packages/editor/package.json | 2 +- packages/element/CHANGELOG.md | 2 ++ packages/element/package.json | 2 +- packages/env/CHANGELOG.md | 2 ++ packages/env/package.json | 2 +- packages/escape-html/CHANGELOG.md | 2 ++ packages/escape-html/package.json | 2 +- packages/eslint-plugin/CHANGELOG.md | 2 ++ packages/eslint-plugin/package.json | 2 +- packages/fields/CHANGELOG.md | 2 ++ packages/fields/package.json | 2 +- packages/format-library/CHANGELOG.md | 2 ++ packages/format-library/package.json | 2 +- packages/hooks/CHANGELOG.md | 2 ++ packages/hooks/package.json | 2 +- packages/html-entities/CHANGELOG.md | 2 ++ packages/html-entities/package.json | 2 +- packages/i18n/CHANGELOG.md | 2 ++ packages/i18n/package.json | 2 +- packages/icons/CHANGELOG.md | 2 ++ packages/icons/package.json | 2 +- packages/interactivity-router/CHANGELOG.md | 2 ++ packages/interactivity-router/package.json | 2 +- packages/interactivity/CHANGELOG.md | 2 ++ packages/interactivity/package.json | 2 +- packages/interface/CHANGELOG.md | 2 ++ packages/interface/package.json | 2 +- packages/is-shallow-equal/CHANGELOG.md | 2 ++ packages/is-shallow-equal/package.json | 2 +- packages/jest-console/CHANGELOG.md | 2 ++ packages/jest-console/package.json | 2 +- packages/jest-preset-default/CHANGELOG.md | 2 ++ packages/jest-preset-default/package.json | 2 +- packages/jest-puppeteer-axe/CHANGELOG.md | 2 ++ packages/jest-puppeteer-axe/package.json | 2 +- packages/keyboard-shortcuts/CHANGELOG.md | 2 ++ packages/keyboard-shortcuts/package.json | 2 +- packages/keycodes/CHANGELOG.md | 2 ++ packages/keycodes/package.json | 2 +- packages/lazy-import/CHANGELOG.md | 2 ++ packages/lazy-import/package.json | 2 +- packages/list-reusable-blocks/CHANGELOG.md | 2 ++ packages/list-reusable-blocks/package.json | 2 +- packages/media-utils/CHANGELOG.md | 2 ++ packages/media-utils/package.json | 2 +- packages/notices/CHANGELOG.md | 2 ++ packages/notices/package.json | 2 +- packages/npm-package-json-lint-config/CHANGELOG.md | 2 ++ packages/npm-package-json-lint-config/package.json | 2 +- packages/nux/CHANGELOG.md | 2 ++ packages/nux/package.json | 2 +- packages/patterns/CHANGELOG.md | 2 ++ packages/patterns/package.json | 2 +- packages/plugins/CHANGELOG.md | 2 ++ packages/plugins/package.json | 2 +- packages/postcss-plugins-preset/CHANGELOG.md | 2 ++ packages/postcss-plugins-preset/package.json | 2 +- packages/postcss-themes/CHANGELOG.md | 2 ++ packages/postcss-themes/package.json | 2 +- packages/preferences-persistence/CHANGELOG.md | 2 ++ packages/preferences-persistence/package.json | 2 +- packages/preferences/CHANGELOG.md | 2 ++ packages/preferences/package.json | 2 +- packages/prettier-config/CHANGELOG.md | 2 ++ packages/prettier-config/package.json | 2 +- packages/primitives/CHANGELOG.md | 2 ++ packages/primitives/package.json | 2 +- packages/priority-queue/CHANGELOG.md | 2 ++ packages/priority-queue/package.json | 2 +- packages/private-apis/CHANGELOG.md | 2 ++ packages/private-apis/package.json | 2 +- packages/project-management-automation/CHANGELOG.md | 2 ++ packages/project-management-automation/package.json | 2 +- packages/react-i18n/CHANGELOG.md | 2 ++ packages/react-i18n/package.json | 2 +- packages/readable-js-assets-webpack-plugin/CHANGELOG.md | 2 ++ packages/readable-js-assets-webpack-plugin/package.json | 2 +- packages/redux-routine/CHANGELOG.md | 2 ++ packages/redux-routine/package.json | 2 +- packages/reusable-blocks/CHANGELOG.md | 2 ++ packages/reusable-blocks/package.json | 2 +- packages/rich-text/CHANGELOG.md | 2 ++ packages/rich-text/package.json | 2 +- packages/router/CHANGELOG.md | 2 ++ packages/router/package.json | 2 +- packages/scripts/CHANGELOG.md | 2 ++ packages/scripts/package.json | 2 +- packages/server-side-render/CHANGELOG.md | 2 ++ packages/server-side-render/package.json | 2 +- packages/shortcode/CHANGELOG.md | 2 ++ packages/shortcode/package.json | 2 +- packages/style-engine/CHANGELOG.md | 2 ++ packages/style-engine/package.json | 2 +- packages/stylelint-config/CHANGELOG.md | 2 ++ packages/stylelint-config/package.json | 2 +- packages/sync/CHANGELOG.md | 2 ++ packages/sync/package.json | 2 +- packages/token-list/CHANGELOG.md | 2 ++ packages/token-list/package.json | 2 +- packages/undo-manager/CHANGELOG.md | 2 ++ packages/undo-manager/package.json | 2 +- packages/upload-media/CHANGELOG.md | 2 ++ packages/upload-media/package.json | 2 +- packages/url/CHANGELOG.md | 2 ++ packages/url/package.json | 2 +- packages/viewport/CHANGELOG.md | 2 ++ packages/viewport/package.json | 2 +- packages/warning/CHANGELOG.md | 2 ++ packages/warning/package.json | 2 +- packages/widgets/CHANGELOG.md | 2 ++ packages/widgets/package.json | 2 +- packages/wordcount/CHANGELOG.md | 2 ++ packages/wordcount/package.json | 2 +- 193 files changed, 289 insertions(+), 97 deletions(-) diff --git a/packages/a11y/CHANGELOG.md b/packages/a11y/CHANGELOG.md index 915a9bd1873177..539d99cd9e4aed 100644 --- a/packages/a11y/CHANGELOG.md +++ b/packages/a11y/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.29.0 (2025-08-20) + ## 4.28.0 (2025-08-07) ## 4.27.0 (2025-07-23) diff --git a/packages/a11y/package.json b/packages/a11y/package.json index d62e4a3a39e770..e46c43303cfd7a 100644 --- a/packages/a11y/package.json +++ b/packages/a11y/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/a11y", - "version": "4.28.0", + "version": "4.29.0-prerelease", "description": "Accessibility (a11y) utilities for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/annotations/CHANGELOG.md b/packages/annotations/CHANGELOG.md index a5f118a7060e6f..6ecb7495bf8c55 100644 --- a/packages/annotations/CHANGELOG.md +++ b/packages/annotations/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 3.29.0 (2025-08-20) + ## 3.28.0 (2025-08-07) ## 3.27.0 (2025-07-23) diff --git a/packages/annotations/package.json b/packages/annotations/package.json index 2bf8f24a2b3527..bcf21f5f2194e1 100644 --- a/packages/annotations/package.json +++ b/packages/annotations/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/annotations", - "version": "3.28.0", + "version": "3.29.0-prerelease", "description": "Annotate content in the Gutenberg editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/api-fetch/CHANGELOG.md b/packages/api-fetch/CHANGELOG.md index 2cade65224252c..09e354d98f3b95 100644 --- a/packages/api-fetch/CHANGELOG.md +++ b/packages/api-fetch/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 7.29.0 (2025-08-20) + ## 7.28.0 (2025-08-07) ## 7.27.0 (2025-07-23) diff --git a/packages/api-fetch/package.json b/packages/api-fetch/package.json index d339d7ba8c021b..6669cfc4225498 100644 --- a/packages/api-fetch/package.json +++ b/packages/api-fetch/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/api-fetch", - "version": "7.28.0", + "version": "7.29.0-prerelease", "description": "Utility to make WordPress REST API requests.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/autop/CHANGELOG.md b/packages/autop/CHANGELOG.md index 5a13aaf433de7a..2a1e1563054287 100644 --- a/packages/autop/CHANGELOG.md +++ b/packages/autop/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.29.0 (2025-08-20) + ## 4.28.0 (2025-08-07) ## 4.27.0 (2025-07-23) diff --git a/packages/autop/package.json b/packages/autop/package.json index 72d03f76762e07..e780c64f5b089e 100644 --- a/packages/autop/package.json +++ b/packages/autop/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/autop", - "version": "4.28.0", + "version": "4.29.0-prerelease", "description": "WordPress's automatic paragraph functions `autop` and `removep`.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/babel-plugin-import-jsx-pragma/CHANGELOG.md b/packages/babel-plugin-import-jsx-pragma/CHANGELOG.md index 491ec994a086ef..9fbbce1a46673e 100644 --- a/packages/babel-plugin-import-jsx-pragma/CHANGELOG.md +++ b/packages/babel-plugin-import-jsx-pragma/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.29.0 (2025-08-20) + ## 5.28.0 (2025-08-07) ## 5.27.0 (2025-07-23) diff --git a/packages/babel-plugin-import-jsx-pragma/package.json b/packages/babel-plugin-import-jsx-pragma/package.json index 9db92bab6f5665..08289070e404e0 100644 --- a/packages/babel-plugin-import-jsx-pragma/package.json +++ b/packages/babel-plugin-import-jsx-pragma/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/babel-plugin-import-jsx-pragma", - "version": "5.28.0", + "version": "5.29.0-prerelease", "description": "Babel transform plugin for automatically injecting an import to be used as the pragma for the React JSX Transform plugin.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/babel-plugin-makepot/CHANGELOG.md b/packages/babel-plugin-makepot/CHANGELOG.md index a99472a4a0ee94..6721a0d8b89229 100644 --- a/packages/babel-plugin-makepot/CHANGELOG.md +++ b/packages/babel-plugin-makepot/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.29.0 (2025-08-20) + ## 6.28.0 (2025-08-07) ## 6.27.0 (2025-07-23) diff --git a/packages/babel-plugin-makepot/package.json b/packages/babel-plugin-makepot/package.json index 038d39167cf58f..271bc40609faa9 100644 --- a/packages/babel-plugin-makepot/package.json +++ b/packages/babel-plugin-makepot/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/babel-plugin-makepot", - "version": "6.28.0", + "version": "6.29.0-prerelease", "description": "WordPress Babel internationalization (i18n) plugin.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/babel-preset-default/CHANGELOG.md b/packages/babel-preset-default/CHANGELOG.md index 8ec65474ae68e6..81943ca09aafb7 100644 --- a/packages/babel-preset-default/CHANGELOG.md +++ b/packages/babel-preset-default/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 8.29.0 (2025-08-20) + ## 8.28.0 (2025-08-07) ## 8.27.0 (2025-07-23) diff --git a/packages/babel-preset-default/package.json b/packages/babel-preset-default/package.json index aabdc00ceaf23d..525f60debe1f88 100644 --- a/packages/babel-preset-default/package.json +++ b/packages/babel-preset-default/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/babel-preset-default", - "version": "8.28.0", + "version": "8.29.0-prerelease", "description": "Default Babel preset for WordPress development.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/base-styles/CHANGELOG.md b/packages/base-styles/CHANGELOG.md index 02399f3542a844..2e1c9be517bcb1 100644 --- a/packages/base-styles/CHANGELOG.md +++ b/packages/base-styles/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.5.0 (2025-08-20) + ## 6.4.0 (2025-08-07) ## 6.3.0 (2025-07-23) diff --git a/packages/base-styles/package.json b/packages/base-styles/package.json index 3501be7a758857..117641004cae4f 100644 --- a/packages/base-styles/package.json +++ b/packages/base-styles/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/base-styles", - "version": "6.4.0", + "version": "6.5.0-prerelease", "description": "Base SCSS utilities and variables for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/blob/CHANGELOG.md b/packages/blob/CHANGELOG.md index 5a144a067ea392..b8ec8c351b0fd6 100644 --- a/packages/blob/CHANGELOG.md +++ b/packages/blob/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.29.0 (2025-08-20) + ## 4.28.0 (2025-08-07) ## 4.27.0 (2025-07-23) diff --git a/packages/blob/package.json b/packages/blob/package.json index 6717a49f0ed372..5c0480ee24a306 100644 --- a/packages/blob/package.json +++ b/packages/blob/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/blob", - "version": "4.28.0", + "version": "4.29.0-prerelease", "description": "Blob utilities for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/block-directory/CHANGELOG.md b/packages/block-directory/CHANGELOG.md index 33dcf97d1a45d7..9130b7e5009b1d 100644 --- a/packages/block-directory/CHANGELOG.md +++ b/packages/block-directory/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.29.0 (2025-08-20) + ## 5.28.0 (2025-08-07) ## 5.27.0 (2025-07-23) diff --git a/packages/block-directory/package.json b/packages/block-directory/package.json index e56eeec65751cc..f81e5af5b152dd 100644 --- a/packages/block-directory/package.json +++ b/packages/block-directory/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-directory", - "version": "5.28.0", + "version": "5.29.0-prerelease", "description": "Extend editor with block directory features to search, download and install blocks.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md index 671d6ae9f31515..5b9ab37f8f8f40 100644 --- a/packages/block-editor/CHANGELOG.md +++ b/packages/block-editor/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 15.2.0 (2025-08-20) + ## 15.1.0 (2025-08-07) ## 15.0.0 (2025-07-23) diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index 83aea7f85d7cb2..13e969a9033da0 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-editor", - "version": "15.1.0", + "version": "15.2.0-prerelease", "description": "Generic block editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/block-library/CHANGELOG.md b/packages/block-library/CHANGELOG.md index 5c9478e55873ea..ef97e364c8f57e 100644 --- a/packages/block-library/CHANGELOG.md +++ b/packages/block-library/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 9.29.0 (2025-08-20) + ## 9.28.0 (2025-08-07) ## 9.27.0 (2025-07-23) diff --git a/packages/block-library/package.json b/packages/block-library/package.json index 6d000dd591c0f9..76c34d230d3c72 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-library", - "version": "9.28.0", + "version": "9.29.0-prerelease", "description": "Block library for the WordPress editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/block-serialization-default-parser/CHANGELOG.md b/packages/block-serialization-default-parser/CHANGELOG.md index 608db3c7c805f9..c3bf6f425911f5 100644 --- a/packages/block-serialization-default-parser/CHANGELOG.md +++ b/packages/block-serialization-default-parser/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.29.0 (2025-08-20) + ## 5.28.0 (2025-08-07) ## 5.27.0 (2025-07-23) diff --git a/packages/block-serialization-default-parser/package.json b/packages/block-serialization-default-parser/package.json index 7e07f9d1dc55eb..5b9e1dc56cce90 100644 --- a/packages/block-serialization-default-parser/package.json +++ b/packages/block-serialization-default-parser/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-serialization-default-parser", - "version": "5.28.0", + "version": "5.29.0-prerelease", "description": "Block serialization specification parser for WordPress posts.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/block-serialization-spec-parser/CHANGELOG.md b/packages/block-serialization-spec-parser/CHANGELOG.md index 415092da4ac373..5244008d495a28 100644 --- a/packages/block-serialization-spec-parser/CHANGELOG.md +++ b/packages/block-serialization-spec-parser/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.29.0 (2025-08-20) + ## 5.28.0 (2025-08-07) ## 5.27.0 (2025-07-23) diff --git a/packages/block-serialization-spec-parser/package.json b/packages/block-serialization-spec-parser/package.json index e8ede54de5daa9..fc6dd14e7b4d24 100644 --- a/packages/block-serialization-spec-parser/package.json +++ b/packages/block-serialization-spec-parser/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-serialization-spec-parser", - "version": "5.28.0", + "version": "5.29.0-prerelease", "description": "Block serialization specification parser for WordPress posts.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/blocks/CHANGELOG.md b/packages/blocks/CHANGELOG.md index 95accaae8d2f02..e172c13566d110 100644 --- a/packages/blocks/CHANGELOG.md +++ b/packages/blocks/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 15.2.0 (2025-08-20) + ## 15.1.0 (2025-08-07) ## 15.0.0 (2025-07-23) diff --git a/packages/blocks/package.json b/packages/blocks/package.json index a9f493796d7905..e887d31cf085ae 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/blocks", - "version": "15.1.0", + "version": "15.2.0-prerelease", "description": "Block API for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/browserslist-config/CHANGELOG.md b/packages/browserslist-config/CHANGELOG.md index f66c08acb38625..a121d662a926a9 100644 --- a/packages/browserslist-config/CHANGELOG.md +++ b/packages/browserslist-config/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.29.0 (2025-08-20) + ## 6.28.0 (2025-08-07) ## 6.27.0 (2025-07-23) diff --git a/packages/browserslist-config/package.json b/packages/browserslist-config/package.json index 447a211b0aed2d..ea3c07ce5e2574 100644 --- a/packages/browserslist-config/package.json +++ b/packages/browserslist-config/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/browserslist-config", - "version": "6.28.0", + "version": "6.29.0-prerelease", "description": "WordPress Browserslist shared configuration.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/commands/CHANGELOG.md b/packages/commands/CHANGELOG.md index 1f4b043f315e6a..50ff1810f71cc1 100644 --- a/packages/commands/CHANGELOG.md +++ b/packages/commands/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 1.29.0 (2025-08-20) + ## 1.28.0 (2025-08-07) ## 1.27.0 (2025-07-23) diff --git a/packages/commands/package.json b/packages/commands/package.json index e63e9947bacdce..9872fa0abf68d1 100644 --- a/packages/commands/package.json +++ b/packages/commands/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/commands", - "version": "1.28.0", + "version": "1.29.0-prerelease", "description": "Handles the commands menu.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 49abeb6edb37e7..17d65b4453b656 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 30.2.0 (2025-08-20) + ### Bug fixes - `MenuItem`: make accessible when disabled ([#71251](https://github.com/WordPress/gutenberg/pull/71251)). diff --git a/packages/components/package.json b/packages/components/package.json index 9d294704f1d21a..fa288d2974df92 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/components", - "version": "30.1.0", + "version": "30.2.0-prerelease", "description": "UI components for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/compose/CHANGELOG.md b/packages/compose/CHANGELOG.md index 85e1e0d3155c29..5d16117e27ba6c 100644 --- a/packages/compose/CHANGELOG.md +++ b/packages/compose/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 7.29.0 (2025-08-20) + ## 7.28.0 (2025-08-07) ## 7.27.0 (2025-07-23) diff --git a/packages/compose/package.json b/packages/compose/package.json index 5ae3655a03062d..1e2fc150ebbf88 100644 --- a/packages/compose/package.json +++ b/packages/compose/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/compose", - "version": "7.28.0", + "version": "7.29.0-prerelease", "description": "WordPress higher-order components (HOCs).", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/core-commands/CHANGELOG.md b/packages/core-commands/CHANGELOG.md index 7a8642cb34635f..6e8f1ca9fa7d0a 100644 --- a/packages/core-commands/CHANGELOG.md +++ b/packages/core-commands/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 1.29.0 (2025-08-20) + ## 1.28.0 (2025-08-07) ## 1.27.0 (2025-07-23) diff --git a/packages/core-commands/package.json b/packages/core-commands/package.json index c988495d791e67..f516cc62f0acce 100644 --- a/packages/core-commands/package.json +++ b/packages/core-commands/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/core-commands", - "version": "1.28.0", + "version": "1.29.0-prerelease", "description": "WordPress core reusable commands.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/core-data/CHANGELOG.md b/packages/core-data/CHANGELOG.md index d7c9478f521cd3..a8af097556809e 100644 --- a/packages/core-data/CHANGELOG.md +++ b/packages/core-data/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 7.29.0 (2025-08-20) + ## 7.28.0 (2025-08-07) ## 7.27.0 (2025-07-23) diff --git a/packages/core-data/package.json b/packages/core-data/package.json index 6502edad3afaea..5fe5f8681d5b05 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/core-data", - "version": "7.28.0", + "version": "7.29.0-prerelease", "description": "Access to and manipulation of core WordPress entities.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/create-block-interactive-template/package.json b/packages/create-block-interactive-template/package.json index fea74f83c4bd39..d84e8152d27c6c 100644 --- a/packages/create-block-interactive-template/package.json +++ b/packages/create-block-interactive-template/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/create-block-interactive-template", - "version": "2.28.0", + "version": "2.29.0-prerelease", "description": "Template for @wordpress/create-block to create interactive blocks with the Interactivity API.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/create-block-tutorial-template/CHANGELOG.md b/packages/create-block-tutorial-template/CHANGELOG.md index 10e87667454a2a..e94cecd70f3fa2 100644 --- a/packages/create-block-tutorial-template/CHANGELOG.md +++ b/packages/create-block-tutorial-template/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.29.0 (2025-08-20) + ## 4.28.0 (2025-08-07) ## 4.27.0 (2025-07-23) diff --git a/packages/create-block-tutorial-template/package.json b/packages/create-block-tutorial-template/package.json index 699e1e4acd1896..c1d706f67dc31b 100644 --- a/packages/create-block-tutorial-template/package.json +++ b/packages/create-block-tutorial-template/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/create-block-tutorial-template", - "version": "4.28.0", + "version": "4.29.0-prerelease", "description": "This is a template for @wordpress/create-block that creates an example 'Copyright Date' block. This block is used in the official WordPress block development Quick Start Guide.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/create-block/CHANGELOG.md b/packages/create-block/CHANGELOG.md index 3443b1cc29b3ba..dd5c1e0fd576d8 100644 --- a/packages/create-block/CHANGELOG.md +++ b/packages/create-block/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.72.0 (2025-08-20) + ## 4.71.0 (2025-08-07) - Add support for template variants to define their own template files ([#70855](https://github.com/WordPress/gutenberg/pull/70855)) diff --git a/packages/create-block/package.json b/packages/create-block/package.json index af0d31b683214d..babcb99588faeb 100644 --- a/packages/create-block/package.json +++ b/packages/create-block/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/create-block", - "version": "4.71.0", + "version": "4.72.0-prerelease", "description": "Generates PHP, JS and CSS code for registering a block for a WordPress plugin.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/customize-widgets/CHANGELOG.md b/packages/customize-widgets/CHANGELOG.md index e34d422fa28a95..ff0e5fc106cb78 100644 --- a/packages/customize-widgets/CHANGELOG.md +++ b/packages/customize-widgets/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.29.0 (2025-08-20) + ## 5.28.0 (2025-08-07) ## 5.27.0 (2025-07-23) diff --git a/packages/customize-widgets/package.json b/packages/customize-widgets/package.json index daacab3df31981..aac6db4df0306e 100644 --- a/packages/customize-widgets/package.json +++ b/packages/customize-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/customize-widgets", - "version": "5.28.0", + "version": "5.29.0-prerelease", "description": "Widgets blocks in Customizer Module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/data-controls/CHANGELOG.md b/packages/data-controls/CHANGELOG.md index 1f52eef46ed392..000da4dc424ebf 100644 --- a/packages/data-controls/CHANGELOG.md +++ b/packages/data-controls/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.29.0 (2025-08-20) + ## 4.28.0 (2025-08-07) ## 4.27.0 (2025-07-23) diff --git a/packages/data-controls/package.json b/packages/data-controls/package.json index 3b78e4ccf56f4e..1a1ffcc6519116 100644 --- a/packages/data-controls/package.json +++ b/packages/data-controls/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/data-controls", - "version": "4.28.0", + "version": "4.29.0-prerelease", "description": "A set of common controls for the @wordpress/data api.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/data/CHANGELOG.md b/packages/data/CHANGELOG.md index e86d2bf33ca786..b5b014c9649d76 100644 --- a/packages/data/CHANGELOG.md +++ b/packages/data/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 10.29.0 (2025-08-20) + ## 10.28.0 (2025-08-07) ## 10.27.0 (2025-07-23) diff --git a/packages/data/package.json b/packages/data/package.json index ba36e5419befd8..fe726b0045d407 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/data", - "version": "10.28.0", + "version": "10.29.0-prerelease", "description": "Data module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index f11f8137a47cb1..6437172e08b29c 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 7.0.0 (2025-08-20) + ### Breaking changes - DataForm: introduce a new `card` layout. The `form.type` has been moved under a new `layout` object and it is now `form.layout.type`, check the README for details. [#71100](https://github.com/WordPress/gutenberg/pull/71100) diff --git a/packages/dataviews/package.json b/packages/dataviews/package.json index d6001da4e8e34c..d2159cd124b81f 100644 --- a/packages/dataviews/package.json +++ b/packages/dataviews/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/dataviews", - "version": "6.0.0", + "version": "7.0.0-prerelease", "description": "DataViews is a component that provides an API to render datasets using different types of layouts (table, grid, list, etc.).", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/date/CHANGELOG.md b/packages/date/CHANGELOG.md index 02d885ffca41b1..e6067ffe7bab0e 100644 --- a/packages/date/CHANGELOG.md +++ b/packages/date/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.29.0 (2025-08-20) + ## 5.28.0 (2025-08-07) ## 5.27.0 (2025-07-23) diff --git a/packages/date/package.json b/packages/date/package.json index c3f2cc9f3a8895..55f6e058de442f 100644 --- a/packages/date/package.json +++ b/packages/date/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/date", - "version": "5.28.0", + "version": "5.29.0-prerelease", "description": "Date module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/dependency-extraction-webpack-plugin/CHANGELOG.md b/packages/dependency-extraction-webpack-plugin/CHANGELOG.md index f202a888389c31..68fa308e787323 100644 --- a/packages/dependency-extraction-webpack-plugin/CHANGELOG.md +++ b/packages/dependency-extraction-webpack-plugin/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.29.0 (2025-08-20) + ## 6.28.0 (2025-08-07) ## 6.27.0 (2025-07-23) diff --git a/packages/dependency-extraction-webpack-plugin/package.json b/packages/dependency-extraction-webpack-plugin/package.json index a29a305957aa37..6d15b2517dbb79 100644 --- a/packages/dependency-extraction-webpack-plugin/package.json +++ b/packages/dependency-extraction-webpack-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/dependency-extraction-webpack-plugin", - "version": "6.28.0", + "version": "6.29.0-prerelease", "description": "Extract WordPress script dependencies from webpack bundles.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/deprecated/CHANGELOG.md b/packages/deprecated/CHANGELOG.md index 353ef553c416a4..75318979cf4c55 100644 --- a/packages/deprecated/CHANGELOG.md +++ b/packages/deprecated/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.29.0 (2025-08-20) + ## 4.28.0 (2025-08-07) ## 4.27.0 (2025-07-23) diff --git a/packages/deprecated/package.json b/packages/deprecated/package.json index b2a5ca119fed02..72c5e345425e13 100644 --- a/packages/deprecated/package.json +++ b/packages/deprecated/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/deprecated", - "version": "4.28.0", + "version": "4.29.0-prerelease", "description": "Deprecation utility for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/docgen/CHANGELOG.md b/packages/docgen/CHANGELOG.md index 32db437a468b1f..52f175225c0ac5 100644 --- a/packages/docgen/CHANGELOG.md +++ b/packages/docgen/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 2.29.0 (2025-08-20) + ## 2.28.0 (2025-08-07) ## 2.27.0 (2025-07-23) diff --git a/packages/docgen/package.json b/packages/docgen/package.json index 6f0084a92a6e09..c3e04d79a2be30 100644 --- a/packages/docgen/package.json +++ b/packages/docgen/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/docgen", - "version": "2.28.0", + "version": "2.29.0-prerelease", "description": "Autogenerate public API documentation from exports and JSDoc comments.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/dom-ready/CHANGELOG.md b/packages/dom-ready/CHANGELOG.md index 315ba1eacc087f..9d22a91edc57f1 100644 --- a/packages/dom-ready/CHANGELOG.md +++ b/packages/dom-ready/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.29.0 (2025-08-20) + ## 4.28.0 (2025-08-07) ## 4.27.0 (2025-07-23) diff --git a/packages/dom-ready/package.json b/packages/dom-ready/package.json index 4a938bb0fb2926..452af4fbb444f1 100644 --- a/packages/dom-ready/package.json +++ b/packages/dom-ready/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/dom-ready", - "version": "4.28.0", + "version": "4.29.0-prerelease", "description": "Execute callback after the DOM is loaded.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/dom/CHANGELOG.md b/packages/dom/CHANGELOG.md index 09f33ae7522816..63aa4cc5e38d76 100644 --- a/packages/dom/CHANGELOG.md +++ b/packages/dom/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.29.0 (2025-08-20) + ## 4.28.0 (2025-08-07) ## 4.27.0 (2025-07-23) diff --git a/packages/dom/package.json b/packages/dom/package.json index 4eae1792ead758..de8e0bff73aff1 100644 --- a/packages/dom/package.json +++ b/packages/dom/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/dom", - "version": "4.28.0", + "version": "4.29.0-prerelease", "description": "DOM utilities module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/e2e-test-utils-playwright/CHANGELOG.md b/packages/e2e-test-utils-playwright/CHANGELOG.md index bc27201c7715aa..e9da95dcab7a2e 100644 --- a/packages/e2e-test-utils-playwright/CHANGELOG.md +++ b/packages/e2e-test-utils-playwright/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 1.29.0 (2025-08-20) + ## 1.28.0 (2025-08-07) ## 1.27.0 (2025-07-23) diff --git a/packages/e2e-test-utils-playwright/package.json b/packages/e2e-test-utils-playwright/package.json index f174f3e2c770ec..d6bd930960c141 100644 --- a/packages/e2e-test-utils-playwright/package.json +++ b/packages/e2e-test-utils-playwright/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/e2e-test-utils-playwright", - "version": "1.28.0", + "version": "1.29.0-prerelease", "description": "End-To-End (E2E) test utils for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/e2e-test-utils/CHANGELOG.md b/packages/e2e-test-utils/CHANGELOG.md index 9eb7211f9bc781..2c2986933e4620 100644 --- a/packages/e2e-test-utils/CHANGELOG.md +++ b/packages/e2e-test-utils/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 11.29.0 (2025-08-20) + ## 11.28.0 (2025-08-07) ## 11.27.0 (2025-07-23) diff --git a/packages/e2e-test-utils/package.json b/packages/e2e-test-utils/package.json index 88dc62ad096c63..e76a8c7033047c 100644 --- a/packages/e2e-test-utils/package.json +++ b/packages/e2e-test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/e2e-test-utils", - "version": "11.28.0", + "version": "11.29.0-prerelease", "description": "End-To-End (E2E) test utils for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/e2e-tests/CHANGELOG.md b/packages/e2e-tests/CHANGELOG.md index 296a45cf09b002..77723f7cd313d3 100644 --- a/packages/e2e-tests/CHANGELOG.md +++ b/packages/e2e-tests/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 8.29.0 (2025-08-20) + ## 8.28.0 (2025-08-07) ## 8.27.0 (2025-07-23) diff --git a/packages/e2e-tests/package.json b/packages/e2e-tests/package.json index b9cafbe95f8a85..d2415ca0ba64d6 100644 --- a/packages/e2e-tests/package.json +++ b/packages/e2e-tests/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/e2e-tests", - "version": "8.28.0", + "version": "8.29.0-prerelease", "description": "End-To-End (E2E) tests for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/edit-post/CHANGELOG.md b/packages/edit-post/CHANGELOG.md index ad8b51b4bdb6bf..a3a1a93b65093b 100644 --- a/packages/edit-post/CHANGELOG.md +++ b/packages/edit-post/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 8.29.0 (2025-08-20) + ## 8.28.0 (2025-08-07) ## 8.27.0 (2025-07-23) diff --git a/packages/edit-post/package.json b/packages/edit-post/package.json index 8ece02c12bfb73..991de0443b9f6d 100644 --- a/packages/edit-post/package.json +++ b/packages/edit-post/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/edit-post", - "version": "8.28.0", + "version": "8.29.0-prerelease", "description": "Edit Post module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/edit-site/CHANGELOG.md b/packages/edit-site/CHANGELOG.md index 6351acda835a12..9346b1580dd4de 100644 --- a/packages/edit-site/CHANGELOG.md +++ b/packages/edit-site/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.29.0 (2025-08-20) + ## 6.28.0 (2025-08-07) ## 6.27.0 (2025-07-23) diff --git a/packages/edit-site/package.json b/packages/edit-site/package.json index 9b66e8bfeb1667..31502ecd485ca7 100644 --- a/packages/edit-site/package.json +++ b/packages/edit-site/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/edit-site", - "version": "6.28.0", + "version": "6.29.0-prerelease", "description": "Edit Site Page module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/edit-widgets/CHANGELOG.md b/packages/edit-widgets/CHANGELOG.md index c596ac2fc4cab7..f0b77a1610f77b 100644 --- a/packages/edit-widgets/CHANGELOG.md +++ b/packages/edit-widgets/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.29.0 (2025-08-20) + ## 6.28.0 (2025-08-07) ## 6.27.0 (2025-07-23) diff --git a/packages/edit-widgets/package.json b/packages/edit-widgets/package.json index 6ccad0bacf94af..79d48310d6b7ec 100644 --- a/packages/edit-widgets/package.json +++ b/packages/edit-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/edit-widgets", - "version": "6.28.0", + "version": "6.29.0-prerelease", "description": "Widgets Page module for WordPress..", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 61916a3c4944eb..80b1da6e528a09 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 14.29.0 (2025-08-20) + ## 14.28.0 (2025-08-07) ## 14.27.0 (2025-07-23) diff --git a/packages/editor/package.json b/packages/editor/package.json index deaf3bacbe208d..db27cc2fa5cd6e 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/editor", - "version": "14.28.0", + "version": "14.29.0-prerelease", "description": "Enhanced block editor for WordPress posts.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/element/CHANGELOG.md b/packages/element/CHANGELOG.md index 663a7be5f0f1a0..e75e64cb0a3974 100644 --- a/packages/element/CHANGELOG.md +++ b/packages/element/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.29.0 (2025-08-20) + ## 6.28.0 (2025-08-07) ## 6.27.0 (2025-07-23) diff --git a/packages/element/package.json b/packages/element/package.json index 2fec956e154dcd..830775c2ce62a5 100644 --- a/packages/element/package.json +++ b/packages/element/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/element", - "version": "6.28.0", + "version": "6.29.0-prerelease", "description": "Element React module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/env/CHANGELOG.md b/packages/env/CHANGELOG.md index a5e6280b7fb076..ff8d0facfea1eb 100644 --- a/packages/env/CHANGELOG.md +++ b/packages/env/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 10.29.0 (2025-08-20) + ## 10.28.0 (2025-08-07) ## 10.27.0 (2025-07-23) diff --git a/packages/env/package.json b/packages/env/package.json index 6644e11f4c86d0..104f1127e4b046 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/env", - "version": "10.28.0", + "version": "10.29.0-prerelease", "description": "A zero-config, self contained local WordPress environment for development and testing.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/escape-html/CHANGELOG.md b/packages/escape-html/CHANGELOG.md index ecc2d6fb9a48f5..c0d1d9d4e57b96 100644 --- a/packages/escape-html/CHANGELOG.md +++ b/packages/escape-html/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 3.29.0 (2025-08-20) + ## 3.28.0 (2025-08-07) ## 3.27.0 (2025-07-23) diff --git a/packages/escape-html/package.json b/packages/escape-html/package.json index f49600011e02f9..fde4447df752ac 100644 --- a/packages/escape-html/package.json +++ b/packages/escape-html/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/escape-html", - "version": "3.28.0", + "version": "3.29.0-prerelease", "description": "Escape HTML utils.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/eslint-plugin/CHANGELOG.md b/packages/eslint-plugin/CHANGELOG.md index 5af6404554e9d4..2e47b2dbc58875 100644 --- a/packages/eslint-plugin/CHANGELOG.md +++ b/packages/eslint-plugin/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 22.15.0 (2025-08-20) + ## 22.14.0 (2025-08-07) ## 22.13.0 (2025-07-23) diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index 132f6d6b524282..44bc2c3a7eb7f0 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/eslint-plugin", - "version": "22.14.0", + "version": "22.15.0-prerelease", "description": "ESLint plugin for WordPress development.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/fields/CHANGELOG.md b/packages/fields/CHANGELOG.md index 07bb56f78c5cd8..b29a88b8bad714 100644 --- a/packages/fields/CHANGELOG.md +++ b/packages/fields/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.21.0 (2025-08-20) + ## 0.20.0 (2025-08-07) ## 0.19.0 (2025-07-23) diff --git a/packages/fields/package.json b/packages/fields/package.json index aeb9c36be07876..9683511defa357 100644 --- a/packages/fields/package.json +++ b/packages/fields/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/fields", - "version": "0.20.0", + "version": "0.21.0-prerelease", "description": "DataViews is a component that provides an API to render datasets using different types of layouts (table, grid, list, etc.).", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/format-library/CHANGELOG.md b/packages/format-library/CHANGELOG.md index 16d2a8521b4be4..98a61f562aa300 100644 --- a/packages/format-library/CHANGELOG.md +++ b/packages/format-library/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.29.0 (2025-08-20) + ## 5.28.0 (2025-08-07) ## 5.27.0 (2025-07-23) diff --git a/packages/format-library/package.json b/packages/format-library/package.json index 17e19e4140c836..f202f8d355de1f 100644 --- a/packages/format-library/package.json +++ b/packages/format-library/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/format-library", - "version": "5.28.0", + "version": "5.29.0-prerelease", "description": "Format library for the WordPress editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/hooks/CHANGELOG.md b/packages/hooks/CHANGELOG.md index 88ca1efe864c13..9b8fac20aa8d7b 100644 --- a/packages/hooks/CHANGELOG.md +++ b/packages/hooks/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.29.0 (2025-08-20) + ## 4.28.0 (2025-08-07) ## 4.27.0 (2025-07-23) diff --git a/packages/hooks/package.json b/packages/hooks/package.json index 29b6cd0d061c45..a638899627f587 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/hooks", - "version": "4.28.0", + "version": "4.29.0-prerelease", "description": "WordPress hooks library.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/html-entities/CHANGELOG.md b/packages/html-entities/CHANGELOG.md index 07e38ee2070a42..2091aa6650ab52 100644 --- a/packages/html-entities/CHANGELOG.md +++ b/packages/html-entities/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.29.0 (2025-08-20) + ## 4.28.0 (2025-08-07) ## 4.27.0 (2025-07-23) diff --git a/packages/html-entities/package.json b/packages/html-entities/package.json index 583fbe915f43dd..77ff78c6c68c8e 100644 --- a/packages/html-entities/package.json +++ b/packages/html-entities/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/html-entities", - "version": "4.28.0", + "version": "4.29.0-prerelease", "description": "HTML entity utilities for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/i18n/CHANGELOG.md b/packages/i18n/CHANGELOG.md index a1823261e680be..3b209a921fad97 100644 --- a/packages/i18n/CHANGELOG.md +++ b/packages/i18n/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.2.0 (2025-08-20) + ## 6.1.0 (2025-08-07) ## 6.0.0 (2025-07-23) diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 6c3c68ad4681c8..8fe5e9e34410a0 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/i18n", - "version": "6.1.0", + "version": "6.2.0-prerelease", "description": "WordPress internationalization (i18n) library.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/icons/CHANGELOG.md b/packages/icons/CHANGELOG.md index d1a8338fa97999..3d1ef998c8f1fd 100644 --- a/packages/icons/CHANGELOG.md +++ b/packages/icons/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 10.29.0 (2025-08-20) + - Breaking change: deprecate `moreHorizontalMobile` icon. This was added in error, and `moreHorizontal` should be used instead. ([#71172](https://github.com/WordPress/gutenberg/pull/71172)) ## 10.28.0 (2025-08-07) diff --git a/packages/icons/package.json b/packages/icons/package.json index 6954f5cc255a58..b15ee186cefad4 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/icons", - "version": "10.28.0", + "version": "10.29.0-prerelease", "description": "WordPress Icons package, based on dashicon.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/interactivity-router/CHANGELOG.md b/packages/interactivity-router/CHANGELOG.md index 464312dca31a68..f5c383f91d9ace 100644 --- a/packages/interactivity-router/CHANGELOG.md +++ b/packages/interactivity-router/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 2.29.0 (2025-08-20) + ## 2.28.0 (2025-08-07) ## 2.27.0 (2025-07-23) diff --git a/packages/interactivity-router/package.json b/packages/interactivity-router/package.json index e701eed6dd3934..5ea1286245d0ec 100644 --- a/packages/interactivity-router/package.json +++ b/packages/interactivity-router/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/interactivity-router", - "version": "2.28.0", + "version": "2.29.0-prerelease", "description": "Package that exposes state and actions from the `core/router` store, part of the Interactivity API.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/interactivity/CHANGELOG.md b/packages/interactivity/CHANGELOG.md index 41c14de8cb3cdb..ee4a7d62d71684 100644 --- a/packages/interactivity/CHANGELOG.md +++ b/packages/interactivity/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.29.0 (2025-08-20) + ## 6.28.0 (2025-08-07) ### Deprecations diff --git a/packages/interactivity/package.json b/packages/interactivity/package.json index f253d14abf3f1a..f17fac42265c92 100644 --- a/packages/interactivity/package.json +++ b/packages/interactivity/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/interactivity", - "version": "6.28.0", + "version": "6.29.0-prerelease", "description": "Package that provides a standard and simple way to handle the frontend interactivity of Gutenberg blocks.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/interface/CHANGELOG.md b/packages/interface/CHANGELOG.md index 5287c7f4f8227b..5a1934f312723e 100644 --- a/packages/interface/CHANGELOG.md +++ b/packages/interface/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 9.14.0 (2025-08-20) + ## 9.13.0 (2025-08-07) ## 9.12.0 (2025-07-23) diff --git a/packages/interface/package.json b/packages/interface/package.json index 3a2aac12d02540..56dd535e6e55a0 100644 --- a/packages/interface/package.json +++ b/packages/interface/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/interface", - "version": "9.13.0", + "version": "9.14.0-prerelease", "description": "Interface module for WordPress. The package contains shared functionality across the modern JavaScript-based WordPress screens.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/is-shallow-equal/CHANGELOG.md b/packages/is-shallow-equal/CHANGELOG.md index 5c80d0dd95a814..48812b70b09153 100644 --- a/packages/is-shallow-equal/CHANGELOG.md +++ b/packages/is-shallow-equal/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.29.0 (2025-08-20) + ## 5.28.0 (2025-08-07) ## 5.27.0 (2025-07-23) diff --git a/packages/is-shallow-equal/package.json b/packages/is-shallow-equal/package.json index 4dbbd3f17753fd..5860ba2735ebfa 100644 --- a/packages/is-shallow-equal/package.json +++ b/packages/is-shallow-equal/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/is-shallow-equal", - "version": "5.28.0", + "version": "5.29.0-prerelease", "description": "Test for shallow equality between two objects or arrays.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/jest-console/CHANGELOG.md b/packages/jest-console/CHANGELOG.md index 987b6017d57cd8..d1be88ea4c17e4 100644 --- a/packages/jest-console/CHANGELOG.md +++ b/packages/jest-console/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 8.29.0 (2025-08-20) + ## 8.28.0 (2025-08-07) ## 8.27.0 (2025-07-23) diff --git a/packages/jest-console/package.json b/packages/jest-console/package.json index c445a976ef6dbe..c135f9049c19e5 100644 --- a/packages/jest-console/package.json +++ b/packages/jest-console/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/jest-console", - "version": "8.28.0", + "version": "8.29.0-prerelease", "description": "Custom Jest matchers for the Console object.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/jest-preset-default/CHANGELOG.md b/packages/jest-preset-default/CHANGELOG.md index 118140abaf2a08..8b598c604dbf6f 100644 --- a/packages/jest-preset-default/CHANGELOG.md +++ b/packages/jest-preset-default/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 12.29.0 (2025-08-20) + ## 12.28.0 (2025-08-07) ## 12.27.0 (2025-07-23) diff --git a/packages/jest-preset-default/package.json b/packages/jest-preset-default/package.json index 092e6afab5a986..a8045587a293b8 100644 --- a/packages/jest-preset-default/package.json +++ b/packages/jest-preset-default/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/jest-preset-default", - "version": "12.28.0", + "version": "12.29.0-prerelease", "description": "Default Jest preset for WordPress development.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/jest-puppeteer-axe/CHANGELOG.md b/packages/jest-puppeteer-axe/CHANGELOG.md index e713028b21d74f..6dca8bd671b1d5 100644 --- a/packages/jest-puppeteer-axe/CHANGELOG.md +++ b/packages/jest-puppeteer-axe/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 7.29.0 (2025-08-20) + ## 7.28.0 (2025-08-07) ## 7.27.0 (2025-07-23) diff --git a/packages/jest-puppeteer-axe/package.json b/packages/jest-puppeteer-axe/package.json index 746d45247c511f..e8788a8e3a734c 100644 --- a/packages/jest-puppeteer-axe/package.json +++ b/packages/jest-puppeteer-axe/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/jest-puppeteer-axe", - "version": "7.28.0", + "version": "7.29.0-prerelease", "description": "Axe API integration with Jest and Puppeteer.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/keyboard-shortcuts/CHANGELOG.md b/packages/keyboard-shortcuts/CHANGELOG.md index 97c3deb2262330..3b3914f6cd1b17 100644 --- a/packages/keyboard-shortcuts/CHANGELOG.md +++ b/packages/keyboard-shortcuts/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.29.0 (2025-08-20) + ## 5.28.0 (2025-08-07) ## 5.27.0 (2025-07-23) diff --git a/packages/keyboard-shortcuts/package.json b/packages/keyboard-shortcuts/package.json index 9b5133912bea42..cdf4c3bf5b5257 100644 --- a/packages/keyboard-shortcuts/package.json +++ b/packages/keyboard-shortcuts/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/keyboard-shortcuts", - "version": "5.28.0", + "version": "5.29.0-prerelease", "description": "Handling keyboard shortcuts.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/keycodes/CHANGELOG.md b/packages/keycodes/CHANGELOG.md index 425b02d6072e38..e930a6efa108ca 100644 --- a/packages/keycodes/CHANGELOG.md +++ b/packages/keycodes/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.29.0 (2025-08-20) + ## 4.28.0 (2025-08-07) ## 4.27.0 (2025-07-23) diff --git a/packages/keycodes/package.json b/packages/keycodes/package.json index 470dff2cf63d25..109c35837434ac 100644 --- a/packages/keycodes/package.json +++ b/packages/keycodes/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/keycodes", - "version": "4.28.0", + "version": "4.29.0-prerelease", "description": "Keycodes utilities for WordPress. Used to check for keyboard events across browsers/operating systems.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/lazy-import/CHANGELOG.md b/packages/lazy-import/CHANGELOG.md index dbcbb638936376..7741f91d9674f0 100644 --- a/packages/lazy-import/CHANGELOG.md +++ b/packages/lazy-import/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 2.29.0 (2025-08-20) + ## 2.28.0 (2025-08-07) ## 2.27.0 (2025-07-23) diff --git a/packages/lazy-import/package.json b/packages/lazy-import/package.json index 2a7b0e43d4f3c0..5cf4b47218b4ad 100644 --- a/packages/lazy-import/package.json +++ b/packages/lazy-import/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/lazy-import", - "version": "2.28.0", + "version": "2.29.0-prerelease", "description": "Lazily import a module, installing it automatically if missing.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/list-reusable-blocks/CHANGELOG.md b/packages/list-reusable-blocks/CHANGELOG.md index 5a1c7af96dcf4f..863e8ca6e6959e 100644 --- a/packages/list-reusable-blocks/CHANGELOG.md +++ b/packages/list-reusable-blocks/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.29.0 (2025-08-20) + ## 5.28.0 (2025-08-07) ## 5.27.0 (2025-07-23) diff --git a/packages/list-reusable-blocks/package.json b/packages/list-reusable-blocks/package.json index c16cd4945a335b..891100f53894d7 100644 --- a/packages/list-reusable-blocks/package.json +++ b/packages/list-reusable-blocks/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/list-reusable-blocks", - "version": "5.28.0", + "version": "5.29.0-prerelease", "description": "Adding Export/Import support to the reusable blocks listing.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/media-utils/CHANGELOG.md b/packages/media-utils/CHANGELOG.md index dacac58d29d81d..38da7e337b53c4 100644 --- a/packages/media-utils/CHANGELOG.md +++ b/packages/media-utils/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.29.0 (2025-08-20) + ## 5.28.0 (2025-08-07) ## 5.27.0 (2025-07-23) diff --git a/packages/media-utils/package.json b/packages/media-utils/package.json index f2fb5d0af22c29..24007e2fc3d312 100644 --- a/packages/media-utils/package.json +++ b/packages/media-utils/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/media-utils", - "version": "5.28.0", + "version": "5.29.0-prerelease", "description": "WordPress Media Upload Utils.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/notices/CHANGELOG.md b/packages/notices/CHANGELOG.md index b155c1f08fe948..9d473b74060e5f 100644 --- a/packages/notices/CHANGELOG.md +++ b/packages/notices/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.29.0 (2025-08-20) + ## 5.28.0 (2025-08-07) ## 5.27.0 (2025-07-23) diff --git a/packages/notices/package.json b/packages/notices/package.json index ebb12ada61d883..e5fb95f853ea94 100644 --- a/packages/notices/package.json +++ b/packages/notices/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/notices", - "version": "5.28.0", + "version": "5.29.0-prerelease", "description": "State management for notices.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/npm-package-json-lint-config/CHANGELOG.md b/packages/npm-package-json-lint-config/CHANGELOG.md index c435fe29589fbb..6e3ec25029b562 100644 --- a/packages/npm-package-json-lint-config/CHANGELOG.md +++ b/packages/npm-package-json-lint-config/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.29.0 (2025-08-20) + ## 5.28.0 (2025-08-07) ## 5.27.0 (2025-07-23) diff --git a/packages/npm-package-json-lint-config/package.json b/packages/npm-package-json-lint-config/package.json index 1593fbb027c5b9..43a37a3d7960ff 100644 --- a/packages/npm-package-json-lint-config/package.json +++ b/packages/npm-package-json-lint-config/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/npm-package-json-lint-config", - "version": "5.28.0", + "version": "5.29.0-prerelease", "description": "WordPress npm-package-json-lint shareable configuration.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/nux/CHANGELOG.md b/packages/nux/CHANGELOG.md index c3c028307407b5..a17a83e538001e 100644 --- a/packages/nux/CHANGELOG.md +++ b/packages/nux/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 9.29.0 (2025-08-20) + ## 9.28.0 (2025-08-07) ## 9.27.0 (2025-07-23) diff --git a/packages/nux/package.json b/packages/nux/package.json index abec6043eb206f..d4e4a931c164ff 100644 --- a/packages/nux/package.json +++ b/packages/nux/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/nux", - "version": "9.28.0", + "version": "9.29.0-prerelease", "description": "NUX (New User eXperience) module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/patterns/CHANGELOG.md b/packages/patterns/CHANGELOG.md index be566a3d69f9b8..4940cb3e5676eb 100644 --- a/packages/patterns/CHANGELOG.md +++ b/packages/patterns/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 2.29.0 (2025-08-20) + ## 2.28.0 (2025-08-07) ## 2.27.0 (2025-07-23) diff --git a/packages/patterns/package.json b/packages/patterns/package.json index 5a5803de7154ce..5cf616cb9537d8 100644 --- a/packages/patterns/package.json +++ b/packages/patterns/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/patterns", - "version": "2.28.0", + "version": "2.29.0-prerelease", "description": "Management of user pattern editing.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/plugins/CHANGELOG.md b/packages/plugins/CHANGELOG.md index cf45c8495f8d2c..3c0e7e11e5a742 100644 --- a/packages/plugins/CHANGELOG.md +++ b/packages/plugins/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 7.29.0 (2025-08-20) + ## 7.28.0 (2025-08-07) ## 7.27.0 (2025-07-23) diff --git a/packages/plugins/package.json b/packages/plugins/package.json index 42c74f31f38f55..31bb2777d1251d 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/plugins", - "version": "7.28.0", + "version": "7.29.0-prerelease", "description": "Plugins module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/postcss-plugins-preset/CHANGELOG.md b/packages/postcss-plugins-preset/CHANGELOG.md index fb4f3eafea0ac3..07237d7669a1a9 100644 --- a/packages/postcss-plugins-preset/CHANGELOG.md +++ b/packages/postcss-plugins-preset/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.29.0 (2025-08-20) + ## 5.28.0 (2025-08-07) ## 5.27.0 (2025-07-23) diff --git a/packages/postcss-plugins-preset/package.json b/packages/postcss-plugins-preset/package.json index ba6eb4e031c0ef..081e22528409cd 100644 --- a/packages/postcss-plugins-preset/package.json +++ b/packages/postcss-plugins-preset/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/postcss-plugins-preset", - "version": "5.28.0", + "version": "5.29.0-prerelease", "description": "PostCSS sharable plugins preset for WordPress development.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/postcss-themes/CHANGELOG.md b/packages/postcss-themes/CHANGELOG.md index d8af2c9ecbfedc..7ed232b8993d1f 100644 --- a/packages/postcss-themes/CHANGELOG.md +++ b/packages/postcss-themes/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.29.0 (2025-08-20) + ## 6.28.0 (2025-08-07) ## 6.27.0 (2025-07-23) diff --git a/packages/postcss-themes/package.json b/packages/postcss-themes/package.json index f32ac7e0e49640..c84337c00c50ea 100644 --- a/packages/postcss-themes/package.json +++ b/packages/postcss-themes/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/postcss-themes", - "version": "6.28.0", + "version": "6.29.0-prerelease", "description": "PostCSS plugin to generate theme colors.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/preferences-persistence/CHANGELOG.md b/packages/preferences-persistence/CHANGELOG.md index 75272a4c709920..201b6b6e5c1e95 100644 --- a/packages/preferences-persistence/CHANGELOG.md +++ b/packages/preferences-persistence/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 2.29.0 (2025-08-20) + ## 2.28.0 (2025-08-07) ## 2.27.0 (2025-07-23) diff --git a/packages/preferences-persistence/package.json b/packages/preferences-persistence/package.json index 7f46a08e481fa8..5e007e72a6c761 100644 --- a/packages/preferences-persistence/package.json +++ b/packages/preferences-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/preferences-persistence", - "version": "2.28.0", + "version": "2.29.0-prerelease", "description": "Persistence utilities for `wordpress/preferences`.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/preferences/CHANGELOG.md b/packages/preferences/CHANGELOG.md index b4def72276c930..dc2d1144ff7e8d 100644 --- a/packages/preferences/CHANGELOG.md +++ b/packages/preferences/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.29.0 (2025-08-20) + ## 4.28.0 (2025-08-07) ## 4.27.0 (2025-07-23) diff --git a/packages/preferences/package.json b/packages/preferences/package.json index 4409ff4db5fc15..0f73186650515a 100644 --- a/packages/preferences/package.json +++ b/packages/preferences/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/preferences", - "version": "4.28.0", + "version": "4.29.0-prerelease", "description": "Utilities for managing WordPress preferences.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/prettier-config/CHANGELOG.md b/packages/prettier-config/CHANGELOG.md index 42b25cf00d00bc..340df32e971c28 100644 --- a/packages/prettier-config/CHANGELOG.md +++ b/packages/prettier-config/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.29.0 (2025-08-20) + ## 4.28.0 (2025-08-07) ## 4.27.0 (2025-07-23) diff --git a/packages/prettier-config/package.json b/packages/prettier-config/package.json index a15a9d9abf3124..1a589030bafef2 100644 --- a/packages/prettier-config/package.json +++ b/packages/prettier-config/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/prettier-config", - "version": "4.28.0", + "version": "4.29.0-prerelease", "description": "WordPress Prettier shared configuration.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/primitives/CHANGELOG.md b/packages/primitives/CHANGELOG.md index 2c0b9fcbfdcfc8..bacfb627f8626b 100644 --- a/packages/primitives/CHANGELOG.md +++ b/packages/primitives/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.29.0 (2025-08-20) + ## 4.28.0 (2025-08-07) ## 4.27.0 (2025-07-23) diff --git a/packages/primitives/package.json b/packages/primitives/package.json index 018d93965caecb..5be9c95014d99e 100644 --- a/packages/primitives/package.json +++ b/packages/primitives/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/primitives", - "version": "4.28.0", + "version": "4.29.0-prerelease", "description": "WordPress cross-platform primitives.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/priority-queue/CHANGELOG.md b/packages/priority-queue/CHANGELOG.md index 91bc3d57326110..4fc6d3346684ef 100644 --- a/packages/priority-queue/CHANGELOG.md +++ b/packages/priority-queue/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 3.29.0 (2025-08-20) + ## 3.28.0 (2025-08-07) ## 3.27.0 (2025-07-23) diff --git a/packages/priority-queue/package.json b/packages/priority-queue/package.json index 617906116e577b..3bf3fd8c592847 100644 --- a/packages/priority-queue/package.json +++ b/packages/priority-queue/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/priority-queue", - "version": "3.28.0", + "version": "3.29.0-prerelease", "description": "Generic browser priority queue.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/private-apis/CHANGELOG.md b/packages/private-apis/CHANGELOG.md index 31a3a3e585c596..8d674f3be7b03c 100644 --- a/packages/private-apis/CHANGELOG.md +++ b/packages/private-apis/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 1.29.0 (2025-08-20) + ## 1.28.0 (2025-08-07) ## 1.27.0 (2025-07-23) diff --git a/packages/private-apis/package.json b/packages/private-apis/package.json index 09f8dc07e12747..a938569e998dd1 100644 --- a/packages/private-apis/package.json +++ b/packages/private-apis/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/private-apis", - "version": "1.28.0", + "version": "1.29.0-prerelease", "description": "Internal experimental APIs for WordPress core.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/project-management-automation/CHANGELOG.md b/packages/project-management-automation/CHANGELOG.md index b54641deb8839b..db605f5034f696 100644 --- a/packages/project-management-automation/CHANGELOG.md +++ b/packages/project-management-automation/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 2.29.0 (2025-08-20) + ## 2.28.0 (2025-08-07) ## 2.27.0 (2025-07-23) diff --git a/packages/project-management-automation/package.json b/packages/project-management-automation/package.json index b744cf6ea87e01..d092de2af2e8c0 100644 --- a/packages/project-management-automation/package.json +++ b/packages/project-management-automation/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/project-management-automation", - "version": "2.28.0", + "version": "2.29.0-prerelease", "description": "GitHub Action that implements various automation to assist with managing the Gutenberg GitHub repository.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/react-i18n/CHANGELOG.md b/packages/react-i18n/CHANGELOG.md index 22a7f68775cea8..44efdc0590cdde 100644 --- a/packages/react-i18n/CHANGELOG.md +++ b/packages/react-i18n/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.29.0 (2025-08-20) + ## 4.28.0 (2025-08-07) ## 4.27.0 (2025-07-23) diff --git a/packages/react-i18n/package.json b/packages/react-i18n/package.json index 5d83b2e466d93d..b3c17e0d0f9e5a 100644 --- a/packages/react-i18n/package.json +++ b/packages/react-i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-i18n", - "version": "4.28.0", + "version": "4.29.0-prerelease", "description": "React bindings for @wordpress/i18n.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/readable-js-assets-webpack-plugin/CHANGELOG.md b/packages/readable-js-assets-webpack-plugin/CHANGELOG.md index aa3f640c4cce81..95fbe5b0406feb 100644 --- a/packages/readable-js-assets-webpack-plugin/CHANGELOG.md +++ b/packages/readable-js-assets-webpack-plugin/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 3.29.0 (2025-08-20) + ## 3.28.0 (2025-08-07) ## 3.27.0 (2025-07-23) diff --git a/packages/readable-js-assets-webpack-plugin/package.json b/packages/readable-js-assets-webpack-plugin/package.json index 39fb3504302807..f51fd850084387 100644 --- a/packages/readable-js-assets-webpack-plugin/package.json +++ b/packages/readable-js-assets-webpack-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/readable-js-assets-webpack-plugin", - "version": "3.28.0", + "version": "3.29.0-prerelease", "description": "Generate a readable JS file for each JS asset.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/redux-routine/CHANGELOG.md b/packages/redux-routine/CHANGELOG.md index 4f1b17cb28a282..755f3fbda314c9 100644 --- a/packages/redux-routine/CHANGELOG.md +++ b/packages/redux-routine/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.29.0 (2025-08-20) + ## 5.28.0 (2025-08-07) ## 5.27.0 (2025-07-23) diff --git a/packages/redux-routine/package.json b/packages/redux-routine/package.json index ac131bb2b3c34d..29171b199c5e09 100644 --- a/packages/redux-routine/package.json +++ b/packages/redux-routine/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/redux-routine", - "version": "5.28.0", + "version": "5.29.0-prerelease", "description": "Redux middleware for generator coroutines.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/reusable-blocks/CHANGELOG.md b/packages/reusable-blocks/CHANGELOG.md index a92eed0fac33d0..d0aef9da295695 100644 --- a/packages/reusable-blocks/CHANGELOG.md +++ b/packages/reusable-blocks/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.29.0 (2025-08-20) + ## 5.28.0 (2025-08-07) ## 5.27.0 (2025-07-23) diff --git a/packages/reusable-blocks/package.json b/packages/reusable-blocks/package.json index 69eae6f51ab7af..2d73ef0f3c9d77 100644 --- a/packages/reusable-blocks/package.json +++ b/packages/reusable-blocks/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/reusable-blocks", - "version": "5.28.0", + "version": "5.29.0-prerelease", "description": "Reusable blocks utilities.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/rich-text/CHANGELOG.md b/packages/rich-text/CHANGELOG.md index c4bcc2fbb5af00..a9df7375362a3e 100644 --- a/packages/rich-text/CHANGELOG.md +++ b/packages/rich-text/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 7.29.0 (2025-08-20) + ## 7.28.0 (2025-08-07) ## 7.27.0 (2025-07-23) diff --git a/packages/rich-text/package.json b/packages/rich-text/package.json index 6908dad2bf214e..9694c60ff8ff89 100644 --- a/packages/rich-text/package.json +++ b/packages/rich-text/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/rich-text", - "version": "7.28.0", + "version": "7.29.0-prerelease", "description": "Rich text value and manipulation API.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/router/CHANGELOG.md b/packages/router/CHANGELOG.md index eb241b00cde865..f460a522297c72 100644 --- a/packages/router/CHANGELOG.md +++ b/packages/router/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 1.29.0 (2025-08-20) + ## 1.28.0 (2025-08-07) ## 1.27.0 (2025-07-23) diff --git a/packages/router/package.json b/packages/router/package.json index 613d886bf77a1b..9cb7579f49cc25 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/router", - "version": "1.28.0", + "version": "1.29.0-prerelease", "description": "Router API for WordPress pages.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/scripts/CHANGELOG.md b/packages/scripts/CHANGELOG.md index f9ba0226535cf9..87f568ae79722b 100644 --- a/packages/scripts/CHANGELOG.md +++ b/packages/scripts/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 30.22.0 (2025-08-20) + ## 30.21.0 (2025-08-07) ## 30.20.0 (2025-07-23) diff --git a/packages/scripts/package.json b/packages/scripts/package.json index f851f948b65eed..3bd6351bf596c4 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/scripts", - "version": "30.21.0", + "version": "30.22.0-prerelease", "description": "Collection of reusable scripts for WordPress development.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/server-side-render/CHANGELOG.md b/packages/server-side-render/CHANGELOG.md index 26ab975d5c6ea5..420c1c7439e8c0 100644 --- a/packages/server-side-render/CHANGELOG.md +++ b/packages/server-side-render/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.5.0 (2025-08-20) + ## 6.4.0 (2025-08-07) ### New Features diff --git a/packages/server-side-render/package.json b/packages/server-side-render/package.json index 478869c03119ff..4105c3e7001702 100644 --- a/packages/server-side-render/package.json +++ b/packages/server-side-render/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/server-side-render", - "version": "6.4.0", + "version": "6.5.0-prerelease", "description": "The component used with WordPress to server-side render a preview of dynamic blocks to display in the editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/shortcode/CHANGELOG.md b/packages/shortcode/CHANGELOG.md index df23877a02d5cd..07af3eec955549 100644 --- a/packages/shortcode/CHANGELOG.md +++ b/packages/shortcode/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.29.0 (2025-08-20) + ## 4.28.0 (2025-08-07) ## 4.27.0 (2025-07-23) diff --git a/packages/shortcode/package.json b/packages/shortcode/package.json index 0599e4622ed930..bb3113dbbea885 100644 --- a/packages/shortcode/package.json +++ b/packages/shortcode/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/shortcode", - "version": "4.28.0", + "version": "4.29.0-prerelease", "description": "Shortcode module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/style-engine/CHANGELOG.md b/packages/style-engine/CHANGELOG.md index d6031a2b236b1a..6d8f0aee2127ed 100644 --- a/packages/style-engine/CHANGELOG.md +++ b/packages/style-engine/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 2.29.0 (2025-08-20) + ## 2.28.0 (2025-08-07) ## 2.27.0 (2025-07-23) diff --git a/packages/style-engine/package.json b/packages/style-engine/package.json index 49af04e0e48ead..8bd2e2f7687e1d 100644 --- a/packages/style-engine/package.json +++ b/packages/style-engine/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/style-engine", - "version": "2.28.0", + "version": "2.29.0-prerelease", "description": "A suite of parsers and compilers for WordPress styles.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/stylelint-config/CHANGELOG.md b/packages/stylelint-config/CHANGELOG.md index 7a16698a35bb12..b74d27c3c68d95 100644 --- a/packages/stylelint-config/CHANGELOG.md +++ b/packages/stylelint-config/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 23.21.0 (2025-08-20) + ## 23.20.0 (2025-08-07) ## 23.19.0 (2025-07-23) diff --git a/packages/stylelint-config/package.json b/packages/stylelint-config/package.json index 70976fcb5705c1..be8a5c02fab43c 100644 --- a/packages/stylelint-config/package.json +++ b/packages/stylelint-config/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/stylelint-config", - "version": "23.20.0", + "version": "23.21.0-prerelease", "description": "stylelint config for WordPress development.", "author": "The WordPress Contributors", "license": "MIT", diff --git a/packages/sync/CHANGELOG.md b/packages/sync/CHANGELOG.md index 527e344ac7288c..a8e4a0505bb5ca 100644 --- a/packages/sync/CHANGELOG.md +++ b/packages/sync/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 1.29.0 (2025-08-20) + ## 1.28.0 (2025-08-07) ## 1.27.0 (2025-07-23) diff --git a/packages/sync/package.json b/packages/sync/package.json index 88098384b448e7..659f7840de4034 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/sync", - "version": "1.28.0", + "version": "1.29.0-prerelease", "description": "Sync Data.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/token-list/CHANGELOG.md b/packages/token-list/CHANGELOG.md index eacb5d0a76e097..d828c9c3568ef7 100644 --- a/packages/token-list/CHANGELOG.md +++ b/packages/token-list/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 3.29.0 (2025-08-20) + ## 3.28.0 (2025-08-07) ## 3.27.0 (2025-07-23) diff --git a/packages/token-list/package.json b/packages/token-list/package.json index 909d068e3d836b..da232693fa9ed0 100644 --- a/packages/token-list/package.json +++ b/packages/token-list/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/token-list", - "version": "3.28.0", + "version": "3.29.0-prerelease", "description": "Constructable, plain JavaScript DOMTokenList implementation, supporting non-browser runtimes.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/undo-manager/CHANGELOG.md b/packages/undo-manager/CHANGELOG.md index ecb9abe433a21e..e745333c3cfdb5 100644 --- a/packages/undo-manager/CHANGELOG.md +++ b/packages/undo-manager/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 1.29.0 (2025-08-20) + ## 1.28.0 (2025-08-07) ## 1.27.0 (2025-07-23) diff --git a/packages/undo-manager/package.json b/packages/undo-manager/package.json index 7b3f203486a201..23f83bf2263464 100644 --- a/packages/undo-manager/package.json +++ b/packages/undo-manager/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/undo-manager", - "version": "1.28.0", + "version": "1.29.0-prerelease", "description": "A small package to manage undo/redo.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/upload-media/CHANGELOG.md b/packages/upload-media/CHANGELOG.md index 46a5c7294d5d72..aeff9ec3d80c8a 100644 --- a/packages/upload-media/CHANGELOG.md +++ b/packages/upload-media/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.14.0 (2025-08-20) + ## 0.13.0 (2025-08-07) ## 0.12.0 (2025-07-23) diff --git a/packages/upload-media/package.json b/packages/upload-media/package.json index a5041506c99b3c..d57313dc2278b3 100644 --- a/packages/upload-media/package.json +++ b/packages/upload-media/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/upload-media", - "version": "0.13.0", + "version": "0.14.0-prerelease", "description": "Core media upload logic.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/url/CHANGELOG.md b/packages/url/CHANGELOG.md index 281f8747c73fd9..2cf675720a41bb 100644 --- a/packages/url/CHANGELOG.md +++ b/packages/url/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.29.0 (2025-08-20) + ## 4.28.0 (2025-08-07) ## 4.27.0 (2025-07-23) diff --git a/packages/url/package.json b/packages/url/package.json index 83eeb79389e78e..0a4344cc88f2f9 100644 --- a/packages/url/package.json +++ b/packages/url/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/url", - "version": "4.28.0", + "version": "4.29.0-prerelease", "description": "WordPress URL utilities.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/viewport/CHANGELOG.md b/packages/viewport/CHANGELOG.md index 101f1b200e74e1..6c57ac46a1e67b 100644 --- a/packages/viewport/CHANGELOG.md +++ b/packages/viewport/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.29.0 (2025-08-20) + ## 6.28.0 (2025-08-07) ## 6.27.0 (2025-07-23) diff --git a/packages/viewport/package.json b/packages/viewport/package.json index 732731d5c36e93..7362d0a8927ab0 100644 --- a/packages/viewport/package.json +++ b/packages/viewport/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/viewport", - "version": "6.28.0", + "version": "6.29.0-prerelease", "description": "Viewport module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/warning/CHANGELOG.md b/packages/warning/CHANGELOG.md index 5276a060d882ab..5afa6650bbc0a1 100644 --- a/packages/warning/CHANGELOG.md +++ b/packages/warning/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 3.29.0 (2025-08-20) + ## 3.28.0 (2025-08-07) ## 3.27.0 (2025-07-23) diff --git a/packages/warning/package.json b/packages/warning/package.json index 126e255e705231..eb4d98a835210b 100644 --- a/packages/warning/package.json +++ b/packages/warning/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/warning", - "version": "3.28.0", + "version": "3.29.0-prerelease", "description": "Warning utility for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/widgets/CHANGELOG.md b/packages/widgets/CHANGELOG.md index e8071d93852792..782894d3ad3ae4 100644 --- a/packages/widgets/CHANGELOG.md +++ b/packages/widgets/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.29.0 (2025-08-20) + ## 4.28.0 (2025-08-07) ## 4.27.0 (2025-07-23) diff --git a/packages/widgets/package.json b/packages/widgets/package.json index 81e0585b74b503..4630d8a745696d 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/widgets", - "version": "4.28.0", + "version": "4.29.0-prerelease", "description": "Functionality used by the widgets block editor in the Widgets screen and the Customizer.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/wordcount/CHANGELOG.md b/packages/wordcount/CHANGELOG.md index 156213b3355981..6222787bd705a6 100644 --- a/packages/wordcount/CHANGELOG.md +++ b/packages/wordcount/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.29.0 (2025-08-20) + ## 4.28.0 (2025-08-07) ## 4.27.0 (2025-07-23) diff --git a/packages/wordcount/package.json b/packages/wordcount/package.json index 6e11a4e5417cb8..5086862c869fe9 100644 --- a/packages/wordcount/package.json +++ b/packages/wordcount/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/wordcount", - "version": "4.28.0", + "version": "4.29.0-prerelease", "description": "WordPress word count utility.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", From edf0eb0c1929dff38b7645c0169aea70b53d34b2 Mon Sep 17 00:00:00 2001 From: Gutenberg Repository Automation Date: Wed, 20 Aug 2025 18:42:24 +0000 Subject: [PATCH 03/99] chore(release): publish - @wordpress/a11y@4.29.0 - @wordpress/annotations@3.29.0 - @wordpress/api-fetch@7.29.0 - @wordpress/autop@4.29.0 - @wordpress/babel-plugin-import-jsx-pragma@5.29.0 - @wordpress/babel-plugin-makepot@6.29.0 - @wordpress/babel-preset-default@8.29.0 - @wordpress/base-styles@6.5.0 - @wordpress/blob@4.29.0 - @wordpress/block-directory@5.29.0 - @wordpress/block-editor@15.2.0 - @wordpress/block-library@9.29.0 - @wordpress/block-serialization-default-parser@5.29.0 - @wordpress/block-serialization-spec-parser@5.29.0 - @wordpress/blocks@15.2.0 - @wordpress/browserslist-config@6.29.0 - @wordpress/commands@1.29.0 - @wordpress/components@30.2.0 - @wordpress/compose@7.29.0 - @wordpress/core-commands@1.29.0 - @wordpress/core-data@7.29.0 - @wordpress/create-block@4.72.0 - @wordpress/create-block-interactive-template@2.29.0 - @wordpress/create-block-tutorial-template@4.29.0 - @wordpress/customize-widgets@5.29.0 - @wordpress/data@10.29.0 - @wordpress/data-controls@4.29.0 - @wordpress/dataviews@7.0.0 - @wordpress/date@5.29.0 - @wordpress/dependency-extraction-webpack-plugin@6.29.0 - @wordpress/deprecated@4.29.0 - @wordpress/docgen@2.29.0 - @wordpress/dom@4.29.0 - @wordpress/dom-ready@4.29.0 - @wordpress/e2e-test-utils@11.29.0 - @wordpress/e2e-test-utils-playwright@1.29.0 - @wordpress/e2e-tests@8.29.0 - @wordpress/edit-post@8.29.0 - @wordpress/edit-site@6.29.0 - @wordpress/edit-widgets@6.29.0 - @wordpress/editor@14.29.0 - @wordpress/element@6.29.0 - @wordpress/env@10.29.0 - @wordpress/escape-html@3.29.0 - @wordpress/eslint-plugin@22.15.0 - @wordpress/fields@0.21.0 - @wordpress/format-library@5.29.0 - @wordpress/hooks@4.29.0 - @wordpress/html-entities@4.29.0 - @wordpress/i18n@6.2.0 - @wordpress/icons@10.29.0 - @wordpress/interactivity@6.29.0 - @wordpress/interactivity-router@2.29.0 - @wordpress/interface@9.14.0 - @wordpress/is-shallow-equal@5.29.0 - @wordpress/jest-console@8.29.0 - @wordpress/jest-preset-default@12.29.0 - @wordpress/jest-puppeteer-axe@7.29.0 - @wordpress/keyboard-shortcuts@5.29.0 - @wordpress/keycodes@4.29.0 - @wordpress/lazy-import@2.29.0 - @wordpress/list-reusable-blocks@5.29.0 - @wordpress/media-utils@5.29.0 - @wordpress/notices@5.29.0 - @wordpress/npm-package-json-lint-config@5.29.0 - @wordpress/nux@9.29.0 - @wordpress/patterns@2.29.0 - @wordpress/plugins@7.29.0 - @wordpress/postcss-plugins-preset@5.29.0 - @wordpress/postcss-themes@6.29.0 - @wordpress/preferences@4.29.0 - @wordpress/preferences-persistence@2.29.0 - @wordpress/prettier-config@4.29.0 - @wordpress/primitives@4.29.0 - @wordpress/priority-queue@3.29.0 - @wordpress/private-apis@1.29.0 - @wordpress/project-management-automation@2.29.0 - @wordpress/react-i18n@4.29.0 - @wordpress/readable-js-assets-webpack-plugin@3.29.0 - @wordpress/redux-routine@5.29.0 - @wordpress/reusable-blocks@5.29.0 - @wordpress/rich-text@7.29.0 - @wordpress/router@1.29.0 - @wordpress/scripts@30.22.0 - @wordpress/server-side-render@6.5.0 - @wordpress/shortcode@4.29.0 - @wordpress/style-engine@2.29.0 - @wordpress/stylelint-config@23.21.0 - @wordpress/sync@1.29.0 - @wordpress/token-list@3.29.0 - @wordpress/undo-manager@1.29.0 - @wordpress/upload-media@0.14.0 - @wordpress/url@4.29.0 - @wordpress/viewport@6.29.0 - @wordpress/warning@3.29.0 - @wordpress/widgets@4.29.0 - @wordpress/wordcount@4.29.0 --- package-lock.json | 194 +++++++++--------- packages/a11y/package.json | 2 +- packages/annotations/package.json | 2 +- packages/api-fetch/package.json | 2 +- packages/autop/package.json | 2 +- .../package.json | 2 +- packages/babel-plugin-makepot/package.json | 2 +- packages/babel-preset-default/package.json | 2 +- packages/base-styles/package.json | 2 +- packages/blob/package.json | 2 +- packages/block-directory/package.json | 2 +- packages/block-editor/package.json | 2 +- packages/block-library/package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- packages/blocks/package.json | 2 +- packages/browserslist-config/package.json | 2 +- packages/commands/package.json | 2 +- packages/components/package.json | 2 +- packages/compose/package.json | 2 +- packages/core-commands/package.json | 2 +- packages/core-data/package.json | 2 +- .../package.json | 2 +- .../package.json | 2 +- packages/create-block/package.json | 2 +- packages/customize-widgets/package.json | 2 +- packages/data-controls/package.json | 2 +- packages/data/package.json | 2 +- packages/dataviews/package.json | 2 +- packages/date/package.json | 2 +- .../package.json | 2 +- packages/deprecated/package.json | 2 +- packages/docgen/package.json | 2 +- packages/dom-ready/package.json | 2 +- packages/dom/package.json | 2 +- .../e2e-test-utils-playwright/package.json | 2 +- packages/e2e-test-utils/package.json | 2 +- packages/e2e-tests/package.json | 2 +- packages/edit-post/package.json | 2 +- packages/edit-site/package.json | 2 +- packages/edit-widgets/package.json | 2 +- packages/editor/package.json | 2 +- packages/element/package.json | 2 +- packages/env/package.json | 2 +- packages/escape-html/package.json | 2 +- packages/eslint-plugin/package.json | 2 +- packages/fields/package.json | 2 +- packages/format-library/package.json | 2 +- packages/hooks/package.json | 2 +- packages/html-entities/package.json | 2 +- packages/i18n/package.json | 2 +- packages/icons/package.json | 2 +- packages/interactivity-router/package.json | 2 +- packages/interactivity/package.json | 2 +- packages/interface/package.json | 2 +- packages/is-shallow-equal/package.json | 2 +- packages/jest-console/package.json | 2 +- packages/jest-preset-default/package.json | 2 +- packages/jest-puppeteer-axe/package.json | 2 +- packages/keyboard-shortcuts/package.json | 2 +- packages/keycodes/package.json | 2 +- packages/lazy-import/package.json | 2 +- packages/list-reusable-blocks/package.json | 2 +- packages/media-utils/package.json | 2 +- packages/notices/package.json | 2 +- .../npm-package-json-lint-config/package.json | 2 +- packages/nux/package.json | 2 +- packages/patterns/package.json | 2 +- packages/plugins/package.json | 2 +- packages/postcss-plugins-preset/package.json | 2 +- packages/postcss-themes/package.json | 2 +- packages/preferences-persistence/package.json | 2 +- packages/preferences/package.json | 2 +- packages/prettier-config/package.json | 2 +- packages/primitives/package.json | 2 +- packages/priority-queue/package.json | 2 +- packages/private-apis/package.json | 2 +- .../package.json | 2 +- packages/react-i18n/package.json | 2 +- .../package.json | 2 +- packages/redux-routine/package.json | 2 +- packages/reusable-blocks/package.json | 2 +- packages/rich-text/package.json | 2 +- packages/router/package.json | 2 +- packages/scripts/package.json | 2 +- packages/server-side-render/package.json | 2 +- packages/shortcode/package.json | 2 +- packages/style-engine/package.json | 2 +- packages/stylelint-config/package.json | 2 +- packages/sync/package.json | 2 +- packages/token-list/package.json | 2 +- packages/undo-manager/package.json | 2 +- packages/upload-media/package.json | 2 +- packages/url/package.json | 2 +- packages/viewport/package.json | 2 +- packages/warning/package.json | 2 +- packages/widgets/package.json | 2 +- packages/wordcount/package.json | 2 +- 98 files changed, 194 insertions(+), 194 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8a7734a4fec2b1..a1158d9c87667e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48925,7 +48925,7 @@ }, "packages/a11y": { "name": "@wordpress/a11y", - "version": "4.28.0", + "version": "4.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -48939,7 +48939,7 @@ }, "packages/annotations": { "name": "@wordpress/annotations", - "version": "3.28.0", + "version": "3.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -48959,7 +48959,7 @@ }, "packages/api-fetch": { "name": "@wordpress/api-fetch", - "version": "7.28.0", + "version": "7.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -48973,7 +48973,7 @@ }, "packages/autop": { "name": "@wordpress/autop", - "version": "4.28.0", + "version": "4.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -48985,7 +48985,7 @@ }, "packages/babel-plugin-import-jsx-pragma": { "name": "@wordpress/babel-plugin-import-jsx-pragma", - "version": "5.28.0", + "version": "5.29.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -48997,7 +48997,7 @@ }, "packages/babel-plugin-makepot": { "name": "@wordpress/babel-plugin-makepot", - "version": "6.28.0", + "version": "6.29.0", "license": "GPL-2.0-or-later", "dependencies": { "deepmerge": "^4.3.0", @@ -49014,7 +49014,7 @@ }, "packages/babel-preset-default": { "name": "@wordpress/babel-preset-default", - "version": "8.28.0", + "version": "8.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/core": "7.25.7", @@ -49685,7 +49685,7 @@ }, "packages/base-styles": { "name": "@wordpress/base-styles", - "version": "6.4.0", + "version": "6.5.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -49694,7 +49694,7 @@ }, "packages/blob": { "name": "@wordpress/blob", - "version": "4.28.0", + "version": "4.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -49706,7 +49706,7 @@ }, "packages/block-directory": { "name": "@wordpress/block-directory", - "version": "5.28.0", + "version": "5.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -49742,7 +49742,7 @@ }, "packages/block-editor": { "name": "@wordpress/block-editor", - "version": "15.1.0", + "version": "15.2.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -49843,7 +49843,7 @@ }, "packages/block-library": { "name": "@wordpress/block-library", - "version": "9.28.0", + "version": "9.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -49901,7 +49901,7 @@ }, "packages/block-serialization-default-parser": { "name": "@wordpress/block-serialization-default-parser", - "version": "5.28.0", + "version": "5.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -49913,7 +49913,7 @@ }, "packages/block-serialization-spec-parser": { "name": "@wordpress/block-serialization-spec-parser", - "version": "5.28.0", + "version": "5.29.0", "license": "GPL-2.0-or-later", "dependencies": { "pegjs": "^0.10.0", @@ -49926,7 +49926,7 @@ }, "packages/blocks": { "name": "@wordpress/blocks", - "version": "15.1.0", + "version": "15.2.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -49972,7 +49972,7 @@ }, "packages/browserslist-config": { "name": "@wordpress/browserslist-config", - "version": "6.28.0", + "version": "6.29.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -49981,7 +49981,7 @@ }, "packages/commands": { "name": "@wordpress/commands", - "version": "1.28.0", + "version": "1.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50220,7 +50220,7 @@ }, "packages/components": { "name": "@wordpress/components", - "version": "30.1.0", + "version": "30.2.0", "license": "GPL-2.0-or-later", "dependencies": { "@ariakit/react": "^0.4.15", @@ -50287,7 +50287,7 @@ }, "packages/compose": { "name": "@wordpress/compose", - "version": "7.28.0", + "version": "7.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50324,7 +50324,7 @@ }, "packages/core-commands": { "name": "@wordpress/core-commands", - "version": "1.28.0", + "version": "1.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50353,7 +50353,7 @@ }, "packages/core-data": { "name": "@wordpress/core-data", - "version": "7.28.0", + "version": "7.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50390,7 +50390,7 @@ }, "packages/create-block": { "name": "@wordpress/create-block", - "version": "4.71.0", + "version": "4.72.0", "license": "GPL-2.0-or-later", "dependencies": { "@inquirer/prompts": "^7.2.0", @@ -50417,7 +50417,7 @@ }, "packages/create-block-interactive-template": { "name": "@wordpress/create-block-interactive-template", - "version": "2.28.0", + "version": "2.29.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -50426,7 +50426,7 @@ }, "packages/create-block-tutorial-template": { "name": "@wordpress/create-block-tutorial-template", - "version": "4.28.0", + "version": "4.29.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -50435,7 +50435,7 @@ }, "packages/customize-widgets": { "name": "@wordpress/customize-widgets", - "version": "5.28.0", + "version": "5.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50473,7 +50473,7 @@ }, "packages/data": { "name": "@wordpress/data", - "version": "10.28.0", + "version": "10.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50502,7 +50502,7 @@ }, "packages/data-controls": { "name": "@wordpress/data-controls", - "version": "4.28.0", + "version": "4.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50520,7 +50520,7 @@ }, "packages/dataviews": { "name": "@wordpress/dataviews", - "version": "6.0.0", + "version": "7.0.0", "license": "GPL-2.0-or-later", "dependencies": { "@ariakit/react": "^0.4.15", @@ -50564,7 +50564,7 @@ }, "packages/date": { "name": "@wordpress/date", - "version": "5.28.0", + "version": "5.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50579,7 +50579,7 @@ }, "packages/dependency-extraction-webpack-plugin": { "name": "@wordpress/dependency-extraction-webpack-plugin", - "version": "6.28.0", + "version": "6.29.0", "license": "GPL-2.0-or-later", "dependencies": { "json2php": "^0.0.7" @@ -50594,7 +50594,7 @@ }, "packages/deprecated": { "name": "@wordpress/deprecated", - "version": "4.28.0", + "version": "4.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50607,7 +50607,7 @@ }, "packages/docgen": { "name": "@wordpress/docgen", - "version": "2.28.0", + "version": "2.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/core": "7.25.7", @@ -50628,7 +50628,7 @@ }, "packages/dom": { "name": "@wordpress/dom", - "version": "4.28.0", + "version": "4.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50641,7 +50641,7 @@ }, "packages/dom-ready": { "name": "@wordpress/dom-ready", - "version": "4.28.0", + "version": "4.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -50653,7 +50653,7 @@ }, "packages/e2e-test-utils": { "name": "@wordpress/e2e-test-utils", - "version": "11.28.0", + "version": "11.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50675,7 +50675,7 @@ }, "packages/e2e-test-utils-playwright": { "name": "@wordpress/e2e-test-utils-playwright", - "version": "1.28.0", + "version": "1.29.0", "license": "GPL-2.0-or-later", "dependencies": { "change-case": "^4.1.2", @@ -50701,7 +50701,7 @@ }, "packages/e2e-tests": { "name": "@wordpress/e2e-tests", - "version": "8.28.0", + "version": "8.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@wordpress/e2e-test-utils": "file:../e2e-test-utils", @@ -50731,7 +50731,7 @@ }, "packages/edit-post": { "name": "@wordpress/edit-post", - "version": "8.28.0", + "version": "8.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50778,7 +50778,7 @@ }, "packages/edit-site": { "name": "@wordpress/edit-site", - "version": "6.28.0", + "version": "6.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50841,7 +50841,7 @@ }, "packages/edit-widgets": { "name": "@wordpress/edit-widgets", - "version": "6.28.0", + "version": "6.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50884,7 +50884,7 @@ }, "packages/editor": { "name": "@wordpress/editor", - "version": "14.28.0", + "version": "14.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50946,7 +50946,7 @@ }, "packages/element": { "name": "@wordpress/element", - "version": "6.28.0", + "version": "6.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50965,7 +50965,7 @@ }, "packages/env": { "name": "@wordpress/env", - "version": "10.28.0", + "version": "10.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@inquirer/prompts": "^7.2.0", @@ -50991,7 +50991,7 @@ }, "packages/escape-html": { "name": "@wordpress/escape-html", - "version": "3.28.0", + "version": "3.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -51003,7 +51003,7 @@ }, "packages/eslint-plugin": { "name": "@wordpress/eslint-plugin", - "version": "22.14.0", + "version": "22.15.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/eslint-parser": "7.25.7", @@ -51072,7 +51072,7 @@ }, "packages/fields": { "name": "@wordpress/fields", - "version": "0.20.0", + "version": "0.21.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51114,7 +51114,7 @@ }, "packages/format-library": { "name": "@wordpress/format-library", - "version": "5.28.0", + "version": "5.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51142,7 +51142,7 @@ }, "packages/hooks": { "name": "@wordpress/hooks", - "version": "4.28.0", + "version": "4.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -51154,7 +51154,7 @@ }, "packages/html-entities": { "name": "@wordpress/html-entities", - "version": "4.28.0", + "version": "4.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -51166,7 +51166,7 @@ }, "packages/i18n": { "name": "@wordpress/i18n", - "version": "6.1.0", + "version": "6.2.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51186,7 +51186,7 @@ }, "packages/icons": { "name": "@wordpress/icons", - "version": "10.28.0", + "version": "10.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51200,7 +51200,7 @@ }, "packages/interactivity": { "name": "@wordpress/interactivity", - "version": "6.28.0", + "version": "6.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@preact/signals": "^1.3.0", @@ -51213,7 +51213,7 @@ }, "packages/interactivity-router": { "name": "@wordpress/interactivity-router", - "version": "2.28.0", + "version": "2.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@wordpress/a11y": "file:../a11y", @@ -51227,7 +51227,7 @@ }, "packages/interface": { "name": "@wordpress/interface", - "version": "9.13.0", + "version": "9.14.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51255,7 +51255,7 @@ }, "packages/is-shallow-equal": { "name": "@wordpress/is-shallow-equal", - "version": "5.28.0", + "version": "5.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -51267,7 +51267,7 @@ }, "packages/jest-console": { "name": "@wordpress/jest-console", - "version": "8.28.0", + "version": "8.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51283,7 +51283,7 @@ }, "packages/jest-preset-default": { "name": "@wordpress/jest-preset-default", - "version": "12.28.0", + "version": "12.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@wordpress/jest-console": "file:../jest-console", @@ -51300,7 +51300,7 @@ }, "packages/jest-puppeteer-axe": { "name": "@wordpress/jest-puppeteer-axe", - "version": "7.28.0", + "version": "7.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@axe-core/puppeteer": "^4.0.0", @@ -51322,7 +51322,7 @@ }, "packages/keyboard-shortcuts": { "name": "@wordpress/keyboard-shortcuts", - "version": "5.28.0", + "version": "5.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51340,7 +51340,7 @@ }, "packages/keycodes": { "name": "@wordpress/keycodes", - "version": "4.28.0", + "version": "4.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51353,7 +51353,7 @@ }, "packages/lazy-import": { "name": "@wordpress/lazy-import", - "version": "2.28.0", + "version": "2.29.0", "license": "GPL-2.0-or-later", "dependencies": { "execa": "^4.0.2", @@ -51367,7 +51367,7 @@ }, "packages/list-reusable-blocks": { "name": "@wordpress/list-reusable-blocks", - "version": "5.28.0", + "version": "5.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51390,7 +51390,7 @@ }, "packages/media-utils": { "name": "@wordpress/media-utils", - "version": "5.28.0", + "version": "5.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51407,7 +51407,7 @@ }, "packages/notices": { "name": "@wordpress/notices", - "version": "5.28.0", + "version": "5.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51424,7 +51424,7 @@ }, "packages/npm-package-json-lint-config": { "name": "@wordpress/npm-package-json-lint-config", - "version": "5.28.0", + "version": "5.29.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -51436,7 +51436,7 @@ }, "packages/nux": { "name": "@wordpress/nux", - "version": "9.28.0", + "version": "9.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51459,7 +51459,7 @@ }, "packages/patterns": { "name": "@wordpress/patterns", - "version": "2.28.0", + "version": "2.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51489,7 +51489,7 @@ }, "packages/plugins": { "name": "@wordpress/plugins", - "version": "7.28.0", + "version": "7.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51513,7 +51513,7 @@ }, "packages/postcss-plugins-preset": { "name": "@wordpress/postcss-plugins-preset", - "version": "5.28.0", + "version": "5.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@wordpress/base-styles": "file:../base-styles", @@ -51582,7 +51582,7 @@ }, "packages/postcss-themes": { "name": "@wordpress/postcss-themes", - "version": "6.28.0", + "version": "6.29.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -51594,7 +51594,7 @@ }, "packages/preferences": { "name": "@wordpress/preferences", - "version": "4.28.0", + "version": "4.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51620,7 +51620,7 @@ }, "packages/preferences-persistence": { "name": "@wordpress/preferences-persistence", - "version": "2.28.0", + "version": "2.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51633,7 +51633,7 @@ }, "packages/prettier-config": { "name": "@wordpress/prettier-config", - "version": "4.28.0", + "version": "4.29.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -51645,7 +51645,7 @@ }, "packages/primitives": { "name": "@wordpress/primitives", - "version": "4.28.0", + "version": "4.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51662,7 +51662,7 @@ }, "packages/priority-queue": { "name": "@wordpress/priority-queue", - "version": "3.28.0", + "version": "3.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51675,7 +51675,7 @@ }, "packages/private-apis": { "name": "@wordpress/private-apis", - "version": "1.28.0", + "version": "1.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -51687,7 +51687,7 @@ }, "packages/project-management-automation": { "name": "@wordpress/project-management-automation", - "version": "2.28.0", + "version": "2.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@actions/core": "1.9.1", @@ -51715,7 +51715,7 @@ }, "packages/react-i18n": { "name": "@wordpress/react-i18n", - "version": "4.28.0", + "version": "4.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51871,7 +51871,7 @@ }, "packages/readable-js-assets-webpack-plugin": { "name": "@wordpress/readable-js-assets-webpack-plugin", - "version": "3.28.0", + "version": "3.29.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -51883,7 +51883,7 @@ }, "packages/redux-routine": { "name": "@wordpress/redux-routine", - "version": "5.28.0", + "version": "5.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51926,7 +51926,7 @@ }, "packages/reusable-blocks": { "name": "@wordpress/reusable-blocks", - "version": "5.28.0", + "version": "5.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51953,7 +51953,7 @@ }, "packages/rich-text": { "name": "@wordpress/rich-text", - "version": "7.28.0", + "version": "7.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51978,7 +51978,7 @@ }, "packages/router": { "name": "@wordpress/router", - "version": "1.28.0", + "version": "1.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51999,7 +51999,7 @@ }, "packages/scripts": { "name": "@wordpress/scripts", - "version": "30.21.0", + "version": "30.22.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/core": "7.25.7", @@ -52143,7 +52143,7 @@ }, "packages/server-side-render": { "name": "@wordpress/server-side-render", - "version": "6.4.0", + "version": "6.5.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -52168,7 +52168,7 @@ }, "packages/shortcode": { "name": "@wordpress/shortcode", - "version": "4.28.0", + "version": "4.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -52181,7 +52181,7 @@ }, "packages/style-engine": { "name": "@wordpress/style-engine", - "version": "2.28.0", + "version": "2.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -52194,7 +52194,7 @@ }, "packages/stylelint-config": { "name": "@wordpress/stylelint-config", - "version": "23.20.0", + "version": "23.21.0", "license": "MIT", "dependencies": { "@stylistic/stylelint-plugin": "^3.0.1", @@ -52306,7 +52306,7 @@ }, "packages/sync": { "name": "@wordpress/sync", - "version": "1.28.0", + "version": "1.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -52327,7 +52327,7 @@ }, "packages/token-list": { "name": "@wordpress/token-list", - "version": "3.28.0", + "version": "3.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -52339,7 +52339,7 @@ }, "packages/undo-manager": { "name": "@wordpress/undo-manager", - "version": "1.28.0", + "version": "1.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -52352,7 +52352,7 @@ }, "packages/upload-media": { "name": "@wordpress/upload-media", - "version": "0.13.0", + "version": "0.14.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -52378,7 +52378,7 @@ }, "packages/url": { "name": "@wordpress/url", - "version": "4.28.0", + "version": "4.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -52391,7 +52391,7 @@ }, "packages/viewport": { "name": "@wordpress/viewport", - "version": "6.28.0", + "version": "6.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -52421,7 +52421,7 @@ }, "packages/warning": { "name": "@wordpress/warning", - "version": "3.28.0", + "version": "3.29.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -52430,7 +52430,7 @@ }, "packages/widgets": { "name": "@wordpress/widgets", - "version": "4.28.0", + "version": "4.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -52458,7 +52458,7 @@ }, "packages/wordcount": { "name": "@wordpress/wordcount", - "version": "4.28.0", + "version": "4.29.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" diff --git a/packages/a11y/package.json b/packages/a11y/package.json index e46c43303cfd7a..177d5dc2ab7def 100644 --- a/packages/a11y/package.json +++ b/packages/a11y/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/a11y", - "version": "4.29.0-prerelease", + "version": "4.29.0", "description": "Accessibility (a11y) utilities for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/annotations/package.json b/packages/annotations/package.json index bcf21f5f2194e1..0bb75d33808098 100644 --- a/packages/annotations/package.json +++ b/packages/annotations/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/annotations", - "version": "3.29.0-prerelease", + "version": "3.29.0", "description": "Annotate content in the Gutenberg editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/api-fetch/package.json b/packages/api-fetch/package.json index 6669cfc4225498..47e55293147b0b 100644 --- a/packages/api-fetch/package.json +++ b/packages/api-fetch/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/api-fetch", - "version": "7.29.0-prerelease", + "version": "7.29.0", "description": "Utility to make WordPress REST API requests.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/autop/package.json b/packages/autop/package.json index e780c64f5b089e..10edaafc4beb96 100644 --- a/packages/autop/package.json +++ b/packages/autop/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/autop", - "version": "4.29.0-prerelease", + "version": "4.29.0", "description": "WordPress's automatic paragraph functions `autop` and `removep`.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/babel-plugin-import-jsx-pragma/package.json b/packages/babel-plugin-import-jsx-pragma/package.json index 08289070e404e0..cff0c09211f38f 100644 --- a/packages/babel-plugin-import-jsx-pragma/package.json +++ b/packages/babel-plugin-import-jsx-pragma/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/babel-plugin-import-jsx-pragma", - "version": "5.29.0-prerelease", + "version": "5.29.0", "description": "Babel transform plugin for automatically injecting an import to be used as the pragma for the React JSX Transform plugin.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/babel-plugin-makepot/package.json b/packages/babel-plugin-makepot/package.json index 271bc40609faa9..c388ae8779b9c0 100644 --- a/packages/babel-plugin-makepot/package.json +++ b/packages/babel-plugin-makepot/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/babel-plugin-makepot", - "version": "6.29.0-prerelease", + "version": "6.29.0", "description": "WordPress Babel internationalization (i18n) plugin.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/babel-preset-default/package.json b/packages/babel-preset-default/package.json index 525f60debe1f88..d69df24df68888 100644 --- a/packages/babel-preset-default/package.json +++ b/packages/babel-preset-default/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/babel-preset-default", - "version": "8.29.0-prerelease", + "version": "8.29.0", "description": "Default Babel preset for WordPress development.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/base-styles/package.json b/packages/base-styles/package.json index 117641004cae4f..243810faba0c34 100644 --- a/packages/base-styles/package.json +++ b/packages/base-styles/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/base-styles", - "version": "6.5.0-prerelease", + "version": "6.5.0", "description": "Base SCSS utilities and variables for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/blob/package.json b/packages/blob/package.json index 5c0480ee24a306..d1719e91b066e3 100644 --- a/packages/blob/package.json +++ b/packages/blob/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/blob", - "version": "4.29.0-prerelease", + "version": "4.29.0", "description": "Blob utilities for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/block-directory/package.json b/packages/block-directory/package.json index f81e5af5b152dd..e9b641eaf8f41c 100644 --- a/packages/block-directory/package.json +++ b/packages/block-directory/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-directory", - "version": "5.29.0-prerelease", + "version": "5.29.0", "description": "Extend editor with block directory features to search, download and install blocks.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index 13e969a9033da0..05b152a139df9b 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-editor", - "version": "15.2.0-prerelease", + "version": "15.2.0", "description": "Generic block editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/block-library/package.json b/packages/block-library/package.json index 76c34d230d3c72..57fe711b9b0eef 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-library", - "version": "9.29.0-prerelease", + "version": "9.29.0", "description": "Block library for the WordPress editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/block-serialization-default-parser/package.json b/packages/block-serialization-default-parser/package.json index 5b9e1dc56cce90..dfcf0bf26f3575 100644 --- a/packages/block-serialization-default-parser/package.json +++ b/packages/block-serialization-default-parser/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-serialization-default-parser", - "version": "5.29.0-prerelease", + "version": "5.29.0", "description": "Block serialization specification parser for WordPress posts.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/block-serialization-spec-parser/package.json b/packages/block-serialization-spec-parser/package.json index fc6dd14e7b4d24..630b9a48ba9a69 100644 --- a/packages/block-serialization-spec-parser/package.json +++ b/packages/block-serialization-spec-parser/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-serialization-spec-parser", - "version": "5.29.0-prerelease", + "version": "5.29.0", "description": "Block serialization specification parser for WordPress posts.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/blocks/package.json b/packages/blocks/package.json index e887d31cf085ae..6543be1c60fb30 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/blocks", - "version": "15.2.0-prerelease", + "version": "15.2.0", "description": "Block API for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/browserslist-config/package.json b/packages/browserslist-config/package.json index ea3c07ce5e2574..5e89651c479d32 100644 --- a/packages/browserslist-config/package.json +++ b/packages/browserslist-config/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/browserslist-config", - "version": "6.29.0-prerelease", + "version": "6.29.0", "description": "WordPress Browserslist shared configuration.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/commands/package.json b/packages/commands/package.json index 9872fa0abf68d1..0617827956aec4 100644 --- a/packages/commands/package.json +++ b/packages/commands/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/commands", - "version": "1.29.0-prerelease", + "version": "1.29.0", "description": "Handles the commands menu.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/components/package.json b/packages/components/package.json index fa288d2974df92..c3b5d1fdd2fc82 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/components", - "version": "30.2.0-prerelease", + "version": "30.2.0", "description": "UI components for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/compose/package.json b/packages/compose/package.json index 1e2fc150ebbf88..e1b8502cd53749 100644 --- a/packages/compose/package.json +++ b/packages/compose/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/compose", - "version": "7.29.0-prerelease", + "version": "7.29.0", "description": "WordPress higher-order components (HOCs).", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/core-commands/package.json b/packages/core-commands/package.json index f516cc62f0acce..48e44b11562d4d 100644 --- a/packages/core-commands/package.json +++ b/packages/core-commands/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/core-commands", - "version": "1.29.0-prerelease", + "version": "1.29.0", "description": "WordPress core reusable commands.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/core-data/package.json b/packages/core-data/package.json index 5fe5f8681d5b05..a6ead7ba3399fe 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/core-data", - "version": "7.29.0-prerelease", + "version": "7.29.0", "description": "Access to and manipulation of core WordPress entities.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/create-block-interactive-template/package.json b/packages/create-block-interactive-template/package.json index d84e8152d27c6c..82730353010bad 100644 --- a/packages/create-block-interactive-template/package.json +++ b/packages/create-block-interactive-template/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/create-block-interactive-template", - "version": "2.29.0-prerelease", + "version": "2.29.0", "description": "Template for @wordpress/create-block to create interactive blocks with the Interactivity API.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/create-block-tutorial-template/package.json b/packages/create-block-tutorial-template/package.json index c1d706f67dc31b..17e97e29809d33 100644 --- a/packages/create-block-tutorial-template/package.json +++ b/packages/create-block-tutorial-template/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/create-block-tutorial-template", - "version": "4.29.0-prerelease", + "version": "4.29.0", "description": "This is a template for @wordpress/create-block that creates an example 'Copyright Date' block. This block is used in the official WordPress block development Quick Start Guide.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/create-block/package.json b/packages/create-block/package.json index babcb99588faeb..ac27aeaf8fb4b8 100644 --- a/packages/create-block/package.json +++ b/packages/create-block/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/create-block", - "version": "4.72.0-prerelease", + "version": "4.72.0", "description": "Generates PHP, JS and CSS code for registering a block for a WordPress plugin.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/customize-widgets/package.json b/packages/customize-widgets/package.json index aac6db4df0306e..8039978ef7aa11 100644 --- a/packages/customize-widgets/package.json +++ b/packages/customize-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/customize-widgets", - "version": "5.29.0-prerelease", + "version": "5.29.0", "description": "Widgets blocks in Customizer Module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/data-controls/package.json b/packages/data-controls/package.json index 1a1ffcc6519116..df2e5a551b6928 100644 --- a/packages/data-controls/package.json +++ b/packages/data-controls/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/data-controls", - "version": "4.29.0-prerelease", + "version": "4.29.0", "description": "A set of common controls for the @wordpress/data api.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/data/package.json b/packages/data/package.json index fe726b0045d407..261127bebcd852 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/data", - "version": "10.29.0-prerelease", + "version": "10.29.0", "description": "Data module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/dataviews/package.json b/packages/dataviews/package.json index d2159cd124b81f..3fb0c4356c4883 100644 --- a/packages/dataviews/package.json +++ b/packages/dataviews/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/dataviews", - "version": "7.0.0-prerelease", + "version": "7.0.0", "description": "DataViews is a component that provides an API to render datasets using different types of layouts (table, grid, list, etc.).", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/date/package.json b/packages/date/package.json index 55f6e058de442f..269ededece18e1 100644 --- a/packages/date/package.json +++ b/packages/date/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/date", - "version": "5.29.0-prerelease", + "version": "5.29.0", "description": "Date module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/dependency-extraction-webpack-plugin/package.json b/packages/dependency-extraction-webpack-plugin/package.json index 6d15b2517dbb79..e88a79e57c2273 100644 --- a/packages/dependency-extraction-webpack-plugin/package.json +++ b/packages/dependency-extraction-webpack-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/dependency-extraction-webpack-plugin", - "version": "6.29.0-prerelease", + "version": "6.29.0", "description": "Extract WordPress script dependencies from webpack bundles.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/deprecated/package.json b/packages/deprecated/package.json index 72c5e345425e13..06dcd25e93e74b 100644 --- a/packages/deprecated/package.json +++ b/packages/deprecated/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/deprecated", - "version": "4.29.0-prerelease", + "version": "4.29.0", "description": "Deprecation utility for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/docgen/package.json b/packages/docgen/package.json index c3e04d79a2be30..b058e4d5b8099e 100644 --- a/packages/docgen/package.json +++ b/packages/docgen/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/docgen", - "version": "2.29.0-prerelease", + "version": "2.29.0", "description": "Autogenerate public API documentation from exports and JSDoc comments.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/dom-ready/package.json b/packages/dom-ready/package.json index 452af4fbb444f1..45228a0995dc14 100644 --- a/packages/dom-ready/package.json +++ b/packages/dom-ready/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/dom-ready", - "version": "4.29.0-prerelease", + "version": "4.29.0", "description": "Execute callback after the DOM is loaded.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/dom/package.json b/packages/dom/package.json index de8e0bff73aff1..e8ebe143eecf57 100644 --- a/packages/dom/package.json +++ b/packages/dom/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/dom", - "version": "4.29.0-prerelease", + "version": "4.29.0", "description": "DOM utilities module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/e2e-test-utils-playwright/package.json b/packages/e2e-test-utils-playwright/package.json index d6bd930960c141..04cf105971f4f4 100644 --- a/packages/e2e-test-utils-playwright/package.json +++ b/packages/e2e-test-utils-playwright/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/e2e-test-utils-playwright", - "version": "1.29.0-prerelease", + "version": "1.29.0", "description": "End-To-End (E2E) test utils for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/e2e-test-utils/package.json b/packages/e2e-test-utils/package.json index e76a8c7033047c..710fb94322950c 100644 --- a/packages/e2e-test-utils/package.json +++ b/packages/e2e-test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/e2e-test-utils", - "version": "11.29.0-prerelease", + "version": "11.29.0", "description": "End-To-End (E2E) test utils for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/e2e-tests/package.json b/packages/e2e-tests/package.json index d2415ca0ba64d6..aa851c712c57fe 100644 --- a/packages/e2e-tests/package.json +++ b/packages/e2e-tests/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/e2e-tests", - "version": "8.29.0-prerelease", + "version": "8.29.0", "description": "End-To-End (E2E) tests for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/edit-post/package.json b/packages/edit-post/package.json index 991de0443b9f6d..028e78bd3f75e8 100644 --- a/packages/edit-post/package.json +++ b/packages/edit-post/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/edit-post", - "version": "8.29.0-prerelease", + "version": "8.29.0", "description": "Edit Post module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/edit-site/package.json b/packages/edit-site/package.json index 31502ecd485ca7..686d7ee6052256 100644 --- a/packages/edit-site/package.json +++ b/packages/edit-site/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/edit-site", - "version": "6.29.0-prerelease", + "version": "6.29.0", "description": "Edit Site Page module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/edit-widgets/package.json b/packages/edit-widgets/package.json index 79d48310d6b7ec..31976c9ecb7f4c 100644 --- a/packages/edit-widgets/package.json +++ b/packages/edit-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/edit-widgets", - "version": "6.29.0-prerelease", + "version": "6.29.0", "description": "Widgets Page module for WordPress..", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/editor/package.json b/packages/editor/package.json index db27cc2fa5cd6e..e825cbe1352883 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/editor", - "version": "14.29.0-prerelease", + "version": "14.29.0", "description": "Enhanced block editor for WordPress posts.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/element/package.json b/packages/element/package.json index 830775c2ce62a5..203820fe5ed004 100644 --- a/packages/element/package.json +++ b/packages/element/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/element", - "version": "6.29.0-prerelease", + "version": "6.29.0", "description": "Element React module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/env/package.json b/packages/env/package.json index 104f1127e4b046..a5efb53cca7b9d 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/env", - "version": "10.29.0-prerelease", + "version": "10.29.0", "description": "A zero-config, self contained local WordPress environment for development and testing.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/escape-html/package.json b/packages/escape-html/package.json index fde4447df752ac..21fa28e56bc0e0 100644 --- a/packages/escape-html/package.json +++ b/packages/escape-html/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/escape-html", - "version": "3.29.0-prerelease", + "version": "3.29.0", "description": "Escape HTML utils.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index 44bc2c3a7eb7f0..0f483ce29cb1ac 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/eslint-plugin", - "version": "22.15.0-prerelease", + "version": "22.15.0", "description": "ESLint plugin for WordPress development.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/fields/package.json b/packages/fields/package.json index 9683511defa357..b6d625aa814d91 100644 --- a/packages/fields/package.json +++ b/packages/fields/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/fields", - "version": "0.21.0-prerelease", + "version": "0.21.0", "description": "DataViews is a component that provides an API to render datasets using different types of layouts (table, grid, list, etc.).", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/format-library/package.json b/packages/format-library/package.json index f202f8d355de1f..a210457ea700ac 100644 --- a/packages/format-library/package.json +++ b/packages/format-library/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/format-library", - "version": "5.29.0-prerelease", + "version": "5.29.0", "description": "Format library for the WordPress editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/hooks/package.json b/packages/hooks/package.json index a638899627f587..88a98c2623ae42 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/hooks", - "version": "4.29.0-prerelease", + "version": "4.29.0", "description": "WordPress hooks library.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/html-entities/package.json b/packages/html-entities/package.json index 77ff78c6c68c8e..7377cde0d67cba 100644 --- a/packages/html-entities/package.json +++ b/packages/html-entities/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/html-entities", - "version": "4.29.0-prerelease", + "version": "4.29.0", "description": "HTML entity utilities for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 8fe5e9e34410a0..a5cee66290982a 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/i18n", - "version": "6.2.0-prerelease", + "version": "6.2.0", "description": "WordPress internationalization (i18n) library.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/icons/package.json b/packages/icons/package.json index b15ee186cefad4..e74f0677cc87c7 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/icons", - "version": "10.29.0-prerelease", + "version": "10.29.0", "description": "WordPress Icons package, based on dashicon.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/interactivity-router/package.json b/packages/interactivity-router/package.json index 5ea1286245d0ec..f9ef29d4e6fbed 100644 --- a/packages/interactivity-router/package.json +++ b/packages/interactivity-router/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/interactivity-router", - "version": "2.29.0-prerelease", + "version": "2.29.0", "description": "Package that exposes state and actions from the `core/router` store, part of the Interactivity API.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/interactivity/package.json b/packages/interactivity/package.json index f17fac42265c92..1c360abf4e321b 100644 --- a/packages/interactivity/package.json +++ b/packages/interactivity/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/interactivity", - "version": "6.29.0-prerelease", + "version": "6.29.0", "description": "Package that provides a standard and simple way to handle the frontend interactivity of Gutenberg blocks.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/interface/package.json b/packages/interface/package.json index 56dd535e6e55a0..5c5cb03776b8d9 100644 --- a/packages/interface/package.json +++ b/packages/interface/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/interface", - "version": "9.14.0-prerelease", + "version": "9.14.0", "description": "Interface module for WordPress. The package contains shared functionality across the modern JavaScript-based WordPress screens.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/is-shallow-equal/package.json b/packages/is-shallow-equal/package.json index 5860ba2735ebfa..4d712a80e4a9b6 100644 --- a/packages/is-shallow-equal/package.json +++ b/packages/is-shallow-equal/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/is-shallow-equal", - "version": "5.29.0-prerelease", + "version": "5.29.0", "description": "Test for shallow equality between two objects or arrays.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/jest-console/package.json b/packages/jest-console/package.json index c135f9049c19e5..25e8fb25f9f77b 100644 --- a/packages/jest-console/package.json +++ b/packages/jest-console/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/jest-console", - "version": "8.29.0-prerelease", + "version": "8.29.0", "description": "Custom Jest matchers for the Console object.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/jest-preset-default/package.json b/packages/jest-preset-default/package.json index a8045587a293b8..5e8ecdf7020f99 100644 --- a/packages/jest-preset-default/package.json +++ b/packages/jest-preset-default/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/jest-preset-default", - "version": "12.29.0-prerelease", + "version": "12.29.0", "description": "Default Jest preset for WordPress development.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/jest-puppeteer-axe/package.json b/packages/jest-puppeteer-axe/package.json index e8788a8e3a734c..a3357c3c37a7fb 100644 --- a/packages/jest-puppeteer-axe/package.json +++ b/packages/jest-puppeteer-axe/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/jest-puppeteer-axe", - "version": "7.29.0-prerelease", + "version": "7.29.0", "description": "Axe API integration with Jest and Puppeteer.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/keyboard-shortcuts/package.json b/packages/keyboard-shortcuts/package.json index cdf4c3bf5b5257..c47315eb48eeb2 100644 --- a/packages/keyboard-shortcuts/package.json +++ b/packages/keyboard-shortcuts/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/keyboard-shortcuts", - "version": "5.29.0-prerelease", + "version": "5.29.0", "description": "Handling keyboard shortcuts.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/keycodes/package.json b/packages/keycodes/package.json index 109c35837434ac..e5f19972b3a18a 100644 --- a/packages/keycodes/package.json +++ b/packages/keycodes/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/keycodes", - "version": "4.29.0-prerelease", + "version": "4.29.0", "description": "Keycodes utilities for WordPress. Used to check for keyboard events across browsers/operating systems.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/lazy-import/package.json b/packages/lazy-import/package.json index 5cf4b47218b4ad..756af8c827a576 100644 --- a/packages/lazy-import/package.json +++ b/packages/lazy-import/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/lazy-import", - "version": "2.29.0-prerelease", + "version": "2.29.0", "description": "Lazily import a module, installing it automatically if missing.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/list-reusable-blocks/package.json b/packages/list-reusable-blocks/package.json index 891100f53894d7..aa6bfeb7827bc7 100644 --- a/packages/list-reusable-blocks/package.json +++ b/packages/list-reusable-blocks/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/list-reusable-blocks", - "version": "5.29.0-prerelease", + "version": "5.29.0", "description": "Adding Export/Import support to the reusable blocks listing.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/media-utils/package.json b/packages/media-utils/package.json index 24007e2fc3d312..efc89cdcb98102 100644 --- a/packages/media-utils/package.json +++ b/packages/media-utils/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/media-utils", - "version": "5.29.0-prerelease", + "version": "5.29.0", "description": "WordPress Media Upload Utils.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/notices/package.json b/packages/notices/package.json index e5fb95f853ea94..c12341cde2c830 100644 --- a/packages/notices/package.json +++ b/packages/notices/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/notices", - "version": "5.29.0-prerelease", + "version": "5.29.0", "description": "State management for notices.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/npm-package-json-lint-config/package.json b/packages/npm-package-json-lint-config/package.json index 43a37a3d7960ff..fb622ac4d1d5d5 100644 --- a/packages/npm-package-json-lint-config/package.json +++ b/packages/npm-package-json-lint-config/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/npm-package-json-lint-config", - "version": "5.29.0-prerelease", + "version": "5.29.0", "description": "WordPress npm-package-json-lint shareable configuration.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/nux/package.json b/packages/nux/package.json index d4e4a931c164ff..4be6a600fe5248 100644 --- a/packages/nux/package.json +++ b/packages/nux/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/nux", - "version": "9.29.0-prerelease", + "version": "9.29.0", "description": "NUX (New User eXperience) module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/patterns/package.json b/packages/patterns/package.json index 5cf616cb9537d8..e8d053258756f3 100644 --- a/packages/patterns/package.json +++ b/packages/patterns/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/patterns", - "version": "2.29.0-prerelease", + "version": "2.29.0", "description": "Management of user pattern editing.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/plugins/package.json b/packages/plugins/package.json index 31bb2777d1251d..1ebef66e71f840 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/plugins", - "version": "7.29.0-prerelease", + "version": "7.29.0", "description": "Plugins module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/postcss-plugins-preset/package.json b/packages/postcss-plugins-preset/package.json index 081e22528409cd..9a35a253452008 100644 --- a/packages/postcss-plugins-preset/package.json +++ b/packages/postcss-plugins-preset/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/postcss-plugins-preset", - "version": "5.29.0-prerelease", + "version": "5.29.0", "description": "PostCSS sharable plugins preset for WordPress development.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/postcss-themes/package.json b/packages/postcss-themes/package.json index c84337c00c50ea..69d02a523dec64 100644 --- a/packages/postcss-themes/package.json +++ b/packages/postcss-themes/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/postcss-themes", - "version": "6.29.0-prerelease", + "version": "6.29.0", "description": "PostCSS plugin to generate theme colors.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/preferences-persistence/package.json b/packages/preferences-persistence/package.json index 5e007e72a6c761..af40bfe88daa37 100644 --- a/packages/preferences-persistence/package.json +++ b/packages/preferences-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/preferences-persistence", - "version": "2.29.0-prerelease", + "version": "2.29.0", "description": "Persistence utilities for `wordpress/preferences`.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/preferences/package.json b/packages/preferences/package.json index 0f73186650515a..069d25f56b0cd5 100644 --- a/packages/preferences/package.json +++ b/packages/preferences/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/preferences", - "version": "4.29.0-prerelease", + "version": "4.29.0", "description": "Utilities for managing WordPress preferences.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/prettier-config/package.json b/packages/prettier-config/package.json index 1a589030bafef2..d76394908e715c 100644 --- a/packages/prettier-config/package.json +++ b/packages/prettier-config/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/prettier-config", - "version": "4.29.0-prerelease", + "version": "4.29.0", "description": "WordPress Prettier shared configuration.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/primitives/package.json b/packages/primitives/package.json index 5be9c95014d99e..635ffb649fc637 100644 --- a/packages/primitives/package.json +++ b/packages/primitives/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/primitives", - "version": "4.29.0-prerelease", + "version": "4.29.0", "description": "WordPress cross-platform primitives.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/priority-queue/package.json b/packages/priority-queue/package.json index 3bf3fd8c592847..9debe17553eed1 100644 --- a/packages/priority-queue/package.json +++ b/packages/priority-queue/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/priority-queue", - "version": "3.29.0-prerelease", + "version": "3.29.0", "description": "Generic browser priority queue.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/private-apis/package.json b/packages/private-apis/package.json index a938569e998dd1..dddc1c632de158 100644 --- a/packages/private-apis/package.json +++ b/packages/private-apis/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/private-apis", - "version": "1.29.0-prerelease", + "version": "1.29.0", "description": "Internal experimental APIs for WordPress core.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/project-management-automation/package.json b/packages/project-management-automation/package.json index d092de2af2e8c0..479da706903e6c 100644 --- a/packages/project-management-automation/package.json +++ b/packages/project-management-automation/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/project-management-automation", - "version": "2.29.0-prerelease", + "version": "2.29.0", "description": "GitHub Action that implements various automation to assist with managing the Gutenberg GitHub repository.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/react-i18n/package.json b/packages/react-i18n/package.json index b3c17e0d0f9e5a..d354c219475609 100644 --- a/packages/react-i18n/package.json +++ b/packages/react-i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-i18n", - "version": "4.29.0-prerelease", + "version": "4.29.0", "description": "React bindings for @wordpress/i18n.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/readable-js-assets-webpack-plugin/package.json b/packages/readable-js-assets-webpack-plugin/package.json index f51fd850084387..db8f85e7b1ed08 100644 --- a/packages/readable-js-assets-webpack-plugin/package.json +++ b/packages/readable-js-assets-webpack-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/readable-js-assets-webpack-plugin", - "version": "3.29.0-prerelease", + "version": "3.29.0", "description": "Generate a readable JS file for each JS asset.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/redux-routine/package.json b/packages/redux-routine/package.json index 29171b199c5e09..855c45ee58cdff 100644 --- a/packages/redux-routine/package.json +++ b/packages/redux-routine/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/redux-routine", - "version": "5.29.0-prerelease", + "version": "5.29.0", "description": "Redux middleware for generator coroutines.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/reusable-blocks/package.json b/packages/reusable-blocks/package.json index 2d73ef0f3c9d77..6da4c0773a6fd0 100644 --- a/packages/reusable-blocks/package.json +++ b/packages/reusable-blocks/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/reusable-blocks", - "version": "5.29.0-prerelease", + "version": "5.29.0", "description": "Reusable blocks utilities.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/rich-text/package.json b/packages/rich-text/package.json index 9694c60ff8ff89..5d8f87b6bdb3f7 100644 --- a/packages/rich-text/package.json +++ b/packages/rich-text/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/rich-text", - "version": "7.29.0-prerelease", + "version": "7.29.0", "description": "Rich text value and manipulation API.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/router/package.json b/packages/router/package.json index 9cb7579f49cc25..921861a30fd4bb 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/router", - "version": "1.29.0-prerelease", + "version": "1.29.0", "description": "Router API for WordPress pages.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/scripts/package.json b/packages/scripts/package.json index 3bd6351bf596c4..f93043df5864ca 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/scripts", - "version": "30.22.0-prerelease", + "version": "30.22.0", "description": "Collection of reusable scripts for WordPress development.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/server-side-render/package.json b/packages/server-side-render/package.json index 4105c3e7001702..757b03e14ada03 100644 --- a/packages/server-side-render/package.json +++ b/packages/server-side-render/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/server-side-render", - "version": "6.5.0-prerelease", + "version": "6.5.0", "description": "The component used with WordPress to server-side render a preview of dynamic blocks to display in the editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/shortcode/package.json b/packages/shortcode/package.json index bb3113dbbea885..e917bc0313f8e3 100644 --- a/packages/shortcode/package.json +++ b/packages/shortcode/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/shortcode", - "version": "4.29.0-prerelease", + "version": "4.29.0", "description": "Shortcode module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/style-engine/package.json b/packages/style-engine/package.json index 8bd2e2f7687e1d..3a2705af1923e3 100644 --- a/packages/style-engine/package.json +++ b/packages/style-engine/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/style-engine", - "version": "2.29.0-prerelease", + "version": "2.29.0", "description": "A suite of parsers and compilers for WordPress styles.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/stylelint-config/package.json b/packages/stylelint-config/package.json index be8a5c02fab43c..1a5e890f1c01cb 100644 --- a/packages/stylelint-config/package.json +++ b/packages/stylelint-config/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/stylelint-config", - "version": "23.21.0-prerelease", + "version": "23.21.0", "description": "stylelint config for WordPress development.", "author": "The WordPress Contributors", "license": "MIT", diff --git a/packages/sync/package.json b/packages/sync/package.json index 659f7840de4034..6dac3bd2f7532b 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/sync", - "version": "1.29.0-prerelease", + "version": "1.29.0", "description": "Sync Data.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/token-list/package.json b/packages/token-list/package.json index da232693fa9ed0..bd481fd7468bfe 100644 --- a/packages/token-list/package.json +++ b/packages/token-list/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/token-list", - "version": "3.29.0-prerelease", + "version": "3.29.0", "description": "Constructable, plain JavaScript DOMTokenList implementation, supporting non-browser runtimes.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/undo-manager/package.json b/packages/undo-manager/package.json index 23f83bf2263464..8960988212266f 100644 --- a/packages/undo-manager/package.json +++ b/packages/undo-manager/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/undo-manager", - "version": "1.29.0-prerelease", + "version": "1.29.0", "description": "A small package to manage undo/redo.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/upload-media/package.json b/packages/upload-media/package.json index d57313dc2278b3..d302f091c593c0 100644 --- a/packages/upload-media/package.json +++ b/packages/upload-media/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/upload-media", - "version": "0.14.0-prerelease", + "version": "0.14.0", "description": "Core media upload logic.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/url/package.json b/packages/url/package.json index 0a4344cc88f2f9..d85c752818d854 100644 --- a/packages/url/package.json +++ b/packages/url/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/url", - "version": "4.29.0-prerelease", + "version": "4.29.0", "description": "WordPress URL utilities.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/viewport/package.json b/packages/viewport/package.json index 7362d0a8927ab0..7876a17ef40635 100644 --- a/packages/viewport/package.json +++ b/packages/viewport/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/viewport", - "version": "6.29.0-prerelease", + "version": "6.29.0", "description": "Viewport module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/warning/package.json b/packages/warning/package.json index eb4d98a835210b..bfd3e032553f0b 100644 --- a/packages/warning/package.json +++ b/packages/warning/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/warning", - "version": "3.29.0-prerelease", + "version": "3.29.0", "description": "Warning utility for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/widgets/package.json b/packages/widgets/package.json index 4630d8a745696d..a031ef8a0276da 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/widgets", - "version": "4.29.0-prerelease", + "version": "4.29.0", "description": "Functionality used by the widgets block editor in the Widgets screen and the Customizer.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/wordcount/package.json b/packages/wordcount/package.json index 5086862c869fe9..5446d9ddab6df5 100644 --- a/packages/wordcount/package.json +++ b/packages/wordcount/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/wordcount", - "version": "4.29.0-prerelease", + "version": "4.29.0", "description": "WordPress word count utility.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", From f0e5391b0306a86eaa4ea504f6197464e8be0f90 Mon Sep 17 00:00:00 2001 From: Gutenberg Repository Automation Date: Wed, 20 Aug 2025 19:14:52 +0000 Subject: [PATCH 04/99] Update Changelog for 21.5.0-rc.1 --- changelog.txt | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/changelog.txt b/changelog.txt index 1442ba3fa6176e..6457698af6b5c7 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,155 @@ == Changelog == += 21.5.0-rc.1 = + + +## Changelog + +### Features + +#### Block Library +- Query Title: Add "Post Type Label" variation. ([71167](https://github.com/WordPress/gutenberg/pull/71167)) + + +### Enhancements + +- DataForm: Panel layout can open as `dropdown` or `modal`. ([71212](https://github.com/WordPress/gutenberg/pull/71212)) +- Move Style above Navigation in Command Pallete. ([71265](https://github.com/WordPress/gutenberg/pull/71265)) +- Update `@tannin/sprintf` for full precision typing support in `@wordpress/i18n`'s sprintf. ([71185](https://github.com/WordPress/gutenberg/pull/71185)) + +#### DataViews +- Add `enableMoving` option to the `table` layout to allow or disallow column moving left and right. ([71120](https://github.com/WordPress/gutenberg/pull/71120)) +- Add smaller preview size to dataviews grid layout. ([71077](https://github.com/WordPress/gutenberg/pull/71077)) +- Allow hiding configuration. ([71173](https://github.com/WordPress/gutenberg/pull/71173)) +- DataForm: Add `array` control. ([71136](https://github.com/WordPress/gutenberg/pull/71136)) +- DataForm: Add new `card` layout. ([71100](https://github.com/WordPress/gutenberg/pull/71100)) +- DataViews story: Rename "Non-interactive" to "Minimal UI". ([71178](https://github.com/WordPress/gutenberg/pull/71178)) +- Try adding optional infinite scroll to dataviews. ([70955](https://github.com/WordPress/gutenberg/pull/70955)) + +#### Block Library +- Add View button to navigation link blocks. ([70986](https://github.com/WordPress/gutenberg/pull/70986)) +- Cover Block: Fix placeholder interaction in contentOnly mode. ([70157](https://github.com/WordPress/gutenberg/pull/70157)) +- Rename Accordions block to Accordion. ([71222](https://github.com/WordPress/gutenberg/pull/71222)) +- Video: Enable autoplay for video on `iOS` devices. ([69380](https://github.com/WordPress/gutenberg/pull/69380)) + +#### Components +- Calendar component: Adjusts the justification for the first and last days of the week. ([71005](https://github.com/WordPress/gutenberg/pull/71005)) +- Form components: Support async validation. ([71184](https://github.com/WordPress/gutenberg/pull/71184)) +- Primary button: Update disabled state to be less prominent. ([70674](https://github.com/WordPress/gutenberg/pull/70674)) +- Update gradient-parser from 1.0.2 to 1.1.1. ([71186](https://github.com/WordPress/gutenberg/pull/71186)) + +#### Write mode +- Hide Cut/Copy commands in Write Mode (contentOnly). ([71063](https://github.com/WordPress/gutenberg/pull/71063)) +- Restrict formatting controls in write mode (contentOnly). ([71058](https://github.com/WordPress/gutenberg/pull/71058)) + +#### Themes +- Support providing border radius presets. ([67544](https://github.com/WordPress/gutenberg/pull/67544)) + + +### Bug Fixes + +- Block Comments: Remove period in comment UI text. ([71234](https://github.com/WordPress/gutenberg/pull/71234)) +- Elements: Support unitless value in CSS custom properties. ([70704](https://github.com/WordPress/gutenberg/pull/70704)) +- Fix horizontal overflow caused by long parent page names in Post Panel. ([71117](https://github.com/WordPress/gutenberg/pull/71117)) + +#### DataViews +- Fix DataViews primary actions horizontal layout consistency. ([71274](https://github.com/WordPress/gutenberg/pull/71274)) +- Preview size picker falls back to the smallest available size. ([71218](https://github.com/WordPress/gutenberg/pull/71218)) + +#### Components +- DateCalendar, DateRangeCalendar: Use px instead of rem units, better theming support. ([71248](https://github.com/WordPress/gutenberg/pull/71248)) +- MenuItem: Make accessible when disabled. ([71251](https://github.com/WordPress/gutenberg/pull/71251)) + +#### Block Library +- Media & Text: Retain 'useFeaturedImage' value during transformations. ([71093](https://github.com/WordPress/gutenberg/pull/71093)) +- Post Excerpts Block: Fix "read more" theme override. ([71179](https://github.com/WordPress/gutenberg/pull/71179)) + +#### Write mode +- Fix: Hide crop and dimensions controls in site-logo block when in Write Mode. ([71061](https://github.com/WordPress/gutenberg/pull/71061)) +- Fix: Hide heading level and alignment controls in site-title block when in contentOnly mode. ([71057](https://github.com/WordPress/gutenberg/pull/71057)) + +#### Block Editor +- Editor: Fix drag and drop to the top of the page. ([71241](https://github.com/WordPress/gutenberg/pull/71241)) + +#### CSS & Styling +- Fields Components: Add missing style resets for fieldset elements. ([71217](https://github.com/WordPress/gutenberg/pull/71217)) + +#### Post Editor +- DocumentBar: Only show template icon when back button is not present. ([71183](https://github.com/WordPress/gutenberg/pull/71183)) + +#### Icons +- Fix viewbox for table and formatting related icons. ([71155](https://github.com/WordPress/gutenberg/pull/71155)) + +#### Design Tools +- Fix duotone `cleanEmptyObject` attributes. ([71151](https://github.com/WordPress/gutenberg/pull/71151)) + +#### Paste +- Classic block: Prevent event bubbubbling on paste. ([71148](https://github.com/WordPress/gutenberg/pull/71148)) + + +### Experiments + +- al: Enable Command Palette in admin dashboard. ([71030](https://github.com/WordPress/gutenberg/pull/71030)) + +#### Block Library +- Accordion Blocks: Make block icons internal. ([71229](https://github.com/WordPress/gutenberg/pull/71229)) + + +### Documentation + +- DataForm: Update stories. ([71268](https://github.com/WordPress/gutenberg/pull/71268)) +- FormTokenField: Improve use case stories. ([71121](https://github.com/WordPress/gutenberg/pull/71121)) +- Social: Include developer blog tutorial in README. ([71266](https://github.com/WordPress/gutenberg/pull/71266)) +- Storybook: Move validated form components into main grouping. ([71181](https://github.com/WordPress/gutenberg/pull/71181)) +- Storybook: Remove `experimental` category in favor of a badge. ([70540](https://github.com/WordPress/gutenberg/pull/70540)) +- block.json schema: Make the apiVersion field required and only allow 3. ([71107](https://github.com/WordPress/gutenberg/pull/71107)) + + +### Code Quality + +#### Block Library +- Accordion: Refactor settings panel to use ToolsPanel. ([71225](https://github.com/WordPress/gutenberg/pull/71225)) +- File Block: Follow HTML API best practices. ([71034](https://github.com/WordPress/gutenberg/pull/71034)) + +#### Widgets Editor +- Widget Area Block: Apply API version 3. ([71110](https://github.com/WordPress/gutenberg/pull/71110)) + + +### Tools + +#### Testing +- Automated Testing: Re-enable storybook smoke test. ([71126](https://github.com/WordPress/gutenberg/pull/71126)) +- Fix flaky 'navigable-toolbar' end-to-end test. ([71158](https://github.com/WordPress/gutenberg/pull/71158)) +- Site editor e2e: Ensure dismissal notice waits for the first occurrence of an updated/published notice. ([71197](https://github.com/WordPress/gutenberg/pull/71197)) +- Upgrade Playwright to v1.54.2. ([70503](https://github.com/WordPress/gutenberg/pull/70503)) + +#### Build Tooling +- Dynamically set `node-version-file` input. ([71090](https://github.com/WordPress/gutenberg/pull/71090)) + + +### Various + +#### Icons +- Deprecate moreHorizontalMobile. ([71172](https://github.com/WordPress/gutenberg/pull/71172)) + +#### Block Library +- Add Accordions Block. ([64119](https://github.com/WordPress/gutenberg/pull/64119)) + + +## First-time contributors + +The following PRs were merged by first-time contributors: + +- @elazzabi: DataForm: Add `array` control. ([71136](https://github.com/WordPress/gutenberg/pull/71136)) + + +## Contributors + +The following contributors merged PRs in this release: + +@andrewserong @BogdanUngureanu @BugReportOnWeb @ciampo @desrosj @elazzabi @getdave @gigitux @im3dabasia @jasmussen @jffng @karthikeya-io @Mamaduka @mikachan @mikejolley @mirka @mrleemon @oandregal @p-jackson @priethor @R1shabh-Gupta @ramonjd @shimotmk @Sukhendu2002 @t-hamano @tellthemachines @USERSATOSHI @yogeshbhutkar @youknowriad + + = 21.4.0 = ## Changelog From 06608967048b252c9730af9c25a7349f246920da Mon Sep 17 00:00:00 2001 From: Gutenberg Repository Automation Date: Wed, 27 Aug 2025 09:36:53 +0000 Subject: [PATCH 05/99] Bump plugin version to 21.5.0 --- gutenberg.php | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/gutenberg.php b/gutenberg.php index 0dca34add104ac..67c177d7705b48 100644 --- a/gutenberg.php +++ b/gutenberg.php @@ -5,7 +5,7 @@ * Description: Printing since 1440. This is the development plugin for the block editor, site editor, and other future WordPress core functionality. * Requires at least: 6.7 * Requires PHP: 7.2 - * Version: 21.5.0-rc.1 + * Version: 21.5.0 * Author: Gutenberg Team * Text Domain: gutenberg * diff --git a/package-lock.json b/package-lock.json index a1158d9c87667e..7a27711fb177ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gutenberg", - "version": "21.5.0-rc.1", + "version": "21.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gutenberg", - "version": "21.5.0-rc.1", + "version": "21.5.0", "hasInstallScript": true, "license": "GPL-2.0-or-later", "workspaces": [ diff --git a/package.json b/package.json index 066d4a5d1fda1b..9d21a5f45c5605 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "21.5.0-rc.1", + "version": "21.5.0", "private": true, "description": "A new WordPress editor experience.", "author": "The WordPress Contributors", From ab1a16bab447eb3868fff72e1a74c6d69062b252 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 24 Jul 2025 14:28:59 -0400 Subject: [PATCH 06/99] Add `core.getSyncProvider` filter This filter allows external code to load its own sync provider. This can be used by both Gutenberg itself and plugins to customize the sync transport. --- lib/client-assets.php | 6 +++ package-lock.json | 2 + packages/core-data/package.json | 1 + packages/core-data/src/sync.js | 27 ------------ packages/core-data/src/sync.ts | 43 +++++++++++++++++++ packages/core-data/tsconfig.json | 1 + packages/sync/CODE.md | 22 +++++++++- packages/sync/README.md | 6 +-- packages/sync/package.json | 1 + packages/sync/src/create-webrtc-connection.js | 39 ----------------- packages/sync/src/create-webrtc-connection.ts | 40 +++++++++++++++++ packages/sync/src/index.js | 3 -- packages/sync/src/index.ts | 40 +++++++++++++++++ packages/sync/src/types.ts | 34 +++++++++++---- packages/sync/tsconfig.json | 2 +- 15 files changed, 182 insertions(+), 85 deletions(-) delete mode 100644 packages/core-data/src/sync.js create mode 100644 packages/core-data/src/sync.ts delete mode 100644 packages/sync/src/create-webrtc-connection.js create mode 100644 packages/sync/src/create-webrtc-connection.ts delete mode 100644 packages/sync/src/index.js create mode 100644 packages/sync/src/index.ts diff --git a/lib/client-assets.php b/lib/client-assets.php index e6f64106cbb323..77e5e79e96c1ab 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -212,6 +212,12 @@ function gutenberg_register_packages_scripts( $scripts ) { } break; + case 'wp-core-data': + if ( gutenberg_is_experiment_enabled( 'gutenberg-sync-collaboration' ) ) { + array_push( $dependencies, 'wp-sync' ); + } + break; + case 'wp-edit-post': array_push( $dependencies, 'media-models', 'media-views', 'postbox' ); break; diff --git a/package-lock.json b/package-lock.json index 7a27711fb177ad..c5f9e595194481 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50364,6 +50364,7 @@ "@wordpress/data": "file:../data", "@wordpress/deprecated": "file:../deprecated", "@wordpress/element": "file:../element", + "@wordpress/hooks": "file:../hooks", "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", @@ -52311,6 +52312,7 @@ "dependencies": { "@babel/runtime": "7.25.7", "@types/simple-peer": "^9.11.5", + "@wordpress/hooks": "file:../hooks", "@wordpress/url": "file:../url", "import-locals": "^2.0.0", "lib0": "^0.2.42", diff --git a/packages/core-data/package.json b/packages/core-data/package.json index a6ead7ba3399fe..ac23f74e9c889a 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -40,6 +40,7 @@ "@wordpress/data": "file:../data", "@wordpress/deprecated": "file:../deprecated", "@wordpress/element": "file:../element", + "@wordpress/hooks": "file:../hooks", "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", diff --git a/packages/core-data/src/sync.js b/packages/core-data/src/sync.js deleted file mode 100644 index fdc421a6bd70e9..00000000000000 --- a/packages/core-data/src/sync.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * WordPress dependencies - */ -import { - createSyncProvider, - connectIndexDb, - createWebRTCConnection, -} from '@wordpress/sync'; - -let syncProvider; - -export function getSyncProvider() { - if ( ! syncProvider ) { - syncProvider = createSyncProvider( - connectIndexDb, - createWebRTCConnection( { - signaling: [ - //'ws://localhost:4444', - window?.wp?.ajax?.settings?.url, - ], - password: window?.__experimentalCollaborativeEditingSecret, - } ) - ); - } - - return syncProvider; -} diff --git a/packages/core-data/src/sync.ts b/packages/core-data/src/sync.ts new file mode 100644 index 00000000000000..f262a8508b361a --- /dev/null +++ b/packages/core-data/src/sync.ts @@ -0,0 +1,43 @@ +/** + * WordPress dependencies + */ +import { applyFilters } from '@wordpress/hooks'; + +/** + * IMPORTANT: Ensure that we only import types from `@wordpress/sync` and not + * code. The code is loaded only behind an experimental flag, which allows + * other plugins to load their own sync provider. + */ +import type { SyncProvider } from '@wordpress/sync'; + +let syncProvider: SyncProvider; + +/** + * Returns the current sync provider, filterable by external code. + * + * If no sync provider is set, it returns a fallback no-op sync provider to + * remove the need for defensive checks in the code that uses it. + * + * @return The current sync provider. + */ +export function getSyncProvider(): SyncProvider { + if ( syncProvider ) { + return syncProvider; + } + + const fallbackNoOpSyncProvider = { + __fallback: true, + bootstrap: async () => {}, + discard: async () => {}, + register: () => {}, + update: () => {}, + }; + + syncProvider = + ( applyFilters( + 'core.getSyncProvider', + null + ) as SyncProvider | null ) ?? fallbackNoOpSyncProvider; + + return syncProvider; +} diff --git a/packages/core-data/tsconfig.json b/packages/core-data/tsconfig.json index 57c9d208e4c689..31800502dae598 100644 --- a/packages/core-data/tsconfig.json +++ b/packages/core-data/tsconfig.json @@ -13,6 +13,7 @@ { "path": "../deprecated" }, { "path": "../element" }, { "path": "../html-entities" }, + { "path": "../hooks" }, { "path": "../i18n" }, { "path": "../is-shallow-equal" }, { "path": "../private-apis" }, diff --git a/packages/sync/CODE.md b/packages/sync/CODE.md index 40a4b76d2cfd42..ae27b2541716dc 100644 --- a/packages/sync/CODE.md +++ b/packages/sync/CODE.md @@ -8,9 +8,27 @@ Relevant docs: - https://github.com/WordPress/gutenberg/issues/52593 - https://docs.yjs.dev/ -## Enable the experiment +## Enable the experiments -The experiment can be enabled in the "Guteberg > Experiments" page. When it is enabled (search for `gutenberg-sync-collaboration` in the codebase), the client receives two new pieces of data: +There are two experiments that must be enabled in the "Guteberg > Experiments" page: + +- Collaboration: enable real-time collaboration +- Collaboration: WebRTC provider + +Alternatively, you can enable just the "Collaboration: enable real-time collaboration" experiment and load a custom provider (transport) via a filter: + +```js +addFilter( 'core.getSyncProvider', 'my-plugin/custom-sync-provider', ( getSyncProvider ) => { + return { + // Custom sync provider implementation + bootstrap: () => {}, + discard: () => {}, + update: () => {}, + }; +} ); +``` + +When it is enabled (search for `gutenberg-sync-collaboration` in the codebase), the client receives two new pieces of data: - `window.__experimentalEnableSync`: boolean. Used by the `core-data` package to determine whether to bootstrap and use the sync provider offered by the `sync` package. - `window.__experimentalCollaborativeEditingSecret`: string. A secret used by the `sync` package to create a secure connection among peers. diff --git a/packages/sync/README.md b/packages/sync/README.md index f15d61b5a1eb5f..6b8f984463d5b5 100644 --- a/packages/sync/README.md +++ b/packages/sync/README.md @@ -47,13 +47,11 @@ Function that creates a new WebRTC Connection. _Parameters_ -- _config_ `Object`: The object ID. -- _config.signaling_ `Array`: -- _config.password_ `string`: +- _config_ `WebRTCConnectionConfig`: Configuration for the WebRTC connection. _Returns_ -- `Function`: Promise that resolves when the connection is established. +- `ConnectDoc`: Promise that resolves when the connection is established. diff --git a/packages/sync/package.json b/packages/sync/package.json index 6dac3bd2f7532b..43895e6887016c 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -31,6 +31,7 @@ "dependencies": { "@babel/runtime": "7.25.7", "@types/simple-peer": "^9.11.5", + "@wordpress/hooks": "file:../hooks", "@wordpress/url": "file:../url", "import-locals": "^2.0.0", "lib0": "^0.2.42", diff --git a/packages/sync/src/create-webrtc-connection.js b/packages/sync/src/create-webrtc-connection.js deleted file mode 100644 index 97fcddc727d024..00000000000000 --- a/packages/sync/src/create-webrtc-connection.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * External dependencies - */ -// import { WebrtcProvider } from 'y-webrtc'; - -/** - * Internal dependencies - */ -import { WebrtcProviderWithHttpSignaling } from './webrtc-http-stream-signaling'; - -/** @typedef {import('./types').ObjectType} ObjectType */ -/** @typedef {import('./types').ObjectID} ObjectID */ -/** @typedef {import('./types').CRDTDoc} CRDTDoc */ - -/** - * Function that creates a new WebRTC Connection. - * - * @param {Object} config The object ID. - * - * @param {Array} config.signaling - * @param {string} config.password - * @return {Function} Promise that resolves when the connection is established. - */ -export function createWebRTCConnection( { signaling, password } ) { - return function ( - /** @type {string} */ objectId, - /** @type {string} */ objectType, - /** @type {import("yjs").Doc} */ doc - ) { - const roomName = `${ objectType }-${ objectId }`; - new WebrtcProviderWithHttpSignaling( roomName, doc, { - signaling, - // @ts-ignore - password, - } ); - - return Promise.resolve( () => true ); - }; -} diff --git a/packages/sync/src/create-webrtc-connection.ts b/packages/sync/src/create-webrtc-connection.ts new file mode 100644 index 00000000000000..456b68694b5da2 --- /dev/null +++ b/packages/sync/src/create-webrtc-connection.ts @@ -0,0 +1,40 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { WebrtcProviderWithHttpSignaling } from './webrtc-http-stream-signaling'; +import type { ConnectDoc, CRDTDoc, ObjectID, ObjectType } from './types'; + +export interface WebRTCConnectionConfig { + signaling: string[]; + password?: string; +} + +/** + * Function that creates a new WebRTC Connection. + * + * @param {WebRTCConnectionConfig} config Configuration for the WebRTC connection. + * @return {ConnectDoc} Promise that resolves when the connection is established. + */ +export function createWebRTCConnection( { + signaling, + password, +}: WebRTCConnectionConfig ): ConnectDoc { + return function ( + objectId: ObjectID, + objectType: ObjectType, + doc: CRDTDoc + ) { + const roomName = `${ objectType }-${ objectId }`; + new WebrtcProviderWithHttpSignaling( roomName, doc, { + signaling, + // @ts-ignore + password, + } ); + + return Promise.resolve( () => true ); + }; +} diff --git a/packages/sync/src/index.js b/packages/sync/src/index.js deleted file mode 100644 index 6c2b6899ffb618..00000000000000 --- a/packages/sync/src/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export { connectIndexDb } from './connect-indexdb'; -export { createWebRTCConnection } from './create-webrtc-connection'; -export { createSyncProvider } from './provider'; diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts new file mode 100644 index 00000000000000..3b6a625a32300e --- /dev/null +++ b/packages/sync/src/index.ts @@ -0,0 +1,40 @@ +/** + * WordPress dependencies + */ +import { addFilter } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import { connectIndexDb } from './connect-indexdb'; +import { createWebRTCConnection } from './create-webrtc-connection'; +import { createSyncProvider } from './provider'; +import type { SyncProvider } from './types'; + +export { connectIndexDb } from './connect-indexdb'; +export { createWebRTCConnection } from './create-webrtc-connection'; +export { createSyncProvider } from './provider'; +export * from './types'; + +addFilter( + 'core.getSyncProvider', + 'wordpress-sync-webrtc/get-sync-provider', + ( provider: SyncProvider | null ): SyncProvider => { + // Do not override an already defined sync provider. + if ( provider ) { + return provider; + } + + return createSyncProvider( + connectIndexDb, + createWebRTCConnection( { + password: window?.__experimentalCollaborativeEditingSecret, + signaling: [ + //'ws://localhost:4444', + window?.wp?.ajax?.settings?.url, + ], + } ) + ); + }, + 10 +); diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 03439ecf280319..73d38eb73bddae 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -1,11 +1,28 @@ +/** + * External dependencies + */ +import type * as Y from 'yjs'; + +declare global { + interface Window { + __experimentalCollaborativeEditingSecret?: string; + wp: { + ajax: { + settings: { + url: string; + }; + }; + }; + } +} + export type ObjectID = string; export type ObjectType = string; -export type ObjectData = any; -export type CRDTDoc = any; +export type CRDTDoc = Y.Doc; export type ObjectConfig = { - fetch: ( id: ObjectID ) => Promise< ObjectData >; - applyChangesToDoc: ( doc: CRDTDoc, data: any ) => void; + applyChangesToDoc: ( doc: CRDTDoc, data: object ) => void; + fetch: ( id: ObjectID ) => Promise< object >; fromCRDTDoc: ( doc: CRDTDoc ) => any; }; @@ -16,12 +33,11 @@ export type ConnectDoc = ( ) => Promise< () => void >; export type SyncProvider = { - register: ( type: ObjectType, config: ObjectConfig ) => void; bootstrap: ( type: ObjectType, id: ObjectID, - handleChanges: ( data: any ) => void - ) => Promise< CRDTDoc >; - update: ( type: ObjectType, id: ObjectID, data: any ) => void; - discard: ( type: ObjectType, id: ObjectID ) => Promise< CRDTDoc >; + handleChanges: ( data: object ) => void + ) => Promise< void >; + discard: ( type: ObjectType, id: ObjectID ) => Promise< void >; + update: ( type: ObjectType, id: ObjectID, data: object ) => void; }; diff --git a/packages/sync/tsconfig.json b/packages/sync/tsconfig.json index f0a5cb0530d297..53e6a2b663d310 100644 --- a/packages/sync/tsconfig.json +++ b/packages/sync/tsconfig.json @@ -4,5 +4,5 @@ "compilerOptions": { "types": [ "node" ] }, - "references": [ { "path": "../url" } ] + "references": [ { "path": "../hooks" }, { "path": "../url" } ] } From b87c34430c169d1550a531cd5f3245cfd01fd8dd Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 24 Jul 2025 16:13:21 -0400 Subject: [PATCH 07/99] Promote @wordpress/sync to public package --- packages/dependency-extraction-webpack-plugin/lib/util.js | 1 - tools/webpack/packages.js | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/dependency-extraction-webpack-plugin/lib/util.js b/packages/dependency-extraction-webpack-plugin/lib/util.js index b5c9f9057c2052..11d24cb433026a 100644 --- a/packages/dependency-extraction-webpack-plugin/lib/util.js +++ b/packages/dependency-extraction-webpack-plugin/lib/util.js @@ -8,7 +8,6 @@ const BUNDLED_PACKAGES = [ '@wordpress/dataviews/wp', '@wordpress/icons', '@wordpress/interface', - '@wordpress/sync', '@wordpress/undo-manager', '@wordpress/upload-media', '@wordpress/fields', diff --git a/tools/webpack/packages.js b/tools/webpack/packages.js index c99c25ee0127ce..f847d67e01f455 100644 --- a/tools/webpack/packages.js +++ b/tools/webpack/packages.js @@ -39,7 +39,6 @@ const BUNDLED_PACKAGES = [ '@wordpress/dataviews/wp', '@wordpress/icons', '@wordpress/interface', - '@wordpress/sync', '@wordpress/undo-manager', '@wordpress/upload-media', '@wordpress/fields', From 67471601af9bd139f226d511a4370a9aa45dc3ee Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 24 Jul 2025 16:52:31 -0400 Subject: [PATCH 08/99] Separate experimental flags for feature and transport --- lib/client-assets.php | 5 ++++- .../class-gutenberg-http-signaling-server.php | 2 +- lib/experiments-page.php | 16 ++++++++++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/client-assets.php b/lib/client-assets.php index 77e5e79e96c1ab..f3a5165538d9db 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -213,7 +213,10 @@ function gutenberg_register_packages_scripts( $scripts ) { break; case 'wp-core-data': - if ( gutenberg_is_experiment_enabled( 'gutenberg-sync-collaboration' ) ) { + if ( + gutenberg_is_experiment_enabled( 'gutenberg-sync-collaboration' ) && + gutenberg_is_experiment_enabled( 'gutenberg-sync-collaboration-webrtc-provider' ) + ) { array_push( $dependencies, 'wp-sync' ); } break; diff --git a/lib/experimental/sync/class-gutenberg-http-signaling-server.php b/lib/experimental/sync/class-gutenberg-http-signaling-server.php index 3448e82a4c31d0..3570599e299cd3 100644 --- a/lib/experimental/sync/class-gutenberg-http-signaling-server.php +++ b/lib/experimental/sync/class-gutenberg-http-signaling-server.php @@ -19,7 +19,7 @@ class Gutenberg_HTTP_Signaling_Server { */ public static function init() { $gutenberg_experiments = get_option( 'gutenberg-experiments' ); - if ( ! $gutenberg_experiments || ! array_key_exists( 'gutenberg-sync-collaboration', $gutenberg_experiments ) ) { + if ( ! $gutenberg_experiments || ! array_key_exists( 'gutenberg-sync-collaboration-webrtc-provider', $gutenberg_experiments ) ) { return; } add_action( 'wp_ajax_gutenberg_signaling_server', array( __CLASS__, 'do_wp_ajax_action' ) ); diff --git a/lib/experiments-page.php b/lib/experiments-page.php index 0daa526f77e7b3..447444b9297bdf 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -129,16 +129,28 @@ function gutenberg_initialize_experiments_settings() { add_settings_field( 'gutenberg-sync-collaboration', - __( 'Collaboration: add real time editing', 'gutenberg' ), + __( 'Collaboration: enable real-time collaboration', 'gutenberg' ), 'gutenberg_display_experiment_field', 'gutenberg-experiments', 'gutenberg_experiments_section', array( - 'label' => __( 'Enables live collaboration and offline persistence between peers.', 'gutenberg' ), + 'label' => __( 'Enables real-time collaboration between peers (requires provider).', 'gutenberg' ), 'id' => 'gutenberg-sync-collaboration', ) ); + add_settings_field( + 'gutenberg-sync-collaboration-webrtc-provider', + __( 'Collaboration: WebRTC provider', 'gutenberg' ), + 'gutenberg_display_experiment_field', + 'gutenberg-experiments', + 'gutenberg_experiments_section', + array( + 'label' => __( 'Enables the WebRTC provider for real-time collaboration.', 'gutenberg' ), + 'id' => 'gutenberg-sync-collaboration-webrtc-provider', + ) + ); + add_settings_field( 'gutenberg-color-randomizer', __( 'Color randomizer', 'gutenberg' ), From b36407470db48fe4e71513f5bd0af9f0d0fa7f48 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Fri, 25 Jul 2025 16:00:31 -0400 Subject: [PATCH 09/99] Restore `register` property, for now. --- packages/sync/src/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 73d38eb73bddae..c141b3e17d6eb8 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -39,5 +39,6 @@ export type SyncProvider = { handleChanges: ( data: object ) => void ) => Promise< void >; discard: ( type: ObjectType, id: ObjectID ) => Promise< void >; + register: ( type: ObjectType, config: ObjectConfig ) => void; update: ( type: ObjectType, id: ObjectID, data: object ) => void; }; From a15469472304c9b0bfaf04cb1ac7b5fa9601cbb3 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Fri, 25 Jul 2025 16:20:03 -0400 Subject: [PATCH 10/99] Fix unit test ... but dangerously, as I don't fully understand it! --- phpunit/script-dependencies-test.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpunit/script-dependencies-test.php b/phpunit/script-dependencies-test.php index 75064dd49cef89..77c229874b6239 100644 --- a/phpunit/script-dependencies-test.php +++ b/phpunit/script-dependencies-test.php @@ -42,7 +42,7 @@ public function test_polyfill_dependents() { 'wp-block-library', 'wp-blocks', 'wp-edit-site', - 'wp-core-data', + 'wp-sync', 'wp-editor', 'wp-router', 'wp-url', From 6ae2dbb84f6130505d4fa4481d346a9396a759b4 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 28 Jul 2025 13:47:42 -0400 Subject: [PATCH 11/99] Remove conditional loading of @wordpress/sync --- lib/client-assets.php | 9 ----- packages/core-data/src/sync.ts | 19 ++++++--- .../lib/util.js | 1 + packages/sync/README.md | 8 ++++ packages/sync/src/index.ts | 40 ++++++++----------- phpunit/script-dependencies-test.php | 2 +- tools/webpack/packages.js | 1 + 7 files changed, 41 insertions(+), 39 deletions(-) diff --git a/lib/client-assets.php b/lib/client-assets.php index f3a5165538d9db..e6f64106cbb323 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -212,15 +212,6 @@ function gutenberg_register_packages_scripts( $scripts ) { } break; - case 'wp-core-data': - if ( - gutenberg_is_experiment_enabled( 'gutenberg-sync-collaboration' ) && - gutenberg_is_experiment_enabled( 'gutenberg-sync-collaboration-webrtc-provider' ) - ) { - array_push( $dependencies, 'wp-sync' ); - } - break; - case 'wp-edit-post': array_push( $dependencies, 'media-models', 'media-views', 'postbox' ); break; diff --git a/packages/core-data/src/sync.ts b/packages/core-data/src/sync.ts index f262a8508b361a..0884c79cb217a5 100644 --- a/packages/core-data/src/sync.ts +++ b/packages/core-data/src/sync.ts @@ -2,14 +2,15 @@ * WordPress dependencies */ import { applyFilters } from '@wordpress/hooks'; - -/** - * IMPORTANT: Ensure that we only import types from `@wordpress/sync` and not - * code. The code is loaded only behind an experimental flag, which allows - * other plugins to load their own sync provider. - */ +import { getWebRTCSyncProvider } from '@wordpress/sync'; import type { SyncProvider } from '@wordpress/sync'; +declare global { + interface Window { + __experimentalEnableSync?: boolean; + } +} + let syncProvider: SyncProvider; /** @@ -39,5 +40,11 @@ export function getSyncProvider(): SyncProvider { null ) as SyncProvider | null ) ?? fallbackNoOpSyncProvider; + // If the filter does not produce a provider and the experimental flag is set, + // get the WebRTC sync provider. + if ( ! syncProvider && window.__experimentalEnableSync ) { + syncProvider = getWebRTCSyncProvider(); + } + return syncProvider; } diff --git a/packages/dependency-extraction-webpack-plugin/lib/util.js b/packages/dependency-extraction-webpack-plugin/lib/util.js index 11d24cb433026a..b5c9f9057c2052 100644 --- a/packages/dependency-extraction-webpack-plugin/lib/util.js +++ b/packages/dependency-extraction-webpack-plugin/lib/util.js @@ -8,6 +8,7 @@ const BUNDLED_PACKAGES = [ '@wordpress/dataviews/wp', '@wordpress/icons', '@wordpress/interface', + '@wordpress/sync', '@wordpress/undo-manager', '@wordpress/upload-media', '@wordpress/fields', diff --git a/packages/sync/README.md b/packages/sync/README.md index 6b8f984463d5b5..ac18815fcbbea1 100644 --- a/packages/sync/README.md +++ b/packages/sync/README.md @@ -53,6 +53,14 @@ _Returns_ - `ConnectDoc`: Promise that resolves when the connection is established. +### getWebRTCSyncProvider + +Returns a WebRTC sync provider. This is the curent default sync provider. + +_Returns_ + +- `SyncProvider`: The WebRTC sync provider. + ## Contributing to this package diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index 3b6a625a32300e..8179d787ef86cc 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -1,7 +1,6 @@ /** * WordPress dependencies */ -import { addFilter } from '@wordpress/hooks'; /** * Internal dependencies @@ -16,25 +15,20 @@ export { createWebRTCConnection } from './create-webrtc-connection'; export { createSyncProvider } from './provider'; export * from './types'; -addFilter( - 'core.getSyncProvider', - 'wordpress-sync-webrtc/get-sync-provider', - ( provider: SyncProvider | null ): SyncProvider => { - // Do not override an already defined sync provider. - if ( provider ) { - return provider; - } - - return createSyncProvider( - connectIndexDb, - createWebRTCConnection( { - password: window?.__experimentalCollaborativeEditingSecret, - signaling: [ - //'ws://localhost:4444', - window?.wp?.ajax?.settings?.url, - ], - } ) - ); - }, - 10 -); +/** + * Returns a WebRTC sync provider. This is the curent default sync provider. + * + * @return {SyncProvider} The WebRTC sync provider. + */ +export function getWebRTCSyncProvider(): SyncProvider { + return createSyncProvider( + connectIndexDb, + createWebRTCConnection( { + password: window?.__experimentalCollaborativeEditingSecret, + signaling: [ + //'ws://localhost:4444', + window?.wp?.ajax?.settings?.url, + ], + } ) + ); +} diff --git a/phpunit/script-dependencies-test.php b/phpunit/script-dependencies-test.php index 77c229874b6239..75064dd49cef89 100644 --- a/phpunit/script-dependencies-test.php +++ b/phpunit/script-dependencies-test.php @@ -42,7 +42,7 @@ public function test_polyfill_dependents() { 'wp-block-library', 'wp-blocks', 'wp-edit-site', - 'wp-sync', + 'wp-core-data', 'wp-editor', 'wp-router', 'wp-url', diff --git a/tools/webpack/packages.js b/tools/webpack/packages.js index f847d67e01f455..c99c25ee0127ce 100644 --- a/tools/webpack/packages.js +++ b/tools/webpack/packages.js @@ -39,6 +39,7 @@ const BUNDLED_PACKAGES = [ '@wordpress/dataviews/wp', '@wordpress/icons', '@wordpress/interface', + '@wordpress/sync', '@wordpress/undo-manager', '@wordpress/upload-media', '@wordpress/fields', From d7c97fe82f0295adad794c2a4b491fd581006097 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 28 Jul 2025 13:57:54 -0400 Subject: [PATCH 12/99] Remove @wordpress/hooks dependency --- packages/sync/package.json | 1 - packages/sync/tsconfig.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/sync/package.json b/packages/sync/package.json index 43895e6887016c..6dac3bd2f7532b 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -31,7 +31,6 @@ "dependencies": { "@babel/runtime": "7.25.7", "@types/simple-peer": "^9.11.5", - "@wordpress/hooks": "file:../hooks", "@wordpress/url": "file:../url", "import-locals": "^2.0.0", "lib0": "^0.2.42", diff --git a/packages/sync/tsconfig.json b/packages/sync/tsconfig.json index 53e6a2b663d310..f0a5cb0530d297 100644 --- a/packages/sync/tsconfig.json +++ b/packages/sync/tsconfig.json @@ -4,5 +4,5 @@ "compilerOptions": { "types": [ "node" ] }, - "references": [ { "path": "../hooks" }, { "path": "../url" } ] + "references": [ { "path": "../url" } ] } From 3233321c5d2e65add5fd772f39df4070fa168e66 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 28 Jul 2025 13:58:09 -0400 Subject: [PATCH 13/99] Remove new experimental flag --- .../sync/class-gutenberg-http-signaling-server.php | 2 +- lib/experiments-page.php | 14 +------------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/lib/experimental/sync/class-gutenberg-http-signaling-server.php b/lib/experimental/sync/class-gutenberg-http-signaling-server.php index 3570599e299cd3..3448e82a4c31d0 100644 --- a/lib/experimental/sync/class-gutenberg-http-signaling-server.php +++ b/lib/experimental/sync/class-gutenberg-http-signaling-server.php @@ -19,7 +19,7 @@ class Gutenberg_HTTP_Signaling_Server { */ public static function init() { $gutenberg_experiments = get_option( 'gutenberg-experiments' ); - if ( ! $gutenberg_experiments || ! array_key_exists( 'gutenberg-sync-collaboration-webrtc-provider', $gutenberg_experiments ) ) { + if ( ! $gutenberg_experiments || ! array_key_exists( 'gutenberg-sync-collaboration', $gutenberg_experiments ) ) { return; } add_action( 'wp_ajax_gutenberg_signaling_server', array( __CLASS__, 'do_wp_ajax_action' ) ); diff --git a/lib/experiments-page.php b/lib/experiments-page.php index 447444b9297bdf..a92c7fd1c6a8b2 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -134,23 +134,11 @@ function gutenberg_initialize_experiments_settings() { 'gutenberg-experiments', 'gutenberg_experiments_section', array( - 'label' => __( 'Enables real-time collaboration between peers (requires provider).', 'gutenberg' ), + 'label' => __( 'Enables real-time collaboration between peers.', 'gutenberg' ), 'id' => 'gutenberg-sync-collaboration', ) ); - add_settings_field( - 'gutenberg-sync-collaboration-webrtc-provider', - __( 'Collaboration: WebRTC provider', 'gutenberg' ), - 'gutenberg_display_experiment_field', - 'gutenberg-experiments', - 'gutenberg_experiments_section', - array( - 'label' => __( 'Enables the WebRTC provider for real-time collaboration.', 'gutenberg' ), - 'id' => 'gutenberg-sync-collaboration-webrtc-provider', - ) - ); - add_settings_field( 'gutenberg-color-randomizer', __( 'Color randomizer', 'gutenberg' ), From e2a5d25a83e9fce9464fc71df3b9df6e2b68b355 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 28 Jul 2025 14:09:26 -0400 Subject: [PATCH 14/99] Correctly fall back to no-op sync provider --- packages/core-data/src/sync.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/core-data/src/sync.ts b/packages/core-data/src/sync.ts index 0884c79cb217a5..a9295508c1ecbc 100644 --- a/packages/core-data/src/sync.ts +++ b/packages/core-data/src/sync.ts @@ -11,7 +11,7 @@ declare global { } } -let syncProvider: SyncProvider; +let syncProvider: SyncProvider | null = null; /** * Returns the current sync provider, filterable by external code. @@ -34,11 +34,10 @@ export function getSyncProvider(): SyncProvider { update: () => {}, }; - syncProvider = - ( applyFilters( - 'core.getSyncProvider', - null - ) as SyncProvider | null ) ?? fallbackNoOpSyncProvider; + syncProvider = applyFilters( + 'core.getSyncProvider', + null + ) as SyncProvider | null; // If the filter does not produce a provider and the experimental flag is set, // get the WebRTC sync provider. @@ -46,5 +45,10 @@ export function getSyncProvider(): SyncProvider { syncProvider = getWebRTCSyncProvider(); } + // If no sync provider is set, use the fallback no-op sync provider. + if ( ! syncProvider ) { + syncProvider = fallbackNoOpSyncProvider; + } + return syncProvider; } From 91d608bd13200a722e3d0baffb8b071c38c91052 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 28 Jul 2025 14:09:51 -0400 Subject: [PATCH 15/99] Remove dependency from base package-lock --- package-lock.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index c5f9e595194481..eca27064c40a80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52312,7 +52312,6 @@ "dependencies": { "@babel/runtime": "7.25.7", "@types/simple-peer": "^9.11.5", - "@wordpress/hooks": "file:../hooks", "@wordpress/url": "file:../url", "import-locals": "^2.0.0", "lib0": "^0.2.42", From e46ea592f154d8ac364e88568a0840d1c8c5e2ea Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 29 Jul 2025 11:16:50 -0400 Subject: [PATCH 16/99] Add Yjs-aware entity config --- package-lock.json | 32 +- packages/core-data/package.json | 1 + packages/core-data/src/actions.js | 56 +- packages/core-data/src/entities.js | 537 +++++++++++++++--- packages/core-data/src/resolvers.js | 182 +++--- packages/core-data/src/sync.ts | 4 +- packages/core-data/tsconfig.json | 3 +- .../lib/util.js | 1 - packages/sync/CODE.md | 2 +- packages/sync/package.json | 11 +- packages/sync/src/connect-indexdb.js | 11 +- packages/sync/src/create-webrtc-connection.ts | 5 +- packages/sync/src/index.ts | 14 + packages/sync/src/provider.js | 128 ----- packages/sync/src/provider.ts | 258 +++++++++ packages/sync/src/types.ts | 77 ++- tools/webpack/packages.js | 1 - 17 files changed, 924 insertions(+), 399 deletions(-) delete mode 100644 packages/sync/src/provider.js create mode 100644 packages/sync/src/provider.ts diff --git a/package-lock.json b/package-lock.json index eca27064c40a80..6cfbb804550fe3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27704,11 +27704,6 @@ "node": ">=8" } }, - "node_modules/import-locals": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/import-locals/-/import-locals-2.0.0.tgz", - "integrity": "sha512-1/bPE89IZhyf7dr5Pkz7b4UyVXy5pEt7PTEfye15UEn3AK8+2zwcDCfKk9Pwun4ltfhOSszOrReSsFcDKw/yoA==" - }, "node_modules/import-meta-resolve": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-3.1.1.tgz", @@ -31292,13 +31287,15 @@ } }, "node_modules/lib0": { - "version": "0.2.79", - "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.79.tgz", - "integrity": "sha512-fIdPbxzMVq10wt3ou1lp3/f9n5ciHZ6t+P1vyGy3XXr018AntTYM4eg24sNFcNq8SYDQwmhhoGdS58IlYBzfBw==", + "version": "0.2.114", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz", + "integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==", + "license": "MIT", "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", "0gentesthtml": "bin/gentesthtml.js", "0serve": "bin/0serve.js" }, @@ -48825,9 +48822,10 @@ } }, "node_modules/yjs": { - "version": "13.6.7", - "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.7.tgz", - "integrity": "sha512-mCZTh4kjvUS2DnaktsYN6wLH3WZCJBLqrTdkWh1bIDpA/sB/GNFaLA/dyVJj2Hc7KwONuuoC/vWe9bwBBosZLQ==", + "version": "13.6.6", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.6.tgz", + "integrity": "sha512-VTvezMeMuOra9jKG1Ym5XuQ2H4xXOubIIIupv/B5oygasa9IqDE7Ufv93QTSe9uz69J5VZGMQb2WTEmJv4kJFQ==", + "license": "MIT", "dependencies": { "lib0": "^0.2.74" }, @@ -50377,6 +50375,7 @@ "change-case": "^4.1.2", "equivalent-key-map": "^0.2.2", "fast-deep-equal": "^3.1.3", + "lib0": "^0.2.99", "memize": "^2.1.0", "uuid": "^9.0.1" }, @@ -52310,16 +52309,15 @@ "version": "1.29.0", "license": "GPL-2.0-or-later", "dependencies": { - "@babel/runtime": "7.25.7", "@types/simple-peer": "^9.11.5", + "@wordpress/hooks": "file:../hooks", "@wordpress/url": "file:../url", - "import-locals": "^2.0.0", - "lib0": "^0.2.42", + "lib0": "^0.2.99", "simple-peer": "^9.11.0", - "y-indexeddb": "~9.0.11", + "y-indexeddb": "^9.0.11", "y-protocols": "^1.0.5", - "y-webrtc": "~10.2.5", - "yjs": "~13.6.6" + "y-webrtc": "^10.2.5", + "yjs": "13.6.6" }, "engines": { "node": ">=18.12.0", diff --git a/packages/core-data/package.json b/packages/core-data/package.json index ac23f74e9c889a..078e66de8fc011 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -53,6 +53,7 @@ "change-case": "^4.1.2", "equivalent-key-map": "^0.2.2", "fast-deep-equal": "^3.1.3", + "lib0": "^0.2.99", "memize": "^2.1.0", "uuid": "^9.0.1" }, diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index f6b245bb525198..3e729a3fbfd7ed 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -400,39 +400,37 @@ export const editEntityRecord = }; if ( window.__experimentalEnableSync && entityConfig.syncConfig ) { if ( globalThis.IS_GUTENBERG_PLUGIN ) { - const objectId = entityConfig.getSyncObjectId( recordId ); + // @todo this always updates the Yjs doc, which is undesirable, probably we can read the yjs + // content from the comment tag here getSyncProvider().update( - entityConfig.syncObjectType + '--edit', - objectId, - edit.edits + entityConfig.syncConfig.objectType, + record, + edit.edits, + 'gutenberg' ); } - } else { - if ( ! options.undoIgnore ) { - select.getUndoManager().addRecord( - [ - { - id: { kind, name, recordId }, - changes: Object.keys( edits ).reduce( - ( acc, key ) => { - acc[ key ] = { - from: editedRecord[ key ], - to: edits[ key ], - }; - return acc; - }, - {} - ), - }, - ], - options.isCached - ); - } - dispatch( { - type: 'EDIT_ENTITY_RECORD', - ...edit, - } ); } + if ( ! options.undoIgnore ) { + select.getUndoManager().addRecord( + [ + { + id: { kind, name, recordId }, + changes: Object.keys( edits ).reduce( ( acc, key ) => { + acc[ key ] = { + from: editedRecord[ key ], + to: edits[ key ], + }; + return acc; + }, {} ), + }, + ], + options.isCached + ); + } + dispatch( { + type: 'EDIT_ENTITY_RECORD', + ...edit, + } ); }; /** diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index f097f76cb89946..1453c996f448e7 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -2,17 +2,106 @@ * External dependencies */ import { capitalCase, pascalCase } from 'change-case'; - +import { v4 as uuidv4 } from 'uuid'; +import * as string from 'lib0/string'; +import * as sha256 from 'lib0/hash/sha256'; /** * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; import { __ } from '@wordpress/i18n'; import { RichTextData } from '@wordpress/rich-text'; +import { parse } from '@wordpress/blocks'; +import { Y } from '@wordpress/sync'; +import * as math from 'lib0/math'; +import * as fun from 'lib0/function'; + +/** + * Internal dependencies + */ +import { getSyncProvider } from './sync'; export const DEFAULT_ENTITY_KEY = 'id'; const POST_RAW_ATTRIBUTES = [ 'title', 'excerpt', 'content' ]; +// @todo refactor `applyChangesToDoc` implementations this to have less repetition (there are +// multiple similar implementations) + +/** + * Similar to `parse`, but only reads the Yjs document if available. + * @param {string} postType + * @param {string} content + */ +export function parseContentYdoc( postType, content ) { + const newClientId = new Uint32Array( + sha256.digest( string.encodeUtf8( content ) ).buffer + ); + const syncProvider = getSyncProvider(); + + // It is important that this is a fresh document - don't use the document from the sync package! + const ydoc = new Y.Doc( { meta: new Map() } ); + + const knownUpdateGuids = new Set(); + ydoc.meta.set( 'knownRemoteUpdates', knownUpdateGuids ); + // Changing the Yjs clientid may lead to very weird bugs if done incorrectly. + // Please handle the following code-portion with great care! + const prevClientId = ydoc.clientID; + ydoc.clientID = newClientId; + const prevClock = ( ydoc.store.clients.get( newClientId ) || [ + { id: { clock: 0 } }, + ] )[ 0 ].id.clock; + const blocks = parse( content ); + syncProvider.configs.get( postType ).applyChangesToDoc( ydoc, { + blocks, + } ); + ydoc.clientID = prevClientId; + const newClock = ( ydoc.store.clients.get( newClientId ) ?? [ + { id: { clock: 0 } }, + ] )[ 0 ].id.clock; + if ( prevClock !== newClock ) { + // eslint-disable-next-line no-console + console.info( + '[Yjs Collab] Yjs document was updated to reflect changes to the HTML document.' + ); + } + return ydoc; +} + +// only sync what is necessary! +const filteredAttributes = new Set( [ + 'content', + 'selection', + 'excerpt', + 'date', + 'date_gmt', + 'format', + 'generated_slug', + 'link', + 'meta', + 'modified', + 'modified_gmt', + 'slug', + 'status', + 'sticky', + 'tags', + 'template', + '_links', + 'id', + 'password', + 'featured_media', +] ); + +/** + * @param {Y.Doc} ydoc + */ +const defaultYdocTransformer = ( ydoc ) => { + const json = ydoc.getMap( 'document' ).toJSON(); + if ( json.title?.raw ) { + json.title = json.title.raw; + } + return json; +}; + export const rootEntitiesConfig = [ { label: __( 'Base' ), @@ -41,23 +130,43 @@ export const rootEntitiesConfig = [ // The property is maintained for backward compatibility. plural: '__unstableBases', syncConfig: { - fetch: async () => { - return apiFetch( { path: '/' } ); - }, applyChangesToDoc: ( doc, changes ) => { - const document = doc.getMap( 'document' ); - Object.entries( changes ).forEach( ( [ key, value ] ) => { - if ( document.get( key ) !== value ) { - document.set( key, value ); - } - } ); - }, - fromCRDTDoc: ( doc ) => { - return doc.getMap( 'document' ).toJSON(); + const content = changes.content?.raw || changes.content; + const parsedYdoc = + typeof content === 'string' + ? parseContentYdoc( 'root/base', content ) + : null; // Note: always use the same 'postType' as this object's config.syncObjectType + if ( parsedYdoc !== null ) { + // parse content which contains a ydoc, and apply it to the current ydoc. The rest of the attributes can be ignored. + Y.transact( + doc, + () => { + // apply remote changes + Y.applyUpdate( + doc, + Y.encodeStateAsUpdate( parsedYdoc ) + ); + }, + 'applyChangesToDoc', + false + ); + } else { + // local changes happened. Apply the differences to the ydoc + const ycontent = doc.getMap( 'document' ); + Object.entries( changes ).forEach( ( [ key, value ] ) => { + if ( + ! filteredAttributes.has( key ) && + ! fun.equalityDeep( ycontent.get( key ), value ) + ) { + ycontent.set( key, value ); + } + } ); + } }, + fromCRDTDoc: defaultYdocTransformer, + getObjectId: () => 'index', + objectType: 'root/base', }, - syncObjectType: 'root/base', - getSyncObjectId: () => 'index', }, { label: __( 'Post Type' ), @@ -68,25 +177,43 @@ export const rootEntitiesConfig = [ baseURLParams: { context: 'edit' }, plural: 'postTypes', syncConfig: { - fetch: async ( id ) => { - return apiFetch( { - path: `/wp/v2/types/${ id }?context=edit`, - } ); - }, - applyChangesToDoc: ( doc, changes ) => { - const document = doc.getMap( 'document' ); - Object.entries( changes ).forEach( ( [ key, value ] ) => { - if ( document.get( key ) !== value ) { - document.set( key, value ); - } - } ); - }, - fromCRDTDoc: ( doc ) => { - return doc.getMap( 'document' ).toJSON(); + applyChangesToDoc: ( ydoc, changes ) => { + const content = changes.content?.raw || changes.content; + const parsedYdoc = + typeof content === 'string' + ? parseContentYdoc( 'root/postType', content ) + : null; // Note: always use the same 'postType' as this object's config.syncObjectType + if ( parsedYdoc !== null ) { + // parse content which contains a ydoc, and apply it to the current ydoc. The rest of the attributes can be ignored. + Y.transact( + ydoc, + () => { + // apply remote changes + Y.applyUpdate( + ydoc, + Y.encodeStateAsUpdate( parsedYdoc ) + ); + }, + 'applyChangesToDoc', + false + ); + } else { + // local changes happened. Apply the differences to the ydoc + const ycontent = ydoc.getMap( 'document' ); + Object.entries( changes ).forEach( ( [ key, value ] ) => { + if ( + ! filteredAttributes.has( key ) && + ! fun.equalityDeep( ycontent.get( key ), value ) + ) { + ycontent.set( key, value ); + } + } ); + } }, + fromCRDTDoc: defaultYdocTransformer, + getObjectId: ( { id } ) => id, + objectType: 'root/postType', }, - syncObjectType: 'root/postType', - getSyncObjectId: ( id ) => id, }, { name: 'media', @@ -287,6 +414,9 @@ function makeBlockAttributesSerializable( attributes ) { function makeBlocksSerializable( blocks ) { return blocks.map( ( block ) => { const { innerBlocks, attributes, ...rest } = block; + delete rest.validationIssues; + delete rest.originalContent; + // delete rest.isValid return { ...rest, attributes: makeBlockAttributesSerializable( attributes ), @@ -330,39 +460,274 @@ async function loadPostTypeEntities() { __unstablePrePersist: isTemplate ? undefined : prePersistPostType, __unstable_rest_base: postType.rest_base, syncConfig: { - fetch: async ( id ) => { - return apiFetch( { - path: `/${ namespace }/${ postType.rest_base }/${ id }?context=edit`, - } ); - }, - applyChangesToDoc: ( doc, changes ) => { - const document = doc.getMap( 'document' ); - - Object.entries( changes ).forEach( ( [ key, value ] ) => { - if ( typeof value !== 'function' ) { - if ( key === 'blocks' ) { - if ( ! serialisableBlocksCache.has( value ) ) { - serialisableBlocksCache.set( - value, - makeBlocksSerializable( value ) - ); + /** + * @param {Y.Doc} ydoc + * @param {any} changes + */ + applyChangesToDoc: ( ydoc, changes ) => { + const content = changes.content?.raw || changes.content; + const parsedYdoc = + typeof content === 'string' + ? parseContentYdoc( + 'postType/' + postType.name, + content + ) + : null; // Note: always use the same 'postType' as this object's config.syncObjectType + if ( parsedYdoc !== null ) { + // parse content which contains a ydoc, and apply it to the current ydoc. The rest of the attributes can be ignored. + Y.transact( + ydoc, + () => { + // apply remote changes + Y.applyUpdate( + ydoc, + Y.encodeStateAsUpdate( parsedYdoc ) + ); + }, + 'applyChangesToDoc', + false + ); + } else { + // local changes happened. Apply the differences to the ydoc + const ycontent = ydoc.getMap( 'document' ); + ydoc.transact( () => { + Object.entries( changes ).forEach( + ( [ key, value ] ) => { + if ( typeof value !== 'function' ) { + if ( key === 'blocks' ) { + if ( + ! serialisableBlocksCache.has( + value + ) + ) { + serialisableBlocksCache.set( + value, + makeBlocksSerializable( + value + ) + ); + } + const blocks = + serialisableBlocksCache.get( + 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` + if ( + ! ycontent.has( key ) || + ycontent.get( key ) instanceof + Array + ) { + // @todo remove the array check + ycontent.set( + key, + new Y.Array() + ); + } + /** + * @type {Y.Array>} + */ + const yblocks = ycontent.get( key ); + const numOfCommonEntries = math.min( + blocks.length, + yblocks.length + ); + let left = 0; + let right = 0; + /** + * @param {any} gblock + * @param {Y.Map} yblock + */ + const blocksEqual = ( + gblock, + yblock + ) => { + if ( yblock.toJSON ) { + yblock = yblock.toJSON(); + } + // we must not sync clientId, as this can't be generated consistenctly and + // hence will lead to merge conflicts. + const overwrites = { + innerBlocks: null, + clientId: null, + }; + const res = fun.equalityDeep( + Object.assign( + {}, + gblock, + overwrites + ), + Object.assign( + {}, + yblock, + overwrites + ) + ); + const inners = + gblock.innerBlocks || []; + const yinners = + yblock.innerBlocks || []; + return ( + res && + inners.length === + yinners.length && + inners.every( + ( block, i ) => + blocksEqual( + block, + yinners[ i ] + ) + ) + ); + }; + // skip equal blocks from left + for ( + ; + left < numOfCommonEntries && + blocksEqual( + blocks[ left ], + yblocks.get( left ) + ); + left++ + ) { + /* nop */ + } + // skip equal blocks from right + for ( + ; + right < + numOfCommonEntries - left && + blocksEqual( + blocks[ + blocks.length - + right - + 1 + ], + yblocks.get( + yblocks.length - + right - + 1 + ) + ); + right++ + ) { + /* nop */ + } + const numOfUpdatesNeeded = + numOfCommonEntries - + left - + right; + const numOfInsertionsNeeded = + math.max( + 0, + blocks.length - + yblocks.length + ); + const numOfDeletionsNeeded = + math.max( + 0, + yblocks.length - + blocks.length + ); + // updates + for ( + let i = 0; + i < numOfUpdatesNeeded; + i++, left++ + ) { + const block = blocks[ left ]; + const yblock = + yblocks.get( left ); + Object.entries( block ).forEach( + ( [ k, v ] ) => { + if ( + ! fun.equalityDeep( + block[ k ], + yblock.get( k ) + ) + ) { + yblock.set( k, v ); + } + } + ); + yblock.forEach( ( _v, k ) => { + if ( + ! block.hasOwnProperty( + k + ) + ) { + yblock.delete( k ); + } + } ); + } + // deletes + yblocks.delete( + left, + numOfDeletionsNeeded + ); + // inserts + for ( + let i = 0; + i < numOfInsertionsNeeded; + i++, left++ + ) { + yblocks.insert( left, [ + new Y.Map( + Object.entries( + blocks[ left ] + ) + ), + ] ); + } + const knownClientIds = new Set(); + // remove duplicate clientids + for ( + let j = 0; + j < yblocks.length; + j++ + ) { + const yblock = yblocks.get( j ); + if ( + knownClientIds.has( + yblock.get( 'clientId' ) + ) + ) { + yblock.set( + 'clientId', + uuidv4() + ); + } + knownClientIds.add( + yblock.get( 'clientId' ) + ); + } + } else if ( + ! filteredAttributes.has( key ) && + ! fun.equalityDeep( + ycontent.get( key ), + value + ) + ) { + ycontent.set( key, value ); + } + } } - - value = serialisableBlocksCache.get( value ); - } - - if ( document.get( key ) !== value ) { - document.set( key, value ); - } - } - } ); - }, - fromCRDTDoc: ( doc ) => { - return doc.getMap( 'document' ).toJSON(); + ); + }, 'gutenberg' ); + } }, + fromCRDTDoc: defaultYdocTransformer, + getObjectId: ( { id } ) => id, + objectType: 'postType/' + postType.name, }, - syncObjectType: 'postType/' + postType.name, - getSyncObjectId: ( id ) => id, supportsPagination: true, getRevisionsUrl: ( parentId, revisionId ) => `/${ namespace }/${ @@ -409,23 +774,43 @@ async function loadSiteEntity() { kind: 'root', baseURL: '/wp/v2/settings', syncConfig: { - fetch: async () => { - return apiFetch( { path: '/wp/v2/settings' } ); - }, applyChangesToDoc: ( doc, changes ) => { - const document = doc.getMap( 'document' ); - Object.entries( changes ).forEach( ( [ key, value ] ) => { - if ( document.get( key ) !== value ) { - document.set( key, value ); - } - } ); - }, - fromCRDTDoc: ( doc ) => { - return doc.getMap( 'document' ).toJSON(); + const content = changes.content?.raw || changes.content; + const parsedYdoc = + typeof content === 'string' + ? parseContentYdoc( 'root/site', content ) + : null; // Note: always use the same 'postType' as this object's config.syncObjectType + if ( parsedYdoc !== null ) { + // parse content which contains a ydoc, and apply it to the current ydoc. The rest of the attributes can be ignored. + Y.transact( + doc, + () => { + // apply remote changes + Y.applyUpdate( + doc, + Y.encodeStateAsUpdate( parsedYdoc ) + ); + }, + 'applyChangesToDoc', + false + ); + } else { + // local changes happened. Apply the differences to the ydoc + const ycontent = doc.getMap( 'document' ); + Object.entries( changes ).forEach( ( [ key, value ] ) => { + if ( + ! filteredAttributes.has( key ) && + ! fun.equalityDeep( ycontent.get( key ), value ) + ) { + ycontent.set( key, value ); + } + } ); + } }, + fromCRDTDoc: defaultYdocTransformer, + getObjectId: () => 'index', + objectType: 'root/site', }, - syncObjectType: 'root/site', - getSyncObjectId: () => 'index', meta: {}, }; diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 840c23ef0dc722..40d61dcd22de57 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -73,49 +73,97 @@ export const getEntityRecord = if ( ! entityConfig ) { return; } - const lock = await dispatch.__unstableAcquireStoreLock( STORE_NAME, [ 'entities', 'records', kind, name, key ], { exclusive: false } ); - try { // Entity supports configs, - // use the sync algorithm instead of the old fetch behavior. + if ( query !== undefined && query._fields ) { + // @todo how does this work? What is happening here? + // If requesting specific fields, items and query association to said + // records are stored by ID reference. Thus, fields must always include + // the ID. + query = { + ...query, + _fields: [ + ...new Set( [ + ...( getNormalizedCommaSeparable( query._fields ) || + [] ), + entityConfig.key || DEFAULT_ENTITY_KEY, + ] ), + ].join(), + }; + } + + // Disable reason: While true that an early return could leave `path` + // unused, it's important that path is derived using the query prior to + // additional query modifications in the condition below, since those + // modifications are relevant to how the data is tracked in state, and not + // for how the request is made to the REST API. + + // eslint-disable-next-line @wordpress/no-unused-vars-before-return + const path = addQueryArgs( + entityConfig.baseURL + ( key ? '/' + key : '' ), + { + ...entityConfig.baseURLParams, + ...query, + } + ); + + if ( query !== undefined && query._fields ) { + query = { ...query, include: [ key ] }; + + // The resolution cache won't consider query as reusable based on the + // fields, so it's tested here, prior to initiating the REST request, + // and without causing `getEntityRecords` resolution to occur. + const hasRecords = select.hasEntityRecords( kind, name, query ); + if ( hasRecords ) { + return; + } + } + + const response = await apiFetch( { path, parse: false } ); + const record = await response.json(); + const permissions = getUserPermissionsFromAllowHeader( + response.headers?.get( 'allow' ) + ); + + const canUserResolutionsArgs = []; + const receiveUserPermissionArgs = {}; + for ( const action of ALLOWED_RESOURCE_ACTIONS ) { + receiveUserPermissionArgs[ + getUserPermissionCacheKey( action, { + kind, + name, + id: key, + } ) + ] = permissions[ action ]; + + canUserResolutionsArgs.push( [ + action, + { kind, name, id: key }, + ] ); + } + if ( window.__experimentalEnableSync && entityConfig.syncConfig && ! query ) { if ( globalThis.IS_GUTENBERG_PLUGIN ) { - const objectId = entityConfig.getSyncObjectId( key ); - // Loads the persisted document. await getSyncProvider().bootstrap( - entityConfig.syncObjectType, - objectId, - ( record ) => { - dispatch.receiveEntityRecords( - kind, - name, - record, - query - ); - } - ); - - // Bootstraps the edited document as well (and load from peers). - await getSyncProvider().bootstrap( - entityConfig.syncObjectType + '--edit', - objectId, - ( record ) => { + entityConfig.syncConfig, + record, + ( edits ) => { dispatch( { type: 'EDIT_ENTITY_RECORD', kind, name, recordId: key, - edits: record, + edits, meta: { undo: undefined, }, @@ -123,89 +171,13 @@ export const getEntityRecord = } ); } - } else { - if ( query !== undefined && query._fields ) { - // If requesting specific fields, items and query association to said - // records are stored by ID reference. Thus, fields must always include - // the ID. - query = { - ...query, - _fields: [ - ...new Set( [ - ...( getNormalizedCommaSeparable( - query._fields - ) || [] ), - entityConfig.key || DEFAULT_ENTITY_KEY, - ] ), - ].join(), - }; - } - - // Disable reason: While true that an early return could leave `path` - // unused, it's important that path is derived using the query prior to - // additional query modifications in the condition below, since those - // modifications are relevant to how the data is tracked in state, and not - // for how the request is made to the REST API. - - // eslint-disable-next-line @wordpress/no-unused-vars-before-return - const path = addQueryArgs( - entityConfig.baseURL + ( key ? '/' + key : '' ), - { - ...entityConfig.baseURLParams, - ...query, - } - ); - - if ( query !== undefined && query._fields ) { - query = { ...query, include: [ key ] }; - - // The resolution cache won't consider query as reusable based on the - // fields, so it's tested here, prior to initiating the REST request, - // and without causing `getEntityRecords` resolution to occur. - const hasRecords = select.hasEntityRecords( - kind, - name, - query - ); - if ( hasRecords ) { - return; - } - } - - const response = await apiFetch( { path, parse: false } ); - const record = await response.json(); - const permissions = getUserPermissionsFromAllowHeader( - response.headers?.get( 'allow' ) - ); - - const canUserResolutionsArgs = []; - const receiveUserPermissionArgs = {}; - for ( const action of ALLOWED_RESOURCE_ACTIONS ) { - receiveUserPermissionArgs[ - getUserPermissionCacheKey( action, { - kind, - name, - id: key, - } ) - ] = permissions[ action ]; - - canUserResolutionsArgs.push( [ - action, - { kind, name, id: key }, - ] ); - } - - registry.batch( () => { - dispatch.receiveEntityRecords( kind, name, record, query ); - dispatch.receiveUserPermissions( - receiveUserPermissionArgs - ); - dispatch.finishResolutions( - 'canUser', - canUserResolutionsArgs - ); - } ); } + + registry.batch( () => { + dispatch.receiveEntityRecords( kind, name, record, query ); + dispatch.receiveUserPermissions( receiveUserPermissionArgs ); + dispatch.finishResolutions( 'canUser', canUserResolutionsArgs ); + } ); } finally { dispatch.__unstableReleaseStoreLock( lock ); } diff --git a/packages/core-data/src/sync.ts b/packages/core-data/src/sync.ts index a9295508c1ecbc..0e54c45ef858fe 100644 --- a/packages/core-data/src/sync.ts +++ b/packages/core-data/src/sync.ts @@ -26,11 +26,11 @@ export function getSyncProvider(): SyncProvider { return syncProvider; } - const fallbackNoOpSyncProvider = { + const fallbackNoOpSyncProvider: SyncProvider = { __fallback: true, bootstrap: async () => {}, + configs: new Map(), discard: async () => {}, - register: () => {}, update: () => {}, }; diff --git a/packages/core-data/tsconfig.json b/packages/core-data/tsconfig.json index 31800502dae598..da362841c47955 100644 --- a/packages/core-data/tsconfig.json +++ b/packages/core-data/tsconfig.json @@ -3,7 +3,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "checkJs": false, - "noImplicitAny": false + "noImplicitAny": false, + "types": [ "node" ] }, "references": [ { "path": "../api-fetch" }, diff --git a/packages/dependency-extraction-webpack-plugin/lib/util.js b/packages/dependency-extraction-webpack-plugin/lib/util.js index b5c9f9057c2052..11d24cb433026a 100644 --- a/packages/dependency-extraction-webpack-plugin/lib/util.js +++ b/packages/dependency-extraction-webpack-plugin/lib/util.js @@ -8,7 +8,6 @@ const BUNDLED_PACKAGES = [ '@wordpress/dataviews/wp', '@wordpress/icons', '@wordpress/interface', - '@wordpress/sync', '@wordpress/undo-manager', '@wordpress/upload-media', '@wordpress/fields', diff --git a/packages/sync/CODE.md b/packages/sync/CODE.md index ae27b2541716dc..d61dac12824e89 100644 --- a/packages/sync/CODE.md +++ b/packages/sync/CODE.md @@ -1,6 +1,6 @@ # Status of the sync experiment in Gutenberg -The sync package is part of an ongoing research effort to lay the groundwork of Real-Time Collaboration in Gutenberg. +The sync package is part of an ongoing effort to lay the groundwork of Real-Time Collaboration in Gutenberg. Relevant docs: diff --git a/packages/sync/package.json b/packages/sync/package.json index 6dac3bd2f7532b..c5b9e8be1f1c2d 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -29,16 +29,15 @@ "types": "build-types", "sideEffects": false, "dependencies": { - "@babel/runtime": "7.25.7", "@types/simple-peer": "^9.11.5", + "@wordpress/hooks": "file:../hooks", "@wordpress/url": "file:../url", - "import-locals": "^2.0.0", - "lib0": "^0.2.42", + "lib0": "^0.2.99", "simple-peer": "^9.11.0", - "y-indexeddb": "~9.0.11", + "y-indexeddb": "^9.0.11", "y-protocols": "^1.0.5", - "y-webrtc": "~10.2.5", - "yjs": "~13.6.6" + "y-webrtc": "^10.2.5", + "yjs": "13.6.27" }, "publishConfig": { "access": "public" diff --git a/packages/sync/src/connect-indexdb.js b/packages/sync/src/connect-indexdb.js index 5523640408f575..c7ef7261fadf87 100644 --- a/packages/sync/src/connect-indexdb.js +++ b/packages/sync/src/connect-indexdb.js @@ -8,7 +8,7 @@ import { IndexeddbPersistence } from 'y-indexeddb'; /** @typedef {import('./types').ObjectID} ObjectID */ /** @typedef {import('./types').CRDTDoc} CRDTDoc */ /** @typedef {import('./types').ConnectDoc} ConnectDoc */ -/** @typedef {import('./types').SyncProvider} SyncProvider */ +/** @typedef {import('./types').ConnectDocResult} ConnectDocResult */ /** * Connect function to the IndexedDB persistence provider. @@ -17,15 +17,14 @@ import { IndexeddbPersistence } from 'y-indexeddb'; * @param {ObjectType} objectType The object type. * @param {CRDTDoc} doc The CRDT document. * - * @return {Promise<() => void>} Promise that resolves when the connection is established. + * @return {Promise< ConnectDocResult >} Promise that resolves when the connection is established. */ export function connectIndexDb( objectId, objectType, doc ) { const roomName = `${ objectType }-${ objectId }`; const provider = new IndexeddbPersistence( roomName, doc ); - return new Promise( ( resolve ) => { - provider.on( 'synced', () => { - resolve( () => provider.destroy() ); - } ); + return Promise.resolve( { + awareness: null, + destroy: () => provider.destroy(), } ); } diff --git a/packages/sync/src/create-webrtc-connection.ts b/packages/sync/src/create-webrtc-connection.ts index 456b68694b5da2..f32b9e31675bf5 100644 --- a/packages/sync/src/create-webrtc-connection.ts +++ b/packages/sync/src/create-webrtc-connection.ts @@ -35,6 +35,9 @@ export function createWebRTCConnection( { password, } ); - return Promise.resolve( () => true ); + return Promise.resolve( { + awareness: null, + destroy: () => {}, + } ); }; } diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index 8179d787ef86cc..cbf4ebeb3b8564 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -10,11 +10,25 @@ import { createWebRTCConnection } from './create-webrtc-connection'; import { createSyncProvider } from './provider'; import type { SyncProvider } from './types'; +export * as Y from 'yjs'; export { connectIndexDb } from './connect-indexdb'; export { createWebRTCConnection } from './create-webrtc-connection'; export { createSyncProvider } from './provider'; export * from './types'; +declare global { + interface Window { + __experimentalCollaborativeEditingSecret?: string; + wp: { + ajax: { + settings: { + url: string; + }; + }; + }; + } +} + /** * Returns a WebRTC sync provider. This is the curent default sync provider. * diff --git a/packages/sync/src/provider.js b/packages/sync/src/provider.js deleted file mode 100644 index 0be1dedab5d308..00000000000000 --- a/packages/sync/src/provider.js +++ /dev/null @@ -1,128 +0,0 @@ -/** - * External dependencies - */ -// @ts-ignore -import * as Y from 'yjs'; - -/** @typedef {import('./types').ObjectType} ObjectType */ -/** @typedef {import('./types').ObjectID} ObjectID */ -/** @typedef {import('./types').ObjectConfig} ObjectConfig */ -/** @typedef {import('./types').CRDTDoc} CRDTDoc */ -/** @typedef {import('./types').ConnectDoc} ConnectDoc */ -/** @typedef {import('./types').SyncProvider} SyncProvider */ - -/** - * Create a sync provider. - * - * @param {ConnectDoc} connectLocal Connect the document to a local database. - * @param {ConnectDoc} connectRemote Connect the document to a remote sync connection. - * @return {SyncProvider} Sync provider. - */ -export const createSyncProvider = ( connectLocal, connectRemote ) => { - /** - * @type {Record} - */ - const config = {}; - - /** - * @type {Recordvoid>>} - */ - const listeners = {}; - - /** - * @type {Record>} - */ - const docs = {}; - - /** - * Registers an object type. - * - * @param {ObjectType} objectType Object type to register. - * @param {ObjectConfig} objectConfig Object config. - */ - function register( objectType, objectConfig ) { - config[ objectType ] = objectConfig; - } - - /** - * Fetch data from local database or remote source. - * - * @param {ObjectType} objectType Object type to load. - * @param {ObjectID} objectId Object ID to load. - * @param {Function} handleChanges Callback to call when data changes. - */ - async function bootstrap( objectType, objectId, handleChanges ) { - const doc = new Y.Doc(); - docs[ objectType ] = docs[ objectType ] || {}; - docs[ objectType ][ objectId ] = doc; - - const updateHandler = () => { - const data = config[ objectType ].fromCRDTDoc( doc ); - handleChanges( data ); - }; - doc.on( 'update', updateHandler ); - - // connect to locally saved database. - const destroyLocalConnection = await connectLocal( - objectId, - objectType, - doc - ); - - // Once the database syncing is done, start the remote syncing - if ( connectRemote ) { - await connectRemote( objectId, objectType, doc ); - } - - const loadRemotely = config[ objectType ].fetch; - if ( loadRemotely ) { - loadRemotely( objectId ).then( ( data ) => { - doc.transact( () => { - config[ objectType ].applyChangesToDoc( doc, data ); - } ); - } ); - } - - listeners[ objectType ] = listeners[ objectType ] || {}; - listeners[ objectType ][ objectId ] = () => { - destroyLocalConnection(); - doc.off( 'update', updateHandler ); - }; - } - - /** - * Fetch data from local database or remote source. - * - * @param {ObjectType} objectType Object type to load. - * @param {ObjectID} objectId Object ID to load. - * @param {any} data Updates to make. - */ - async function update( objectType, objectId, data ) { - const doc = docs[ objectType ][ objectId ]; - if ( ! doc ) { - throw 'Error doc ' + objectType + ' ' + objectId + ' not found'; - } - doc.transact( () => { - config[ objectType ].applyChangesToDoc( doc, data ); - } ); - } - - /** - * Stop updating a document and discard it. - * - * @param {ObjectType} objectType Object type to load. - * @param {ObjectID} objectId Object ID to load. - */ - async function discard( objectType, objectId ) { - if ( listeners?.[ objectType ]?.[ objectId ] ) { - listeners[ objectType ][ objectId ](); - } - } - - return { - register, - bootstrap, - update, - discard, - }; -}; diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts new file mode 100644 index 00000000000000..e80a99814d930d --- /dev/null +++ b/packages/sync/src/provider.ts @@ -0,0 +1,258 @@ +/** + * WordPress dependencies + */ + +/** + * External dependencies + */ +import type { Awareness } from 'y-protocols/awareness'; +import { removeAwarenessStates as removeAwarenessStatesFromProtocol } from 'y-protocols/awareness'; +import * as Y from 'yjs'; + +/** + * Internal dependencies + */ +import type { + AwarenessClientID, + AwarenessEventListener, + AwarenessStates, + ConnectDoc, + ConnectDocResult, + ObjectID, + ObjectData, + ObjectType, + SyncConfig, + SyncProvider, +} from './types'; + +interface EntityState { + awareness: Awareness | null; + destroy: () => void; + prevContentClientId: AwarenessClientID; + ydoc: Y.Doc; +} + +interface PendingAwarenessSetup { + pendingListeners: [ string, AwarenessEventListener ][]; + pendingStateFields: Map< string, unknown >; +} + +/** + * Create a sync provider. + * + * @param {ConnectDoc | null} connectLocal Connect the document to a local database. + * @param {ConnectDoc | null} connectRemote Connect the document to a remote sync connection. + * @return {SyncProvider} Sync provider. + */ +export const createSyncProvider = ( + connectLocal: ConnectDoc | null, + connectRemote: ConnectDoc | null +): SyncProvider => { + const configs: Map< ObjectType, SyncConfig > = new Map< + ObjectType, + SyncConfig + >(); + const entityStates: Map< string, EntityState > = new Map< + string, + EntityState + >(); + + const pendingAwarenessSetup: PendingAwarenessSetup = { + pendingListeners: [], + pendingStateFields: new Map< string, unknown >(), + }; + + /** + * Fetch data from local database or remote source. + * + * @param {SyncConfig} syncConfig Sync configuration for the object type. + * @param {ObjectData} initialData Initial data to apply to the document. + * @param {Function} handleChanges Callback to call when data changes. + */ + async function bootstrap( + syncConfig: SyncConfig, + initialData: ObjectData, + handleChanges: ( data: Partial< ObjectData > ) => void + ): Promise< void > { + const ydoc = new Y.Doc( { meta: new Map() } ); + const objectId = syncConfig.getObjectId( initialData ); + const objectType = syncConfig.objectType; + const entityId = `${ objectType }_${ objectId }`; + + configs.set( objectType, syncConfig ); + + const updateHandler: ( _update: Uint8Array, origin: string ) => void = ( + _update, + origin + ): void => { + if ( origin !== 'gutenberg' ) { + const data = syncConfig.fromCRDTDoc( ydoc ); + handleChanges( data ); + } + }; + + ydoc.on( 'update', updateHandler ); + + const connectLocalResult: ConnectDocResult | null = + ( await connectLocal?.( objectId, objectType, ydoc ) ) ?? null; + const connectRemoteResult = + ( await connectRemote?.( objectId, objectType, ydoc ) ) ?? null; + + const entityState: EntityState = { + awareness: connectRemoteResult?.awareness || null, + destroy: () => { + connectLocalResult?.destroy?.(); + connectRemoteResult?.destroy?.(); + + ydoc.off( 'update', updateHandler ); + ydoc.destroy(); + entityStates.delete( entityId ); + }, + prevContentClientId: 0, + ydoc, + }; + + entityStates.set( entityId, entityState ); + + bootstrapAwareness( connectRemoteResult?.awareness ?? null ); + update( objectType, initialData, initialData, 'gutenberg' ); + } + + /** + * Fetch data from local database or remote source. + * + * @param {ObjectType} objectType Object type to load. + * @param {ObjectData} record Record to load. + * @param {Partial< ObjectData >} changes Updates to make. + * @param {string} origin The source of change. + */ + function update( + objectType: ObjectType, + record: ObjectData, + changes: Partial< ObjectData >, + origin: string + ) { + const objectId = configs.get( objectType )?.getObjectId( record ); + const entityId = `${ objectType }_${ objectId }`; + const entityState = entityStates.get( entityId ); + + if ( ! entityState ) { + throw new Error( + `Entity ${ objectType }:${ objectId } not found ` + ); + } + + entityState.ydoc.transact( () => { + configs + .get( objectType ) + ?.applyChangesToDoc( entityState.ydoc, changes ); + }, origin ); + } + + /** + * Stop updating a document and discard it. + * + * @param {ObjectType} objectType Object type to load. + * @param {ObjectID} objectId Object ID to load. + */ + function discard( objectType: ObjectType, objectId: ObjectID ) { + const entityId = `${ objectType }_${ objectId }`; + + entityStates.get( entityId )?.destroy(); + entityStates.delete( entityId ); + } + + // Awareness handlers + + /** + * Add a listener for awareness events. + * + * @param {'update'|'change'} eventType Event type. + * @param {AwarenessEventListener} awarenessEventListener Awareness event listener. + */ + function addListener( + eventType: 'update' | 'change', + awarenessEventListener: AwarenessEventListener + ) { + Array.from( entityStates.values() ).forEach( ( entityState ) => { + entityState.awareness?.on( eventType, awarenessEventListener ); + } ); + + pendingAwarenessSetup.pendingListeners.push( [ + eventType, + awarenessEventListener, + ] ); + } + + function bootstrapAwareness( awareness: Awareness | null ) { + if ( ! awareness ) { + return; + } + + pendingAwarenessSetup.pendingListeners.forEach( + ( [ eventType, listener ]: [ string, AwarenessEventListener ] ) => { + awareness.on( eventType, listener ); + } + ); + + Array.from( + pendingAwarenessSetup.pendingStateFields.entries() + ).forEach( ( [ field, value ]: [ string, unknown ] ) => { + awareness.setLocalStateField( field, value ); + } ); + } + + /** + * Get the states of all awareness documents. + */ + function getStates(): AwarenessStates { + return ( + Array.from( entityStates.values() ) + .find( ( entityState ) => entityState.awareness ) + ?.awareness?.getStates() ?? new Map() + ); + } + + /** + * Removes the states of all awareness documents. + */ + function removeStates(): void { + Array.from( entityStates.values() ).forEach( ( entityState ) => { + if ( entityState.awareness ) { + removeAwarenessStatesFromProtocol( + entityState.awareness, + [ entityState.awareness.clientID ], + 'removeAwarenessStates' + ); + } + } ); + } + + /** + * Set a local state field on all awareness documents. + * + * @param {string} field Field name. + * @param {any} value State value. + */ + function setLocalState( field: string, value: unknown ) { + Array.from( entityStates.values() ).forEach( ( entityState ) => { + entityState.awareness?.setLocalStateField( field, value ); + } ); + + pendingAwarenessSetup.pendingStateFields.set( field, value ); + } + + return { + bootstrap, + configs, + discard, + update, + + awarenessManager: { + addListener, + getStates, + removeStates, + setLocalState, + }, + }; +}; diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index c141b3e17d6eb8..9526d0002ab587 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -1,44 +1,71 @@ /** * External dependencies */ +import type { Awareness } from 'y-protocols/awareness'; import type * as Y from 'yjs'; -declare global { - interface Window { - __experimentalCollaborativeEditingSecret?: string; - wp: { - ajax: { - settings: { - url: string; - }; - }; - }; - } -} - +export type * as Y from 'yjs'; export type ObjectID = string; export type ObjectType = string; +export type ObjectData = object; +export type UndoManager = Y.UndoManager; + +export type AwarenessClientID = number; + +export type AwarenessEventListener = ( params: { + added: AwarenessClientID[]; + updated: AwarenessClientID[]; + removed: AwarenessClientID[]; +} ) => void; + +export type AwarenessStates = Map< + AwarenessClientID, + Record< string, unknown > +>; + export type CRDTDoc = Y.Doc; -export type ObjectConfig = { - applyChangesToDoc: ( doc: CRDTDoc, data: object ) => void; - fetch: ( id: ObjectID ) => Promise< object >; - fromCRDTDoc: ( doc: CRDTDoc ) => any; +export type ConnectDocResult = { + awareness: Awareness | null; + destroy: () => void; }; export type ConnectDoc = ( id: ObjectID, type: ObjectType, - doc: CRDTDoc -) => Promise< () => void >; + ydoc: Y.Doc +) => Promise< ConnectDocResult >; + +export type SyncConfig = { + applyChangesToDoc: ( ydoc: Y.Doc, data: Partial< ObjectData > ) => void; + fromCRDTDoc: ( ydoc: Y.Doc ) => ObjectData; + getObjectId: ( data: ObjectData ) => ObjectID; + objectType: ObjectType; +}; export type SyncProvider = { + __fallback?: boolean; bootstrap: ( - type: ObjectType, - id: ObjectID, - handleChanges: ( data: object ) => void + syncConfig: SyncConfig, + initialData: ObjectData, + handleChanges: ( data: Partial< ObjectData > ) => void ) => Promise< void >; - discard: ( type: ObjectType, id: ObjectID ) => Promise< void >; - register: ( type: ObjectType, config: ObjectConfig ) => void; - update: ( type: ObjectType, id: ObjectID, data: object ) => void; + configs: Map< ObjectType, SyncConfig >; + discard: ( type: ObjectType, id: ObjectID ) => void; + update: ( + type: ObjectType, + record: ObjectData, + changes: Partial< ObjectData >, + origin: string + ) => void; + + awarenessManager?: { + addListener: ( + eventType: 'update' | 'change', + listener: AwarenessEventListener + ) => void; + getStates: () => AwarenessStates; + setLocalState: ( field: string, value: unknown ) => void; + removeStates: () => void; + }; }; diff --git a/tools/webpack/packages.js b/tools/webpack/packages.js index c99c25ee0127ce..f847d67e01f455 100644 --- a/tools/webpack/packages.js +++ b/tools/webpack/packages.js @@ -39,7 +39,6 @@ const BUNDLED_PACKAGES = [ '@wordpress/dataviews/wp', '@wordpress/icons', '@wordpress/interface', - '@wordpress/sync', '@wordpress/undo-manager', '@wordpress/upload-media', '@wordpress/fields', From b6c1894116c9c42cb729409f7edd26898767cd0e Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 29 Jul 2025 16:39:25 -0400 Subject: [PATCH 17/99] Remove awareness implementation --- packages/sync/src/provider.ts | 107 ---------------------------------- packages/sync/src/types.ts | 25 -------- 2 files changed, 132 deletions(-) diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index e80a99814d930d..d590354d898d60 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -5,17 +5,12 @@ /** * External dependencies */ -import type { Awareness } from 'y-protocols/awareness'; -import { removeAwarenessStates as removeAwarenessStatesFromProtocol } from 'y-protocols/awareness'; import * as Y from 'yjs'; /** * Internal dependencies */ import type { - AwarenessClientID, - AwarenessEventListener, - AwarenessStates, ConnectDoc, ConnectDocResult, ObjectID, @@ -26,17 +21,10 @@ import type { } from './types'; interface EntityState { - awareness: Awareness | null; destroy: () => void; - prevContentClientId: AwarenessClientID; ydoc: Y.Doc; } -interface PendingAwarenessSetup { - pendingListeners: [ string, AwarenessEventListener ][]; - pendingStateFields: Map< string, unknown >; -} - /** * Create a sync provider. * @@ -57,11 +45,6 @@ export const createSyncProvider = ( EntityState >(); - const pendingAwarenessSetup: PendingAwarenessSetup = { - pendingListeners: [], - pendingStateFields: new Map< string, unknown >(), - }; - /** * Fetch data from local database or remote source. * @@ -99,7 +82,6 @@ export const createSyncProvider = ( ( await connectRemote?.( objectId, objectType, ydoc ) ) ?? null; const entityState: EntityState = { - awareness: connectRemoteResult?.awareness || null, destroy: () => { connectLocalResult?.destroy?.(); connectRemoteResult?.destroy?.(); @@ -108,13 +90,11 @@ export const createSyncProvider = ( ydoc.destroy(); entityStates.delete( entityId ); }, - prevContentClientId: 0, ydoc, }; entityStates.set( entityId, entityState ); - bootstrapAwareness( connectRemoteResult?.awareness ?? null ); update( objectType, initialData, initialData, 'gutenberg' ); } @@ -162,97 +142,10 @@ export const createSyncProvider = ( entityStates.delete( entityId ); } - // Awareness handlers - - /** - * Add a listener for awareness events. - * - * @param {'update'|'change'} eventType Event type. - * @param {AwarenessEventListener} awarenessEventListener Awareness event listener. - */ - function addListener( - eventType: 'update' | 'change', - awarenessEventListener: AwarenessEventListener - ) { - Array.from( entityStates.values() ).forEach( ( entityState ) => { - entityState.awareness?.on( eventType, awarenessEventListener ); - } ); - - pendingAwarenessSetup.pendingListeners.push( [ - eventType, - awarenessEventListener, - ] ); - } - - function bootstrapAwareness( awareness: Awareness | null ) { - if ( ! awareness ) { - return; - } - - pendingAwarenessSetup.pendingListeners.forEach( - ( [ eventType, listener ]: [ string, AwarenessEventListener ] ) => { - awareness.on( eventType, listener ); - } - ); - - Array.from( - pendingAwarenessSetup.pendingStateFields.entries() - ).forEach( ( [ field, value ]: [ string, unknown ] ) => { - awareness.setLocalStateField( field, value ); - } ); - } - - /** - * Get the states of all awareness documents. - */ - function getStates(): AwarenessStates { - return ( - Array.from( entityStates.values() ) - .find( ( entityState ) => entityState.awareness ) - ?.awareness?.getStates() ?? new Map() - ); - } - - /** - * Removes the states of all awareness documents. - */ - function removeStates(): void { - Array.from( entityStates.values() ).forEach( ( entityState ) => { - if ( entityState.awareness ) { - removeAwarenessStatesFromProtocol( - entityState.awareness, - [ entityState.awareness.clientID ], - 'removeAwarenessStates' - ); - } - } ); - } - - /** - * Set a local state field on all awareness documents. - * - * @param {string} field Field name. - * @param {any} value State value. - */ - function setLocalState( field: string, value: unknown ) { - Array.from( entityStates.values() ).forEach( ( entityState ) => { - entityState.awareness?.setLocalStateField( field, value ); - } ); - - pendingAwarenessSetup.pendingStateFields.set( field, value ); - } - return { bootstrap, configs, discard, update, - - awarenessManager: { - addListener, - getStates, - removeStates, - setLocalState, - }, }; }; diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 9526d0002ab587..e33ae936e41037 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -1,7 +1,6 @@ /** * External dependencies */ -import type { Awareness } from 'y-protocols/awareness'; import type * as Y from 'yjs'; export type * as Y from 'yjs'; @@ -10,23 +9,9 @@ export type ObjectType = string; export type ObjectData = object; export type UndoManager = Y.UndoManager; -export type AwarenessClientID = number; - -export type AwarenessEventListener = ( params: { - added: AwarenessClientID[]; - updated: AwarenessClientID[]; - removed: AwarenessClientID[]; -} ) => void; - -export type AwarenessStates = Map< - AwarenessClientID, - Record< string, unknown > ->; - export type CRDTDoc = Y.Doc; export type ConnectDocResult = { - awareness: Awareness | null; destroy: () => void; }; @@ -58,14 +43,4 @@ export type SyncProvider = { changes: Partial< ObjectData >, origin: string ) => void; - - awarenessManager?: { - addListener: ( - eventType: 'update' | 'change', - listener: AwarenessEventListener - ) => void; - getStates: () => AwarenessStates; - setLocalState: ( field: string, value: unknown ) => void; - removeStates: () => void; - }; }; From 5b6c44aaf646152c63a7d298165ada230fdc8813 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 30 Jul 2025 13:46:33 -0400 Subject: [PATCH 18/99] Convert SyncProvider to class --- packages/core-data/src/sync.ts | 11 +-- packages/sync/src/index.ts | 7 +- packages/sync/src/provider.ts | 127 ++++++++++++++++++++------------- packages/sync/src/types.ts | 17 ----- 4 files changed, 81 insertions(+), 81 deletions(-) diff --git a/packages/core-data/src/sync.ts b/packages/core-data/src/sync.ts index 0e54c45ef858fe..b52faff570dc4c 100644 --- a/packages/core-data/src/sync.ts +++ b/packages/core-data/src/sync.ts @@ -2,8 +2,7 @@ * WordPress dependencies */ import { applyFilters } from '@wordpress/hooks'; -import { getWebRTCSyncProvider } from '@wordpress/sync'; -import type { SyncProvider } from '@wordpress/sync'; +import { getWebRTCSyncProvider, SyncProvider } from '@wordpress/sync'; declare global { interface Window { @@ -26,13 +25,7 @@ export function getSyncProvider(): SyncProvider { return syncProvider; } - const fallbackNoOpSyncProvider: SyncProvider = { - __fallback: true, - bootstrap: async () => {}, - configs: new Map(), - discard: async () => {}, - update: () => {}, - }; + const fallbackNoOpSyncProvider = new SyncProvider( null, null ); syncProvider = applyFilters( 'core.getSyncProvider', diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index cbf4ebeb3b8564..a630969d6d49b8 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -7,13 +7,12 @@ */ import { connectIndexDb } from './connect-indexdb'; import { createWebRTCConnection } from './create-webrtc-connection'; -import { createSyncProvider } from './provider'; -import type { SyncProvider } from './types'; +import { SyncProvider } from './provider'; export * as Y from 'yjs'; export { connectIndexDb } from './connect-indexdb'; export { createWebRTCConnection } from './create-webrtc-connection'; -export { createSyncProvider } from './provider'; +export { SyncProvider } from './provider'; export * from './types'; declare global { @@ -35,7 +34,7 @@ declare global { * @return {SyncProvider} The WebRTC sync provider. */ export function getWebRTCSyncProvider(): SyncProvider { - return createSyncProvider( + return new SyncProvider( connectIndexDb, createWebRTCConnection( { password: window?.__experimentalCollaborativeEditingSecret, diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index d590354d898d60..010ecfd773e145 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -1,7 +1,3 @@ -/** - * WordPress dependencies - */ - /** * External dependencies */ @@ -13,38 +9,46 @@ import * as Y from 'yjs'; import type { ConnectDoc, ConnectDocResult, + CRDTDoc, ObjectID, ObjectData, ObjectType, SyncConfig, - SyncProvider, } from './types'; interface EntityState { destroy: () => void; - ydoc: Y.Doc; + ydoc: CRDTDoc; } -/** - * Create a sync provider. - * - * @param {ConnectDoc | null} connectLocal Connect the document to a local database. - * @param {ConnectDoc | null} connectRemote Connect the document to a remote sync connection. - * @return {SyncProvider} Sync provider. - */ -export const createSyncProvider = ( - connectLocal: ConnectDoc | null, - connectRemote: ConnectDoc | null -): SyncProvider => { - const configs: Map< ObjectType, SyncConfig > = new Map< +export class SyncProvider { + protected connectLocal: ConnectDoc | null; + protected connectRemote: ConnectDoc | null; + + protected configs: Map< ObjectType, SyncConfig > = new Map< ObjectType, SyncConfig >(); - const entityStates: Map< string, EntityState > = new Map< + + protected entityStates: Map< string, EntityState > = new Map< string, EntityState >(); + /** + * Constructor. + * + * @param {ConnectDoc | null} connectLocal Connect the document to a local database. + * @param {ConnectDoc | null} connectRemote Connect the document to a remote sync connection. + */ + public constructor( + connectLocal: ConnectDoc | null, + connectRemote: ConnectDoc | null + ) { + this.connectLocal = connectLocal; + this.connectRemote = connectRemote; + } + /** * Fetch data from local database or remote source. * @@ -52,7 +56,7 @@ export const createSyncProvider = ( * @param {ObjectData} initialData Initial data to apply to the document. * @param {Function} handleChanges Callback to call when data changes. */ - async function bootstrap( + public async bootstrap( syncConfig: SyncConfig, initialData: ObjectData, handleChanges: ( data: Partial< ObjectData > ) => void @@ -60,9 +64,9 @@ export const createSyncProvider = ( const ydoc = new Y.Doc( { meta: new Map() } ); const objectId = syncConfig.getObjectId( initialData ); const objectType = syncConfig.objectType; - const entityId = `${ objectType }_${ objectId }`; + const entityId = this.getEntityId( objectType, objectId ); - configs.set( objectType, syncConfig ); + this.configs.set( objectType, syncConfig ); const updateHandler: ( _update: Uint8Array, origin: string ) => void = ( _update, @@ -77,9 +81,10 @@ export const createSyncProvider = ( ydoc.on( 'update', updateHandler ); const connectLocalResult: ConnectDocResult | null = - ( await connectLocal?.( objectId, objectType, ydoc ) ) ?? null; + ( await this.connectLocal?.( objectId, objectType, ydoc ) ) ?? null; const connectRemoteResult = - ( await connectRemote?.( objectId, objectType, ydoc ) ) ?? null; + ( await this.connectRemote?.( objectId, objectType, ydoc ) ) ?? + null; const entityState: EntityState = { destroy: () => { @@ -88,14 +93,43 @@ export const createSyncProvider = ( ydoc.off( 'update', updateHandler ); ydoc.destroy(); - entityStates.delete( entityId ); + this.entityStates.delete( entityId ); }, ydoc, }; - entityStates.set( entityId, entityState ); + this.entityStates.set( entityId, entityState ); - update( objectType, initialData, initialData, 'gutenberg' ); + this.update( objectType, initialData, initialData, 'gutenberg' ); + } + + /** + * Get the entity ID for the given object type and object ID. + * + * @param {ObjectType} objectType Object type. + * @param {ObjectID} objectId Object ID. + */ + protected getEntityId( + objectType: ObjectType, + objectId: ObjectID + ): string { + return `${ objectType }_${ objectId }`; + } + + /** + * Get the entity state for the given object type and object ID. + * + * @param {ObjectType} objectType Object type. + * @param {ObjectID} objectId Object ID. + */ + protected getEntityState( + objectType: ObjectType, + objectId: ObjectID + ): EntityState | null { + return ( + this.entityStates.get( this.getEntityId( objectType, objectId ) ) ?? + null + ); } /** @@ -106,24 +140,22 @@ export const createSyncProvider = ( * @param {Partial< ObjectData >} changes Updates to make. * @param {string} origin The source of change. */ - function update( + public update( objectType: ObjectType, record: ObjectData, changes: Partial< ObjectData >, origin: string - ) { - const objectId = configs.get( objectType )?.getObjectId( record ); - const entityId = `${ objectType }_${ objectId }`; - const entityState = entityStates.get( entityId ); + ): void { + const objectId = this.configs.get( objectType )?.getObjectId( record ); - if ( ! entityState ) { - throw new Error( - `Entity ${ objectType }:${ objectId } not found ` - ); + if ( ! objectId ) { + return; } - entityState.ydoc.transact( () => { - configs + const entityState = this.getEntityState( objectType, objectId ); + + entityState?.ydoc.transact( () => { + this.configs .get( objectType ) ?.applyChangesToDoc( entityState.ydoc, changes ); }, origin ); @@ -132,20 +164,13 @@ export const createSyncProvider = ( /** * Stop updating a document and discard it. * - * @param {ObjectType} objectType Object type to load. - * @param {ObjectID} objectId Object ID to load. + * @param {ObjectType} objectType Object type to discard. + * @param {ObjectID} objectId Object ID to discard. */ - function discard( objectType: ObjectType, objectId: ObjectID ) { + public discard( objectType: ObjectType, objectId: ObjectID ): void { const entityId = `${ objectType }_${ objectId }`; - entityStates.get( entityId )?.destroy(); - entityStates.delete( entityId ); + this.getEntityState( objectType, objectId )?.destroy(); + this.entityStates.delete( entityId ); } - - return { - bootstrap, - configs, - discard, - update, - }; -}; +} diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index e33ae936e41037..f4a502381a04ca 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -27,20 +27,3 @@ export type SyncConfig = { getObjectId: ( data: ObjectData ) => ObjectID; objectType: ObjectType; }; - -export type SyncProvider = { - __fallback?: boolean; - bootstrap: ( - syncConfig: SyncConfig, - initialData: ObjectData, - handleChanges: ( data: Partial< ObjectData > ) => void - ) => Promise< void >; - configs: Map< ObjectType, SyncConfig >; - discard: ( type: ObjectType, id: ObjectID ) => void; - update: ( - type: ObjectType, - record: ObjectData, - changes: Partial< ObjectData >, - origin: string - ) => void; -}; From d3a4e3e9d8e34dc4e76c7b843ee75b8b4ec83aa2 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Fri, 1 Aug 2025 13:55:28 +1000 Subject: [PATCH 19/99] Update the package-lock.json and bump the y- packages --- package-lock.json | 181 ++++++++++++++++++++++--------------- packages/sync/package.json | 6 +- 2 files changed, 113 insertions(+), 74 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6cfbb804550fe3..2fc0abcbb06169 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48639,56 +48639,6 @@ "node": ">=0.4" } }, - "node_modules/y-indexeddb": { - "version": "9.0.11", - "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.11.tgz", - "integrity": "sha512-HOKQ70qW1h2WJGtOKu9rE8fbX86ExVZedecndMuhwax3yM4DQsQzCTGHt/jvTrFZr/9Ahvd8neD6aZ4dMMjtdg==", - "dependencies": { - "lib0": "^0.2.74" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - }, - "peerDependencies": { - "yjs": "^13.0.0" - } - }, - "node_modules/y-protocols": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.5.tgz", - "integrity": "sha512-Wil92b7cGk712lRHDqS4T90IczF6RkcvCwAD0A2OPg+adKmOe+nOiT/N2hvpQIWS3zfjmtL4CPaH5sIW1Hkm/A==", - "dependencies": { - "lib0": "^0.2.42" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - } - }, - "node_modules/y-webrtc": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/y-webrtc/-/y-webrtc-10.2.5.tgz", - "integrity": "sha512-ZyBNvTI5L28sQ2PQI0T/JvyWgvuTq05L21vGkIlcvNLNSJqAaLCBJRe3FHEqXoaogqWmRcEAKGfII4ErNXMnNw==", - "dependencies": { - "lib0": "^0.2.42", - "simple-peer": "^9.11.0", - "y-protocols": "^1.0.5" - }, - "bin": { - "y-webrtc-signaling": "bin/server.js" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - }, - "optionalDependencies": { - "ws": "^7.2.0" - } - }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", @@ -48821,23 +48771,6 @@ "fd-slicer": "~1.1.0" } }, - "node_modules/yjs": { - "version": "13.6.6", - "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.6.tgz", - "integrity": "sha512-VTvezMeMuOra9jKG1Ym5XuQ2H4xXOubIIIupv/B5oygasa9IqDE7Ufv93QTSe9uz69J5VZGMQb2WTEmJv4kJFQ==", - "license": "MIT", - "dependencies": { - "lib0": "^0.2.74" - }, - "engines": { - "node": ">=16.0.0", - "npm": ">=8.0.0" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - } - }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -52314,16 +52247,122 @@ "@wordpress/url": "file:../url", "lib0": "^0.2.99", "simple-peer": "^9.11.0", - "y-indexeddb": "^9.0.11", - "y-protocols": "^1.0.5", - "y-webrtc": "^10.2.5", - "yjs": "13.6.6" + "y-indexeddb": "^9.0.12", + "y-protocols": "^1.0.6", + "y-webrtc": "^10.3.0", + "yjs": "13.6.27" }, "engines": { "node": ">=18.12.0", "npm": ">=8.19.2" } }, + "packages/sync/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "packages/sync/node_modules/y-indexeddb": { + "version": "9.0.12", + "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz", + "integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.74" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "packages/sync/node_modules/y-protocols": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", + "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "packages/sync/node_modules/y-webrtc": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/y-webrtc/-/y-webrtc-10.3.0.tgz", + "integrity": "sha512-KalJr7dCgUgyVFxoG3CQYbpS0O2qybegD0vI4bYnYHI0MOwoVbucED3RZ5f2o1a5HZb1qEssUKS0H/Upc6p1lA==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.42", + "simple-peer": "^9.11.0", + "y-protocols": "^1.0.6" + }, + "bin": { + "y-webrtc-signaling": "bin/server.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "optionalDependencies": { + "ws": "^8.14.2" + }, + "peerDependencies": { + "yjs": "^13.6.8" + } + }, + "packages/sync/node_modules/yjs": { + "version": "13.6.27", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", + "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "packages/token-list": { "name": "@wordpress/token-list", "version": "3.29.0", diff --git a/packages/sync/package.json b/packages/sync/package.json index c5b9e8be1f1c2d..9c53e905e2987a 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -34,9 +34,9 @@ "@wordpress/url": "file:../url", "lib0": "^0.2.99", "simple-peer": "^9.11.0", - "y-indexeddb": "^9.0.11", - "y-protocols": "^1.0.5", - "y-webrtc": "^10.2.5", + "y-indexeddb": "^9.0.12", + "y-protocols": "^1.0.6", + "y-webrtc": "^10.3.0", "yjs": "13.6.27" }, "publishConfig": { From 1eb3ad57ed9010049f5ff8a530edc9267b9bfcdf Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 5 Aug 2025 14:14:34 -0400 Subject: [PATCH 20/99] Add awareness as optional property on ConnectDocResult --- packages/sync/src/connect-indexdb.js | 1 - packages/sync/src/create-webrtc-connection.ts | 1 - packages/sync/src/types.ts | 2 ++ 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sync/src/connect-indexdb.js b/packages/sync/src/connect-indexdb.js index c7ef7261fadf87..5329f066b296ea 100644 --- a/packages/sync/src/connect-indexdb.js +++ b/packages/sync/src/connect-indexdb.js @@ -24,7 +24,6 @@ export function connectIndexDb( objectId, objectType, doc ) { const provider = new IndexeddbPersistence( roomName, doc ); return Promise.resolve( { - awareness: null, destroy: () => provider.destroy(), } ); } diff --git a/packages/sync/src/create-webrtc-connection.ts b/packages/sync/src/create-webrtc-connection.ts index f32b9e31675bf5..2ea51777e8a91e 100644 --- a/packages/sync/src/create-webrtc-connection.ts +++ b/packages/sync/src/create-webrtc-connection.ts @@ -36,7 +36,6 @@ export function createWebRTCConnection( { } ); return Promise.resolve( { - awareness: null, destroy: () => {}, } ); }; diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index f4a502381a04ca..ffa63c527a15fe 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -2,6 +2,7 @@ * External dependencies */ import type * as Y from 'yjs'; +import type { Awareness } from 'y-protocols/awareness'; export type * as Y from 'yjs'; export type ObjectID = string; @@ -12,6 +13,7 @@ export type UndoManager = Y.UndoManager; export type CRDTDoc = Y.Doc; export type ConnectDocResult = { + awareness?: Awareness; destroy: () => void; }; From 6bfebd03dea47c610beb8465194e898d5147c91b Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 5 Aug 2025 14:15:15 -0400 Subject: [PATCH 21/99] Update SyncProvider for better extensibility --- packages/sync/src/provider.ts | 80 ++++++++++++++++++----------------- packages/sync/src/types.ts | 1 + 2 files changed, 42 insertions(+), 39 deletions(-) diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index 010ecfd773e145..f87e47c1f41149 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -10,6 +10,7 @@ import type { ConnectDoc, ConnectDocResult, CRDTDoc, + EntityID, ObjectID, ObjectData, ObjectType, @@ -22,18 +23,12 @@ interface EntityState { } export class SyncProvider { - protected connectLocal: ConnectDoc | null; - protected connectRemote: ConnectDoc | null; + private connectLocal: ConnectDoc | null; + private connectRemote: ConnectDoc | null; - protected configs: Map< ObjectType, SyncConfig > = new Map< - ObjectType, - SyncConfig - >(); - - protected entityStates: Map< string, EntityState > = new Map< - string, - EntityState - >(); + protected configs: Map< ObjectType, SyncConfig > = new Map(); + protected connections: Map< EntityID, ConnectDocResult[] > = new Map(); + protected entityStates: Map< EntityID, EntityState > = new Map(); /** * Constructor. @@ -49,6 +44,26 @@ export class SyncProvider { this.connectRemote = connectRemote; } + /** + * Connect to a document. + * + * @param {ObjectID} objectId Object ID to connect. + * @param {ObjectType} objectType Object type to connect. + * @param {CRDTDoc} ydoc Yjs document for the object. + */ + private async connect( + objectId: ObjectID, + objectType: ObjectType, + ydoc: CRDTDoc + ): Promise< ConnectDocResult[] > { + return ( + await Promise.all( [ + this.connectLocal?.( objectId, objectType, ydoc ), + this.connectRemote?.( objectId, objectType, ydoc ), + ] ) + ).filter( ( result ): result is ConnectDocResult => Boolean( result ) ); + } + /** * Fetch data from local database or remote source. * @@ -64,41 +79,31 @@ export class SyncProvider { const ydoc = new Y.Doc( { meta: new Map() } ); const objectId = syncConfig.getObjectId( initialData ); const objectType = syncConfig.objectType; + const connections = await this.connect( objectId, objectType, ydoc ); const entityId = this.getEntityId( objectType, objectId ); - this.configs.set( objectType, syncConfig ); + const onDestroy = (): void => { + connections.forEach( ( result ) => result.destroy() ); + ydoc.off( 'update', onUpdate ); + ydoc.destroy(); + this.entityStates.delete( entityId ); + }; - const updateHandler: ( _update: Uint8Array, origin: string ) => void = ( - _update, - origin - ): void => { + const onUpdate = ( _update: Uint8Array, origin: string ): void => { if ( origin !== 'gutenberg' ) { const data = syncConfig.fromCRDTDoc( ydoc ); handleChanges( data ); } }; - ydoc.on( 'update', updateHandler ); - - const connectLocalResult: ConnectDocResult | null = - ( await this.connectLocal?.( objectId, objectType, ydoc ) ) ?? null; - const connectRemoteResult = - ( await this.connectRemote?.( objectId, objectType, ydoc ) ) ?? - null; - - const entityState: EntityState = { - destroy: () => { - connectLocalResult?.destroy?.(); - connectRemoteResult?.destroy?.(); + ydoc.on( 'update', onUpdate ); - ydoc.off( 'update', updateHandler ); - ydoc.destroy(); - this.entityStates.delete( entityId ); - }, + this.configs.set( objectType, syncConfig ); + this.connections.set( entityId, connections ); + this.entityStates.set( entityId, { ydoc, - }; - - this.entityStates.set( entityId, entityState ); + destroy: onDestroy, + } ); this.update( objectType, initialData, initialData, 'gutenberg' ); } @@ -112,7 +117,7 @@ export class SyncProvider { protected getEntityId( objectType: ObjectType, objectId: ObjectID - ): string { + ): EntityID { return `${ objectType }_${ objectId }`; } @@ -168,9 +173,6 @@ export class SyncProvider { * @param {ObjectID} objectId Object ID to discard. */ public discard( objectType: ObjectType, objectId: ObjectID ): void { - const entityId = `${ objectType }_${ objectId }`; - this.getEntityState( objectType, objectId )?.destroy(); - this.entityStates.delete( entityId ); } } diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index ffa63c527a15fe..bca2d1364112e7 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -5,6 +5,7 @@ import type * as Y from 'yjs'; import type { Awareness } from 'y-protocols/awareness'; export type * as Y from 'yjs'; +export type EntityID = string; export type ObjectID = string; export type ObjectType = string; export type ObjectData = object; From cc1f45746f4fca6307f9c25b409e17f47990ca4a Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Wed, 6 Aug 2025 14:12:59 +1000 Subject: [PATCH 22/99] Add the Yjs undo manager in the provider.ts --- .vscode/launch.json | 38 +++++++++++++++++++++++++++++++++++ packages/sync/src/provider.ts | 29 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000000000..768d17a8f09d98 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,38 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Listen for Xdebug", + "type": "php", + "request": "launch", + "port": 9003, + "pathMappings": { + "/var/www/html/wp-content/plugins/gutenberg": "${workspaceFolder}" + } + }, + { + "name": "Debug Vivaldi", + "type": "msedge", + "request": "launch", + "url": "http://localhost:8888/wp-admin", + "runtimeExecutable": "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi", + }, + { + "type": "node", + "request": "launch", + "name": "Debug current e2e test", + "program": "${workspaceFolder}/node_modules/@wordpress/scripts/bin/wp-scripts.js", + "args": [ + "test-e2e", + "--config=${workspaceFolder}/packages/e2e-tests/jest.config.js", + "--verbose=true", + "--runInBand", + "--watch", + "${file}" + ], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "trace": "all" + } + ] +} diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index f87e47c1f41149..9d8a5139637ae7 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -18,6 +18,7 @@ import type { } from './types'; interface EntityState { + undoManager: Y.UndoManager; destroy: () => void; ydoc: CRDTDoc; } @@ -82,10 +83,22 @@ export class SyncProvider { const connections = await this.connect( objectId, objectType, ydoc ); const entityId = this.getEntityId( objectType, objectId ); + const undoManager = new Y.UndoManager( ydoc.getMap( 'document' ), { + // Ensure we undo and redo one character at a time. + captureTimeout: 0, + // Ensure that we only scope the undo/redo to the current client, and Gutenberg origins. + // ToDo: Keep an eye on this, as it needs to be battle tested. + trackedOrigins: new Set( [ 'gutenberg', ydoc.clientID ] ), + // This ensures that are able to improve the client specific undo/redo experience. + // This reduces the bugs we see, but it doesn't eliminate them entirely. + ignoreRemoteMapChanges: true, + } ); + const onDestroy = (): void => { connections.forEach( ( result ) => result.destroy() ); ydoc.off( 'update', onUpdate ); ydoc.destroy(); + undoManager.destroy(); this.entityStates.delete( entityId ); }; @@ -101,6 +114,7 @@ export class SyncProvider { this.configs.set( objectType, syncConfig ); this.connections.set( entityId, connections ); this.entityStates.set( entityId, { + undoManager, ydoc, destroy: onDestroy, } ); @@ -137,6 +151,21 @@ export class SyncProvider { ); } + /** + * Get the undo manager for the given object type and object ID. + * + * @param {ObjectType} objectType Object type. + * @param {ObjectID} objectId Object ID. + * @return {Y.UndoManager | null} The undo manager, or null if not found. + */ + public getUndoManager( + objectType: ObjectType, + objectId: ObjectID + ): Y.UndoManager | null { + const entityState = this.getEntityState( objectType, objectId ); + return entityState ? entityState.undoManager : null; + } + /** * Fetch data from local database or remote source. * From 1fa7191398b2da0aff30c56bbf5a60d8bf670abd Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Wed, 6 Aug 2025 14:56:09 +1000 Subject: [PATCH 23/99] Make a separate exported clas for the undo manager coming from the provider --- .vscode/launch.json | 2 +- packages/core-data/src/reducer.js | 3 + packages/sync/src/provider.ts | 10 +-- packages/sync/src/undo-manager.ts | 100 ++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 packages/sync/src/undo-manager.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 768d17a8f09d98..ca66e559bd2554 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -15,7 +15,7 @@ "type": "msedge", "request": "launch", "url": "http://localhost:8888/wp-admin", - "runtimeExecutable": "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi", + "runtimeExecutable": "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi" }, { "type": "node", diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 9748355fc5caf6..f6324b2719240f 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -472,6 +472,9 @@ export const entities = ( state = {}, action ) => { * @type {UndoManager} */ export function undoManager( state = createUndoManager() ) { + if ( window.__experimentalEnableSync ) { + // Replace the undo manager with the one provided by the sync provider. + } return state; } diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index 9d8a5139637ae7..fbd013e75b7704 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -6,6 +6,7 @@ import * as Y from 'yjs'; /** * Internal dependencies */ +import { UndoManager } from './undo-manager'; import type { ConnectDoc, ConnectDocResult, @@ -18,7 +19,7 @@ import type { } from './types'; interface EntityState { - undoManager: Y.UndoManager; + undoManager: UndoManager; destroy: () => void; ydoc: CRDTDoc; } @@ -83,7 +84,7 @@ export class SyncProvider { const connections = await this.connect( objectId, objectType, ydoc ); const entityId = this.getEntityId( objectType, objectId ); - const undoManager = new Y.UndoManager( ydoc.getMap( 'document' ), { + const yjsUndoManager = new Y.UndoManager( ydoc.getMap( 'document' ), { // Ensure we undo and redo one character at a time. captureTimeout: 0, // Ensure that we only scope the undo/redo to the current client, and Gutenberg origins. @@ -94,11 +95,12 @@ export class SyncProvider { ignoreRemoteMapChanges: true, } ); + const undoManager = new UndoManager( yjsUndoManager ); + const onDestroy = (): void => { connections.forEach( ( result ) => result.destroy() ); ydoc.off( 'update', onUpdate ); ydoc.destroy(); - undoManager.destroy(); this.entityStates.delete( entityId ); }; @@ -161,7 +163,7 @@ export class SyncProvider { public getUndoManager( objectType: ObjectType, objectId: ObjectID - ): Y.UndoManager | null { + ): UndoManager | null { const entityState = this.getEntityState( objectType, objectId ); return entityState ? entityState.undoManager : null; } diff --git a/packages/sync/src/undo-manager.ts b/packages/sync/src/undo-manager.ts new file mode 100644 index 00000000000000..63fc5f0f630b44 --- /dev/null +++ b/packages/sync/src/undo-manager.ts @@ -0,0 +1,100 @@ +/** + * External dependencies + */ +import type * as Y from 'yjs'; + +/** + * WordPress dependencies + */ +import type { + HistoryRecord, + UndoManager as WPUndoManager, +} from '@wordpress/undo-manager'; + +/** + * Internal dependencies + */ +import type { ObjectData } from './types'; + +/** + * Wrapper class that provides the WordPress UndoManager interface while using Y.UndoManager internally. + * This allows seamless integration between Yjs collaborative editing and WordPress undo/redo functionality. + */ +export class UndoManager implements WPUndoManager< ObjectData > { + private undoManager: Y.UndoManager; + + /** + * Constructor. + * + * @param undoManager The Y.UndoManager instance to wrap. + */ + constructor( undoManager: Y.UndoManager ) { + this.undoManager = undoManager; + } + + /** + * Record changes into the history. + * Since Yjs automatically tracks changes, this method translates the WordPress + * HistoryRecord format into Yjs operations. + * + * @param record A record of changes to record. + * @param isStaged Whether to immediately create an undo point or not. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + addRecord( record?: HistoryRecord< ObjectData >, isStaged = false ): void { + // This is a no-op for Yjs since it automatically tracks changes. + // If needed, we could implement custom logic to handle specific records. + } + + /** + * Undo the last recorded changes. + * + * @return The undone record or undefined if nothing to undo. + */ + undo(): HistoryRecord< ObjectData > | undefined { + if ( ! this.hasUndo() ) { + return undefined; + } + + // Perform the undo operation + this.undoManager.undo(); + + // ToDo: See if the undo operation can return a record from Yjs. + return []; + } + + /** + * Redo the last undone changes. + * + * @return The redone record or undefined if nothing to redo. + */ + redo(): HistoryRecord< ObjectData > | undefined { + if ( ! this.hasRedo() ) { + return undefined; + } + + // Perform the redo operation + this.undoManager.redo(); + + // ToDo: See if the redo operation can return a record from Yjs. + return []; + } + + /** + * Check if there are changes that can be undone. + * + * @return Whether there are changes to undo. + */ + hasUndo(): boolean { + return this.undoManager.canUndo(); + } + + /** + * Check if there are changes that can be redone. + * + * @return Whether there are changes to redo. + */ + hasRedo(): boolean { + return this.undoManager.canRedo(); + } +} From d568b5493e6d4e19caaa8fc88a9323605e00cef2 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 6 Aug 2025 09:28:56 -0400 Subject: [PATCH 24/99] Cleanup and separation of concerns --- packages/sync/src/provider.ts | 18 ++---------- packages/sync/src/undo-manager.ts | 46 +++++++++++++++++-------------- 2 files changed, 29 insertions(+), 35 deletions(-) diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index fbd013e75b7704..48b2130dbfdb45 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -19,8 +19,8 @@ import type { } from './types'; interface EntityState { - undoManager: UndoManager; destroy: () => void; + undoManager: UndoManager; ydoc: CRDTDoc; } @@ -79,24 +79,12 @@ export class SyncProvider { handleChanges: ( data: Partial< ObjectData > ) => void ): Promise< void > { const ydoc = new Y.Doc( { meta: new Map() } ); + const undoManager = new UndoManager( ydoc ); const objectId = syncConfig.getObjectId( initialData ); const objectType = syncConfig.objectType; const connections = await this.connect( objectId, objectType, ydoc ); const entityId = this.getEntityId( objectType, objectId ); - const yjsUndoManager = new Y.UndoManager( ydoc.getMap( 'document' ), { - // Ensure we undo and redo one character at a time. - captureTimeout: 0, - // Ensure that we only scope the undo/redo to the current client, and Gutenberg origins. - // ToDo: Keep an eye on this, as it needs to be battle tested. - trackedOrigins: new Set( [ 'gutenberg', ydoc.clientID ] ), - // This ensures that are able to improve the client specific undo/redo experience. - // This reduces the bugs we see, but it doesn't eliminate them entirely. - ignoreRemoteMapChanges: true, - } ); - - const undoManager = new UndoManager( yjsUndoManager ); - const onDestroy = (): void => { connections.forEach( ( result ) => result.destroy() ); ydoc.off( 'update', onUpdate ); @@ -116,9 +104,9 @@ export class SyncProvider { this.configs.set( objectType, syncConfig ); this.connections.set( entityId, connections ); this.entityStates.set( entityId, { + destroy: onDestroy, undoManager, ydoc, - destroy: onDestroy, } ); this.update( objectType, initialData, initialData, 'gutenberg' ); diff --git a/packages/sync/src/undo-manager.ts b/packages/sync/src/undo-manager.ts index 63fc5f0f630b44..8cc6c784232f8d 100644 --- a/packages/sync/src/undo-manager.ts +++ b/packages/sync/src/undo-manager.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import type * as Y from 'yjs'; +import * as Y from 'yjs'; /** * WordPress dependencies @@ -14,7 +14,7 @@ import type { /** * Internal dependencies */ -import type { ObjectData } from './types'; +import type { CRDTDoc, ObjectData } from './types'; /** * Wrapper class that provides the WordPress UndoManager interface while using Y.UndoManager internally. @@ -23,13 +23,17 @@ import type { ObjectData } from './types'; export class UndoManager implements WPUndoManager< ObjectData > { private undoManager: Y.UndoManager; - /** - * Constructor. - * - * @param undoManager The Y.UndoManager instance to wrap. - */ - constructor( undoManager: Y.UndoManager ) { - this.undoManager = undoManager; + public constructor( ydoc: CRDTDoc ) { + this.undoManager = new Y.UndoManager( ydoc.getMap( 'document' ), { + // Ensure we undo and redo one character at a time. + captureTimeout: 0, + // Ensure that we only scope the undo/redo to the current client, and Gutenberg origins. + // ToDo: Keep an eye on this, as it needs to be battle tested. + trackedOrigins: new Set( [ 'gutenberg', ydoc.clientID ] ), + // This ensures that are able to improve the client specific undo/redo experience. + // This reduces the bugs we see, but it doesn't eliminate them entirely. + ignoreRemoteMapChanges: true, + } ); } /** @@ -37,11 +41,13 @@ export class UndoManager implements WPUndoManager< ObjectData > { * Since Yjs automatically tracks changes, this method translates the WordPress * HistoryRecord format into Yjs operations. * - * @param record A record of changes to record. - * @param isStaged Whether to immediately create an undo point or not. + * @param _record A record of changes to record. + * @param _isStaged Whether to immediately create an undo point or not. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - addRecord( record?: HistoryRecord< ObjectData >, isStaged = false ): void { + public addRecord( + _record?: HistoryRecord< ObjectData >, + _isStaged = false // eslint-disable-line @typescript-eslint/no-unused-vars + ): void { // This is a no-op for Yjs since it automatically tracks changes. // If needed, we could implement custom logic to handle specific records. } @@ -51,7 +57,7 @@ export class UndoManager implements WPUndoManager< ObjectData > { * * @return The undone record or undefined if nothing to undo. */ - undo(): HistoryRecord< ObjectData > | undefined { + public undo(): HistoryRecord< ObjectData > | undefined { if ( ! this.hasUndo() ) { return undefined; } @@ -59,7 +65,7 @@ export class UndoManager implements WPUndoManager< ObjectData > { // Perform the undo operation this.undoManager.undo(); - // ToDo: See if the undo operation can return a record from Yjs. + // @TODO See if the undo operation can return a record from Yjs. return []; } @@ -68,15 +74,15 @@ export class UndoManager implements WPUndoManager< ObjectData > { * * @return The redone record or undefined if nothing to redo. */ - redo(): HistoryRecord< ObjectData > | undefined { + public redo(): HistoryRecord< ObjectData > | undefined { if ( ! this.hasRedo() ) { - return undefined; + return; } // Perform the redo operation this.undoManager.redo(); - // ToDo: See if the redo operation can return a record from Yjs. + // @TODO See if the redo operation can return a record from Yjs. return []; } @@ -85,7 +91,7 @@ export class UndoManager implements WPUndoManager< ObjectData > { * * @return Whether there are changes to undo. */ - hasUndo(): boolean { + public hasUndo(): boolean { return this.undoManager.canUndo(); } @@ -94,7 +100,7 @@ export class UndoManager implements WPUndoManager< ObjectData > { * * @return Whether there are changes to redo. */ - hasRedo(): boolean { + public hasRedo(): boolean { return this.undoManager.canRedo(); } } From 6f831a2d5619cd0a7ac1b2351d2ce2b61df01a7e Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 6 Aug 2025 11:40:58 -0400 Subject: [PATCH 25/99] Rely on a single instance of UndoManager --- packages/core-data/src/entities.js | 1 + packages/core-data/src/private-selectors.ts | 3 +- packages/core-data/src/selectors.ts | 5 +-- packages/sync/src/provider.ts | 35 +++++++++++++-------- packages/sync/src/types.ts | 1 + 5 files changed, 29 insertions(+), 16 deletions(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 1453c996f448e7..e7de10f241e87d 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -727,6 +727,7 @@ async function loadPostTypeEntities() { fromCRDTDoc: defaultYdocTransformer, getObjectId: ( { id } ) => id, objectType: 'postType/' + postType.name, + supportsUndo: true, }, supportsPagination: true, getRevisionsUrl: ( parentId, revisionId ) => diff --git a/packages/core-data/src/private-selectors.ts b/packages/core-data/src/private-selectors.ts index 6acbe493913a89..648895d3ec983f 100644 --- a/packages/core-data/src/private-selectors.ts +++ b/packages/core-data/src/private-selectors.ts @@ -10,6 +10,7 @@ import { getDefaultTemplateId, getEntityRecord, type State } from './selectors'; import { STORE_NAME } from './name'; import { unlock } from './lock-unlock'; import logEntityDeprecation from './utils/log-entity-deprecation'; +import { getSyncProvider } from './sync'; type EntityRecordKey = string | number; @@ -22,7 +23,7 @@ type EntityRecordKey = string | number; * @return The undo manager. */ export function getUndoManager( state: State ) { - return state.undoManager; + return getSyncProvider().getUndoManager() ?? state.undoManager; } /** diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 3de1cd05cd5c5b..336e5906412c94 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -15,6 +15,7 @@ import { getQueriedTotalPages, } from './queried-data'; import { DEFAULT_ENTITY_KEY } from './entities'; +import { getUndoManager } from './private-selectors'; import { getNormalizedCommaSeparable, isRawAttribute, @@ -1071,7 +1072,7 @@ export function getRedoEdit( state: State ): Optional< any > { * @return Whether there is a previous edit or not. */ export function hasUndo( state: State ): boolean { - return state.undoManager.hasUndo(); + return getUndoManager( state ).hasUndo(); } /** @@ -1083,7 +1084,7 @@ export function hasUndo( state: State ): boolean { * @return Whether there is a next edit or not. */ export function hasRedo( state: State ): boolean { - return state.undoManager.hasRedo(); + return getUndoManager( state ).hasRedo(); } /** diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index 48b2130dbfdb45..04d9098515b15e 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -20,7 +20,6 @@ import type { interface EntityState { destroy: () => void; - undoManager: UndoManager; ydoc: CRDTDoc; } @@ -28,6 +27,20 @@ export class SyncProvider { private connectLocal: ConnectDoc | null; private connectRemote: ConnectDoc | null; + /** + * CAUTION: We currently store a single UndoManager instance under these + * assumptions: + * + * 1. Only entities loaded by the block editor support an undo manager. + * 2. Only one such entity is loaded at a time. + * 3. The entity's SyncConfig has `supportsUndo` set to true. + * + * If these assumptions fail, we will need to refactor the selectors provided + * by `@wordpress/core-data` (e.g., `getUndoManager`) to support multiple + * UndoManager instances by requiring the entity type and ID as parameters. + */ + private undoManager: UndoManager | null = null; + protected configs: Map< ObjectType, SyncConfig > = new Map(); protected connections: Map< EntityID, ConnectDocResult[] > = new Map(); protected entityStates: Map< EntityID, EntityState > = new Map(); @@ -79,7 +92,6 @@ export class SyncProvider { handleChanges: ( data: Partial< ObjectData > ) => void ): Promise< void > { const ydoc = new Y.Doc( { meta: new Map() } ); - const undoManager = new UndoManager( ydoc ); const objectId = syncConfig.getObjectId( initialData ); const objectType = syncConfig.objectType; const connections = await this.connect( objectId, objectType, ydoc ); @@ -101,11 +113,14 @@ export class SyncProvider { ydoc.on( 'update', onUpdate ); + if ( syncConfig.supportsUndo ) { + this.undoManager = new UndoManager( ydoc ); + } + this.configs.set( objectType, syncConfig ); this.connections.set( entityId, connections ); this.entityStates.set( entityId, { destroy: onDestroy, - undoManager, ydoc, } ); @@ -142,18 +157,12 @@ export class SyncProvider { } /** - * Get the undo manager for the given object type and object ID. + * Get the undo manager. * - * @param {ObjectType} objectType Object type. - * @param {ObjectID} objectId Object ID. - * @return {Y.UndoManager | null} The undo manager, or null if not found. + * @return {Y.UndoManager | null} The undo manager, or null if unsupported. */ - public getUndoManager( - objectType: ObjectType, - objectId: ObjectID - ): UndoManager | null { - const entityState = this.getEntityState( objectType, objectId ); - return entityState ? entityState.undoManager : null; + public getUndoManager(): UndoManager | null { + return this.undoManager; } /** diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index bca2d1364112e7..9a5fcbcf83bd0a 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -29,4 +29,5 @@ export type SyncConfig = { fromCRDTDoc: ( ydoc: Y.Doc ) => ObjectData; getObjectId: ( data: ObjectData ) => ObjectID; objectType: ObjectType; + supportsUndo?: boolean; }; From e80bb3df98366e840becfda3bb08d2c63820edf2 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 6 Aug 2025 11:42:58 -0400 Subject: [PATCH 26/99] Remove launch.json --- .vscode/launch.json | 38 -------------------------------------- 1 file changed, 38 deletions(-) delete mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index ca66e559bd2554..00000000000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "name": "Listen for Xdebug", - "type": "php", - "request": "launch", - "port": 9003, - "pathMappings": { - "/var/www/html/wp-content/plugins/gutenberg": "${workspaceFolder}" - } - }, - { - "name": "Debug Vivaldi", - "type": "msedge", - "request": "launch", - "url": "http://localhost:8888/wp-admin", - "runtimeExecutable": "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi" - }, - { - "type": "node", - "request": "launch", - "name": "Debug current e2e test", - "program": "${workspaceFolder}/node_modules/@wordpress/scripts/bin/wp-scripts.js", - "args": [ - "test-e2e", - "--config=${workspaceFolder}/packages/e2e-tests/jest.config.js", - "--verbose=true", - "--runInBand", - "--watch", - "${file}" - ], - "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen", - "trace": "all" - } - ] -} From 9644e7cbe6d049294814d8188ee65e2bf6c9f1c9 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 6 Aug 2025 11:45:41 -0400 Subject: [PATCH 27/99] Remove feature flag check --- packages/core-data/src/reducer.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index f6324b2719240f..9748355fc5caf6 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -472,9 +472,6 @@ export const entities = ( state = {}, action ) => { * @type {UndoManager} */ export function undoManager( state = createUndoManager() ) { - if ( window.__experimentalEnableSync ) { - // Replace the undo manager with the one provided by the sync provider. - } return state; } From c5f78e3597986ce77d03a4cd58f8201a408a4b4d Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 6 Aug 2025 16:34:49 -0400 Subject: [PATCH 28/99] Add supportsAwareness: true to post entities --- packages/core-data/src/entities.js | 1 + packages/sync/src/types.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index e7de10f241e87d..acdf314616b760 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -727,6 +727,7 @@ async function loadPostTypeEntities() { fromCRDTDoc: defaultYdocTransformer, getObjectId: ( { id } ) => id, objectType: 'postType/' + postType.name, + supportsAwareness: true, supportsUndo: true, }, supportsPagination: true, diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 9a5fcbcf83bd0a..1230b1b2e8c304 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -29,5 +29,6 @@ export type SyncConfig = { fromCRDTDoc: ( ydoc: Y.Doc ) => ObjectData; getObjectId: ( data: ObjectData ) => ObjectID; objectType: ObjectType; + supportsAwareness?: boolean; supportsUndo?: boolean; }; From 44df1880f6c52a96bf6b888ee029c079b7d8b845 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Thu, 7 Aug 2025 10:48:27 +1000 Subject: [PATCH 29/99] Minor change in the return for undo and undo manger return type --- packages/sync/src/provider.ts | 2 +- packages/sync/src/undo-manager.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index 04d9098515b15e..1ee97b33065737 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -159,7 +159,7 @@ export class SyncProvider { /** * Get the undo manager. * - * @return {Y.UndoManager | null} The undo manager, or null if unsupported. + * @return {UndoManager | null} The undo manager, or null if unsupported. */ public getUndoManager(): UndoManager | null { return this.undoManager; diff --git a/packages/sync/src/undo-manager.ts b/packages/sync/src/undo-manager.ts index 8cc6c784232f8d..63c24234094272 100644 --- a/packages/sync/src/undo-manager.ts +++ b/packages/sync/src/undo-manager.ts @@ -59,7 +59,7 @@ export class UndoManager implements WPUndoManager< ObjectData > { */ public undo(): HistoryRecord< ObjectData > | undefined { if ( ! this.hasUndo() ) { - return undefined; + return; } // Perform the undo operation From b4eeb98600b7046b0db85ed38cc51327bec41247 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Thu, 7 Aug 2025 12:00:35 +1000 Subject: [PATCH 30/99] Fix the missing path in tsconfig for hooks --- packages/sync/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sync/tsconfig.json b/packages/sync/tsconfig.json index f0a5cb0530d297..53e6a2b663d310 100644 --- a/packages/sync/tsconfig.json +++ b/packages/sync/tsconfig.json @@ -4,5 +4,5 @@ "compilerOptions": { "types": [ "node" ] }, - "references": [ { "path": "../url" } ] + "references": [ { "path": "../hooks" }, { "path": "../url" } ] } From e6b3784b378887d3679ad2da918b1736b90e6a49 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 14 Aug 2025 16:33:45 -0600 Subject: [PATCH 31/99] Remove sync configs for non-synced entities --- packages/core-data/src/entities.js | 114 ----------------------------- 1 file changed, 114 deletions(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index acdf314616b760..4d0c2be5bc985e 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -129,44 +129,6 @@ export const rootEntitiesConfig = [ // The entity doesn't support selecting multiple records. // The property is maintained for backward compatibility. plural: '__unstableBases', - syncConfig: { - applyChangesToDoc: ( doc, changes ) => { - const content = changes.content?.raw || changes.content; - const parsedYdoc = - typeof content === 'string' - ? parseContentYdoc( 'root/base', content ) - : null; // Note: always use the same 'postType' as this object's config.syncObjectType - if ( parsedYdoc !== null ) { - // parse content which contains a ydoc, and apply it to the current ydoc. The rest of the attributes can be ignored. - Y.transact( - doc, - () => { - // apply remote changes - Y.applyUpdate( - doc, - Y.encodeStateAsUpdate( parsedYdoc ) - ); - }, - 'applyChangesToDoc', - false - ); - } else { - // local changes happened. Apply the differences to the ydoc - const ycontent = doc.getMap( 'document' ); - Object.entries( changes ).forEach( ( [ key, value ] ) => { - if ( - ! filteredAttributes.has( key ) && - ! fun.equalityDeep( ycontent.get( key ), value ) - ) { - ycontent.set( key, value ); - } - } ); - } - }, - fromCRDTDoc: defaultYdocTransformer, - getObjectId: () => 'index', - objectType: 'root/base', - }, }, { label: __( 'Post Type' ), @@ -176,44 +138,6 @@ export const rootEntitiesConfig = [ baseURL: '/wp/v2/types', baseURLParams: { context: 'edit' }, plural: 'postTypes', - syncConfig: { - applyChangesToDoc: ( ydoc, changes ) => { - const content = changes.content?.raw || changes.content; - const parsedYdoc = - typeof content === 'string' - ? parseContentYdoc( 'root/postType', content ) - : null; // Note: always use the same 'postType' as this object's config.syncObjectType - if ( parsedYdoc !== null ) { - // parse content which contains a ydoc, and apply it to the current ydoc. The rest of the attributes can be ignored. - Y.transact( - ydoc, - () => { - // apply remote changes - Y.applyUpdate( - ydoc, - Y.encodeStateAsUpdate( parsedYdoc ) - ); - }, - 'applyChangesToDoc', - false - ); - } else { - // local changes happened. Apply the differences to the ydoc - const ycontent = ydoc.getMap( 'document' ); - Object.entries( changes ).forEach( ( [ key, value ] ) => { - if ( - ! filteredAttributes.has( key ) && - ! fun.equalityDeep( ycontent.get( key ), value ) - ) { - ycontent.set( key, value ); - } - } ); - } - }, - fromCRDTDoc: defaultYdocTransformer, - getObjectId: ( { id } ) => id, - objectType: 'root/postType', - }, }, { name: 'media', @@ -775,44 +699,6 @@ async function loadSiteEntity() { name: 'site', kind: 'root', baseURL: '/wp/v2/settings', - syncConfig: { - applyChangesToDoc: ( doc, changes ) => { - const content = changes.content?.raw || changes.content; - const parsedYdoc = - typeof content === 'string' - ? parseContentYdoc( 'root/site', content ) - : null; // Note: always use the same 'postType' as this object's config.syncObjectType - if ( parsedYdoc !== null ) { - // parse content which contains a ydoc, and apply it to the current ydoc. The rest of the attributes can be ignored. - Y.transact( - doc, - () => { - // apply remote changes - Y.applyUpdate( - doc, - Y.encodeStateAsUpdate( parsedYdoc ) - ); - }, - 'applyChangesToDoc', - false - ); - } else { - // local changes happened. Apply the differences to the ydoc - const ycontent = doc.getMap( 'document' ); - Object.entries( changes ).forEach( ( [ key, value ] ) => { - if ( - ! filteredAttributes.has( key ) && - ! fun.equalityDeep( ycontent.get( key ), value ) - ) { - ycontent.set( key, value ); - } - } ); - } - }, - fromCRDTDoc: defaultYdocTransformer, - getObjectId: () => 'index', - objectType: 'root/site', - }, meta: {}, }; From fd4a59bbbdfd46e050f1c54a2880fcaa3de376ae Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 14 Aug 2025 16:35:22 -0600 Subject: [PATCH 32/99] Allow custom sync provider to override initial CRDT doc --- packages/core-data/src/entities.js | 522 +++++++++++------------------ packages/sync/src/provider.ts | 50 ++- packages/sync/src/types.ts | 3 +- 3 files changed, 242 insertions(+), 333 deletions(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 4d0c2be5bc985e..61ca424237ba2f 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -3,8 +3,7 @@ */ import { capitalCase, pascalCase } from 'change-case'; import { v4 as uuidv4 } from 'uuid'; -import * as string from 'lib0/string'; -import * as sha256 from 'lib0/hash/sha256'; + /** * WordPress dependencies */ @@ -19,82 +18,15 @@ import * as fun from 'lib0/function'; /** * Internal dependencies */ -import { getSyncProvider } from './sync'; export const DEFAULT_ENTITY_KEY = 'id'; const POST_RAW_ATTRIBUTES = [ 'title', 'excerpt', 'content' ]; -// @todo refactor `applyChangesToDoc` implementations this to have less repetition (there are -// multiple similar implementations) - -/** - * Similar to `parse`, but only reads the Yjs document if available. - * @param {string} postType - * @param {string} content - */ -export function parseContentYdoc( postType, content ) { - const newClientId = new Uint32Array( - sha256.digest( string.encodeUtf8( content ) ).buffer - ); - const syncProvider = getSyncProvider(); - - // It is important that this is a fresh document - don't use the document from the sync package! - const ydoc = new Y.Doc( { meta: new Map() } ); - - const knownUpdateGuids = new Set(); - ydoc.meta.set( 'knownRemoteUpdates', knownUpdateGuids ); - // Changing the Yjs clientid may lead to very weird bugs if done incorrectly. - // Please handle the following code-portion with great care! - const prevClientId = ydoc.clientID; - ydoc.clientID = newClientId; - const prevClock = ( ydoc.store.clients.get( newClientId ) || [ - { id: { clock: 0 } }, - ] )[ 0 ].id.clock; - const blocks = parse( content ); - syncProvider.configs.get( postType ).applyChangesToDoc( ydoc, { - blocks, - } ); - ydoc.clientID = prevClientId; - const newClock = ( ydoc.store.clients.get( newClientId ) ?? [ - { id: { clock: 0 } }, - ] )[ 0 ].id.clock; - if ( prevClock !== newClock ) { - // eslint-disable-next-line no-console - console.info( - '[Yjs Collab] Yjs document was updated to reflect changes to the HTML document.' - ); - } - return ydoc; -} - -// only sync what is necessary! -const filteredAttributes = new Set( [ - 'content', - 'selection', - 'excerpt', - 'date', - 'date_gmt', - 'format', - 'generated_slug', - 'link', - 'meta', - 'modified', - 'modified_gmt', - 'slug', - 'status', - 'sticky', - 'tags', - 'template', - '_links', - 'id', - 'password', - 'featured_media', -] ); - /** * @param {Y.Doc} ydoc + * @return {import('@wordpress/sync').ObjectData} The JSON representation of the document. */ -const defaultYdocTransformer = ( ydoc ) => { +const defaultFromCRDTDoc = ( ydoc ) => { const json = ydoc.getMap( 'document' ).toJSON(); if ( json.title?.raw ) { json.title = json.title.raw; @@ -355,6 +287,20 @@ function makeBlocksSerializable( blocks ) { * @return {Promise} Entities promise */ async function loadPostTypeEntities() { + const syncedProperties = new Set( [ + 'blocks', + 'content', + 'excerpt', + 'featured_media', + 'format', + 'generated_slug', + 'password', + 'slug', + 'sticky', + 'tags', + 'template', + ] ); + const postTypes = await apiFetch( { path: '/wp/v2/types?context=view', } ); @@ -388,267 +334,197 @@ async function loadPostTypeEntities() { * @param {Y.Doc} ydoc * @param {any} changes */ - applyChangesToDoc: ( ydoc, changes ) => { - const content = changes.content?.raw || changes.content; - const parsedYdoc = - typeof content === 'string' - ? parseContentYdoc( - 'postType/' + postType.name, - content - ) - : null; // Note: always use the same 'postType' as this object's config.syncObjectType - if ( parsedYdoc !== null ) { - // parse content which contains a ydoc, and apply it to the current ydoc. The rest of the attributes can be ignored. - Y.transact( - ydoc, - () => { - // apply remote changes - Y.applyUpdate( - ydoc, - Y.encodeStateAsUpdate( parsedYdoc ) + applyChangesToCRDTDoc: ( ydoc, changes ) => { + // local changes happened. Apply the differences to the ydoc + const ycontent = ydoc.getMap( 'document' ); + Object.entries( changes ).forEach( ( [ key, value ] ) => { + if ( typeof value !== 'function' ) { + if ( key === 'blocks' ) { + if ( ! serialisableBlocksCache.has( value ) ) { + serialisableBlocksCache.set( + value, + makeBlocksSerializable( value ) + ); + } + const blocks = + serialisableBlocksCache.get( 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` + if ( + ! ycontent.has( key ) || + ycontent.get( key ) instanceof Array + ) { + // @todo remove the array check + ycontent.set( key, new Y.Array() ); + } + /** + * @type {Y.Array>} + */ + const yblocks = ycontent.get( key ); + const numOfCommonEntries = math.min( + blocks.length, + yblocks.length + ); + let left = 0; + let right = 0; + /** + * @param {any} gblock + * @param {Y.Map} yblock + */ + const blocksEqual = ( gblock, yblock ) => { + if ( yblock.toJSON ) { + yblock = yblock.toJSON(); + } + // we must not sync clientId, as this can't be generated consistenctly and + // hence will lead to merge conflicts. + const overwrites = { + innerBlocks: null, + clientId: null, + }; + const res = fun.equalityDeep( + Object.assign( {}, gblock, overwrites ), + Object.assign( {}, yblock, overwrites ) + ); + const inners = gblock.innerBlocks || []; + const yinners = yblock.innerBlocks || []; + return ( + res && + inners.length === yinners.length && + inners.every( ( block, i ) => + blocksEqual( block, yinners[ i ] ) + ) + ); + }; + // skip equal blocks from left + for ( + ; + left < numOfCommonEntries && + blocksEqual( + blocks[ left ], + yblocks.get( left ) + ); + left++ + ) { + /* nop */ + } + // skip equal blocks from right + for ( + ; + right < numOfCommonEntries - left && + blocksEqual( + blocks[ blocks.length - right - 1 ], + yblocks.get( + yblocks.length - right - 1 + ) + ); + right++ + ) { + /* nop */ + } + const numOfUpdatesNeeded = + numOfCommonEntries - left - right; + const numOfInsertionsNeeded = math.max( + 0, + blocks.length - yblocks.length + ); + const numOfDeletionsNeeded = math.max( + 0, + yblocks.length - blocks.length ); - }, - 'applyChangesToDoc', - false - ); - } else { - // local changes happened. Apply the differences to the ydoc - const ycontent = ydoc.getMap( 'document' ); - ydoc.transact( () => { - Object.entries( changes ).forEach( - ( [ key, value ] ) => { - if ( typeof value !== 'function' ) { - if ( key === 'blocks' ) { + // updates + for ( + let i = 0; + i < numOfUpdatesNeeded; + i++, left++ + ) { + const block = blocks[ left ]; + const yblock = yblocks.get( left ); + Object.entries( block ).forEach( + ( [ k, v ] ) => { if ( - ! serialisableBlocksCache.has( - value + ! fun.equalityDeep( + block[ k ], + yblock.get( k ) ) ) { - serialisableBlocksCache.set( - value, - makeBlocksSerializable( - value - ) - ); + yblock.set( k, v ); } - const blocks = - serialisableBlocksCache.get( - 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` - if ( - ! ycontent.has( key ) || - ycontent.get( key ) instanceof - Array - ) { - // @todo remove the array check - ycontent.set( - key, - new Y.Array() - ); - } - /** - * @type {Y.Array>} - */ - const yblocks = ycontent.get( key ); - const numOfCommonEntries = math.min( - blocks.length, - yblocks.length - ); - let left = 0; - let right = 0; - /** - * @param {any} gblock - * @param {Y.Map} yblock - */ - const blocksEqual = ( - gblock, - yblock - ) => { - if ( yblock.toJSON ) { - yblock = yblock.toJSON(); - } - // we must not sync clientId, as this can't be generated consistenctly and - // hence will lead to merge conflicts. - const overwrites = { - innerBlocks: null, - clientId: null, - }; - const res = fun.equalityDeep( - Object.assign( - {}, - gblock, - overwrites - ), - Object.assign( - {}, - yblock, - overwrites - ) - ); - const inners = - gblock.innerBlocks || []; - const yinners = - yblock.innerBlocks || []; - return ( - res && - inners.length === - yinners.length && - inners.every( - ( block, i ) => - blocksEqual( - block, - yinners[ i ] - ) - ) - ); - }; - // skip equal blocks from left - for ( - ; - left < numOfCommonEntries && - blocksEqual( - blocks[ left ], - yblocks.get( left ) - ); - left++ - ) { - /* nop */ - } - // skip equal blocks from right - for ( - ; - right < - numOfCommonEntries - left && - blocksEqual( - blocks[ - blocks.length - - right - - 1 - ], - yblocks.get( - yblocks.length - - right - - 1 - ) - ); - right++ - ) { - /* nop */ - } - const numOfUpdatesNeeded = - numOfCommonEntries - - left - - right; - const numOfInsertionsNeeded = - math.max( - 0, - blocks.length - - yblocks.length - ); - const numOfDeletionsNeeded = - math.max( - 0, - yblocks.length - - blocks.length - ); - // updates - for ( - let i = 0; - i < numOfUpdatesNeeded; - i++, left++ - ) { - const block = blocks[ left ]; - const yblock = - yblocks.get( left ); - Object.entries( block ).forEach( - ( [ k, v ] ) => { - if ( - ! fun.equalityDeep( - block[ k ], - yblock.get( k ) - ) - ) { - yblock.set( k, v ); - } - } - ); - yblock.forEach( ( _v, k ) => { - if ( - ! block.hasOwnProperty( - k - ) - ) { - yblock.delete( k ); - } - } ); - } - // deletes - yblocks.delete( - left, - numOfDeletionsNeeded - ); - // inserts - for ( - let i = 0; - i < numOfInsertionsNeeded; - i++, left++ - ) { - yblocks.insert( left, [ - new Y.Map( - Object.entries( - blocks[ left ] - ) - ), - ] ); - } - const knownClientIds = new Set(); - // remove duplicate clientids - for ( - let j = 0; - j < yblocks.length; - j++ - ) { - const yblock = yblocks.get( j ); - if ( - knownClientIds.has( - yblock.get( 'clientId' ) - ) - ) { - yblock.set( - 'clientId', - uuidv4() - ); - } - knownClientIds.add( - yblock.get( 'clientId' ) - ); - } - } else if ( - ! filteredAttributes.has( key ) && - ! fun.equalityDeep( - ycontent.get( key ), - value - ) - ) { - ycontent.set( key, value ); } + ); + yblock.forEach( ( _v, k ) => { + if ( ! block.hasOwnProperty( k ) ) { + yblock.delete( k ); + } + } ); + } + // deletes + yblocks.delete( left, numOfDeletionsNeeded ); + // inserts + for ( + let i = 0; + i < numOfInsertionsNeeded; + i++, left++ + ) { + yblocks.insert( left, [ + new Y.Map( + Object.entries( blocks[ left ] ) + ), + ] ); + } + const knownClientIds = new Set(); + // remove duplicate clientids + for ( let j = 0; j < yblocks.length; j++ ) { + const yblock = yblocks.get( j ); + if ( + knownClientIds.has( + yblock.get( 'clientId' ) + ) + ) { + yblock.set( 'clientId', uuidv4() ); } + knownClientIds.add( + yblock.get( 'clientId' ) + ); } - ); - }, 'gutenberg' ); - } + } else if ( + ! syncedProperties.has( key ) && + ! fun.equalityDeep( ycontent.get( key ), value ) + ) { + ycontent.set( key, value ); + } + } + } ); + }, + fromCRDTDoc: defaultFromCRDTDoc, + /** + * This initial object data represents the data that will be synced via + * the CRDT document, which may differ from the entity record. There may + * be properties that should not be synced, or properties that are + * derived from the record. + * + * @param {import('@wordpress/sync').ObjectData} record + * @return {import('@wordpress/sync').ObjectData} The initial data + */ + getInitialObjectData: ( record ) => { + // Mix in the parsed blocks into the record. Only allow properties in + // the synced properties set. + const content = record.content?.raw ?? record.content ?? ''; + const blocks = parse( content ); + + return Object.fromEntries( + Object.entries( { ...record, blocks } ).filter( + ( [ key ] ) => syncedProperties.has( key ) + ) + ); }, - fromCRDTDoc: defaultYdocTransformer, getObjectId: ( { id } ) => id, objectType: 'postType/' + postType.name, supportsAwareness: true, diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index 1ee97b33065737..1d960a5599e2f9 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -83,16 +83,16 @@ export class SyncProvider { * Fetch data from local database or remote source. * * @param {SyncConfig} syncConfig Sync configuration for the object type. - * @param {ObjectData} initialData Initial data to apply to the document. + * @param {ObjectData} record Record representing this object type. * @param {Function} handleChanges Callback to call when data changes. */ public async bootstrap( syncConfig: SyncConfig, - initialData: ObjectData, + record: ObjectData, handleChanges: ( data: Partial< ObjectData > ) => void ): Promise< void > { const ydoc = new Y.Doc( { meta: new Map() } ); - const objectId = syncConfig.getObjectId( initialData ); + const objectId = syncConfig.getObjectId( record ); const objectType = syncConfig.objectType; const connections = await this.connect( objectId, objectType, ydoc ); const entityId = this.getEntityId( objectType, objectId ); @@ -124,7 +124,19 @@ export class SyncProvider { ydoc, } ); - this.update( objectType, initialData, initialData, 'gutenberg' ); + // Get the initial data to be synced for this record. + const initialCRDTDoc = await this.getCRDTDoc( syncConfig, record ); + + // Create the initial document, possible from persisted doc. + Y.transact( + ydoc, + () => { + // apply remote changes + Y.applyUpdate( ydoc, Y.encodeStateAsUpdate( initialCRDTDoc ) ); + }, + 'syncProvider.bootstrap', + false + ); } /** @@ -156,6 +168,27 @@ export class SyncProvider { ); } + /** + * Get the CRDTDoc that represents the initial state of the object data. Custom + * sync providers can override this method to provide a custom initial state. + * + * @param {SyncConfig} syncConfig Sync configuration for the object type. + * @param {ObjectData} record Initial data to apply to the document. + */ + protected async getCRDTDoc( + syncConfig: SyncConfig, + record: ObjectData + ): Promise< CRDTDoc > { + // IMPORTANT: We use a new Yjs document so that the initial state can be + // applied to the "real" Yjs document as a singular update. + const initialStateDoc = new Y.Doc( { meta: new Map() } ); + + const initialData = syncConfig.getInitialObjectData( record ); + syncConfig.applyChangesToCRDTDoc( initialStateDoc, initialData ); + + return initialStateDoc; + } + /** * Get the undo manager. * @@ -179,18 +212,17 @@ export class SyncProvider { changes: Partial< ObjectData >, origin: string ): void { - const objectId = this.configs.get( objectType )?.getObjectId( record ); + const syncConfig = this.configs.get( objectType ); + const objectId = syncConfig?.getObjectId( record ); - if ( ! objectId ) { + if ( ! syncConfig || ! objectId ) { return; } const entityState = this.getEntityState( objectType, objectId ); entityState?.ydoc.transact( () => { - this.configs - .get( objectType ) - ?.applyChangesToDoc( entityState.ydoc, changes ); + syncConfig.applyChangesToCRDTDoc( entityState.ydoc, changes ); }, origin ); } diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 1230b1b2e8c304..5b676821d67ac8 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -25,8 +25,9 @@ export type ConnectDoc = ( ) => Promise< ConnectDocResult >; export type SyncConfig = { - applyChangesToDoc: ( ydoc: Y.Doc, data: Partial< ObjectData > ) => void; + applyChangesToCRDTDoc: ( ydoc: Y.Doc, data: Partial< ObjectData > ) => void; fromCRDTDoc: ( ydoc: Y.Doc ) => ObjectData; + getInitialObjectData: ( record: ObjectData ) => ObjectData; getObjectId: ( data: ObjectData ) => ObjectID; objectType: ObjectType; supportsAwareness?: boolean; From 6da12d120fb6f06ac47c8e8ac2917f02dfecf079 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Fri, 15 Aug 2025 16:05:04 -0600 Subject: [PATCH 33/99] Rename method for clarity --- packages/sync/src/provider.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index 1d960a5599e2f9..8a457199556f75 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -125,7 +125,10 @@ export class SyncProvider { } ); // Get the initial data to be synced for this record. - const initialCRDTDoc = await this.getCRDTDoc( syncConfig, record ); + const initialCRDTDoc = await this.getInitialCRDTDoc( + syncConfig, + record + ); // Create the initial document, possible from persisted doc. Y.transact( @@ -175,7 +178,7 @@ export class SyncProvider { * @param {SyncConfig} syncConfig Sync configuration for the object type. * @param {ObjectData} record Initial data to apply to the document. */ - protected async getCRDTDoc( + protected async getInitialCRDTDoc( syncConfig: SyncConfig, record: ObjectData ): Promise< CRDTDoc > { From b67b8ef205946746695213b827a30ba9e6af6c29 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 18 Aug 2025 13:16:20 -0600 Subject: [PATCH 34/99] Filter synced properties --- packages/core-data/src/entities.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 61ca424237ba2f..51597a05584c7b 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -332,11 +332,15 @@ async function loadPostTypeEntities() { syncConfig: { /** * @param {Y.Doc} ydoc - * @param {any} changes + * @param {any} allChanges */ - applyChangesToCRDTDoc: ( ydoc, changes ) => { + applyChangesToCRDTDoc: ( ydoc, allChanges ) => { // local changes happened. Apply the differences to the ydoc const ycontent = ydoc.getMap( 'document' ); + const changes = Object.entries( allChanges ).filter( + ( [ key ] ) => syncedProperties.has( key ) + ); + Object.entries( changes ).forEach( ( [ key, value ] ) => { if ( typeof value !== 'function' ) { if ( key === 'blocks' ) { @@ -495,7 +499,6 @@ async function loadPostTypeEntities() { ); } } else if ( - ! syncedProperties.has( key ) && ! fun.equalityDeep( ycontent.get( key ), value ) ) { ycontent.set( key, value ); From db5d1359953a465242e873b36317f8fdd1455d1a Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 18 Aug 2025 13:19:53 -0600 Subject: [PATCH 35/99] Reform object --- packages/core-data/src/entities.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 51597a05584c7b..0f5e2a9bf09dba 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -337,8 +337,10 @@ async function loadPostTypeEntities() { applyChangesToCRDTDoc: ( ydoc, allChanges ) => { // local changes happened. Apply the differences to the ydoc const ycontent = ydoc.getMap( 'document' ); - const changes = Object.entries( allChanges ).filter( - ( [ key ] ) => syncedProperties.has( key ) + const changes = Object.fromEntries( + Object.entries( allChanges ).filter( ( [ key ] ) => + syncedProperties.has( key ) + ) ); Object.entries( changes ).forEach( ( [ key, value ] ) => { From 2c06dcba6824a49092dbf92a52cfcbe15420ca39 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 18 Aug 2025 14:49:11 -0600 Subject: [PATCH 36/99] Remove content and excerpt from synced property list --- packages/core-data/src/entities.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 0f5e2a9bf09dba..a29a25c60171ec 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -289,8 +289,6 @@ function makeBlocksSerializable( blocks ) { async function loadPostTypeEntities() { const syncedProperties = new Set( [ 'blocks', - 'content', - 'excerpt', 'featured_media', 'format', 'generated_slug', From 38d0cf369d0824c90f30f347f7b4106a165e3857 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 18 Aug 2025 18:19:44 -0600 Subject: [PATCH 37/99] Move merge functions to separate utils file --- packages/core-data/src/entities.js | 239 ++++---------------- packages/core-data/src/utils/crdt-blocks.ts | 183 +++++++++++++++ packages/core-data/src/utils/crdt.ts | 21 ++ packages/sync/src/provider.ts | 12 +- packages/sync/src/types.ts | 6 +- 5 files changed, 260 insertions(+), 201 deletions(-) create mode 100644 packages/core-data/src/utils/crdt-blocks.ts create mode 100644 packages/core-data/src/utils/crdt.ts diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index a29a25c60171ec..9afa1f18db27e2 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -2,22 +2,19 @@ * External dependencies */ import { capitalCase, pascalCase } from 'change-case'; -import { v4 as uuidv4 } from 'uuid'; /** * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; import { __ } from '@wordpress/i18n'; -import { RichTextData } from '@wordpress/rich-text'; import { parse } from '@wordpress/blocks'; import { Y } from '@wordpress/sync'; -import * as math from 'lib0/math'; -import * as fun from 'lib0/function'; /** * Internal dependencies */ +import { mergeBlocks, mergePrimitiveValue } from './utils/crdt'; export const DEFAULT_ENTITY_KEY = 'id'; const POST_RAW_ATTRIBUTES = [ 'title', 'excerpt', 'content' ]; @@ -255,32 +252,6 @@ export const prePersistPostType = ( persistedRecord, edits ) => { return newEdits; }; -const serialisableBlocksCache = new WeakMap(); - -function makeBlockAttributesSerializable( attributes ) { - const newAttributes = { ...attributes }; - for ( const [ key, value ] of Object.entries( attributes ) ) { - if ( value instanceof RichTextData ) { - newAttributes[ key ] = value.valueOf(); - } - } - return newAttributes; -} - -function makeBlocksSerializable( blocks ) { - return blocks.map( ( block ) => { - const { innerBlocks, attributes, ...rest } = block; - delete rest.validationIssues; - delete rest.originalContent; - // delete rest.isValid - return { - ...rest, - attributes: makeBlockAttributesSerializable( attributes ), - innerBlocks: makeBlocksSerializable( innerBlocks ), - }; - } ); -} - /** * Returns the list of post type entities. * @@ -329,179 +300,55 @@ async function loadPostTypeEntities() { __unstable_rest_base: postType.rest_base, syncConfig: { /** - * @param {Y.Doc} ydoc - * @param {any} allChanges + * @param {Y.Doc} ydoc + * @param {any} changes + * @param {string} origin */ - applyChangesToCRDTDoc: ( ydoc, allChanges ) => { + applyChangesToCRDTDoc: ( ydoc, changes, origin ) => { // local changes happened. Apply the differences to the ydoc const ycontent = ydoc.getMap( 'document' ); - const changes = Object.fromEntries( - Object.entries( allChanges ).filter( ( [ key ] ) => - syncedProperties.has( key ) - ) + + const filteredEntries = Object.entries( changes ).filter( + ( [ key, value ] ) => + syncedProperties.has( key ) && + 'function' !== typeof value // cannot serialize function values ); - Object.entries( changes ).forEach( ( [ key, value ] ) => { - if ( typeof value !== 'function' ) { - if ( key === 'blocks' ) { - if ( ! serialisableBlocksCache.has( value ) ) { - serialisableBlocksCache.set( - value, - makeBlocksSerializable( value ) - ); - } - const blocks = - serialisableBlocksCache.get( 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` - if ( - ! ycontent.has( key ) || - ycontent.get( key ) instanceof Array - ) { - // @todo remove the array check - ycontent.set( key, new Y.Array() ); - } - /** - * @type {Y.Array>} - */ - const yblocks = ycontent.get( key ); - const numOfCommonEntries = math.min( - blocks.length, - yblocks.length - ); - let left = 0; - let right = 0; - /** - * @param {any} gblock - * @param {Y.Map} yblock - */ - const blocksEqual = ( gblock, yblock ) => { - if ( yblock.toJSON ) { - yblock = yblock.toJSON(); - } - // we must not sync clientId, as this can't be generated consistenctly and - // hence will lead to merge conflicts. - const overwrites = { - innerBlocks: null, - clientId: null, - }; - const res = fun.equalityDeep( - Object.assign( {}, gblock, overwrites ), - Object.assign( {}, yblock, overwrites ) - ); - const inners = gblock.innerBlocks || []; - const yinners = yblock.innerBlocks || []; - return ( - res && - inners.length === yinners.length && - inners.every( ( block, i ) => - blocksEqual( block, yinners[ i ] ) - ) - ); - }; - // skip equal blocks from left - for ( - ; - left < numOfCommonEntries && - blocksEqual( - blocks[ left ], - yblocks.get( left ) - ); - left++ - ) { - /* nop */ - } - // skip equal blocks from right - for ( - ; - right < numOfCommonEntries - left && - blocksEqual( - blocks[ blocks.length - right - 1 ], - yblocks.get( - yblocks.length - right - 1 - ) - ); - right++ - ) { - /* nop */ + filteredEntries.forEach( ( [ key, newValue ] ) => { + const currentValue = ycontent.get( key ); + + // Return .get() result so that caller can operate on the data type + // without having to call .get() themselves. + function setValue( updatedValue ) { + ycontent.set( key, updatedValue ); + return ycontent.get( key ); + } + + switch ( key ) { + case 'blocks': { + let currentBlocks = currentValue; + if ( ! ( currentBlocks instanceof Y.Array ) ) { + currentBlocks = setValue( new Y.Array() ); // Initialize } - const numOfUpdatesNeeded = - numOfCommonEntries - left - right; - const numOfInsertionsNeeded = math.max( - 0, - blocks.length - yblocks.length - ); - const numOfDeletionsNeeded = math.max( - 0, - yblocks.length - blocks.length + + // Block[] from local changes or Y.Array< Y.Map > from peer. + const newBlocks = newValue ?? []; + + // Merge blocks does not need `setValue` because it has been + // called above and the result can be operated on directly. + mergeBlocks( currentBlocks, newBlocks, origin ); + break; + } + + // Add support for additional data types here. + + default: { + mergePrimitiveValue( + currentValue ?? undefined, + newValue ?? undefined, + setValue, + origin ); - // updates - for ( - let i = 0; - i < numOfUpdatesNeeded; - i++, left++ - ) { - const block = blocks[ left ]; - const yblock = yblocks.get( left ); - Object.entries( block ).forEach( - ( [ k, v ] ) => { - if ( - ! fun.equalityDeep( - block[ k ], - yblock.get( k ) - ) - ) { - yblock.set( k, v ); - } - } - ); - yblock.forEach( ( _v, k ) => { - if ( ! block.hasOwnProperty( k ) ) { - yblock.delete( k ); - } - } ); - } - // deletes - yblocks.delete( left, numOfDeletionsNeeded ); - // inserts - for ( - let i = 0; - i < numOfInsertionsNeeded; - i++, left++ - ) { - yblocks.insert( left, [ - new Y.Map( - Object.entries( blocks[ left ] ) - ), - ] ); - } - const knownClientIds = new Set(); - // remove duplicate clientids - for ( let j = 0; j < yblocks.length; j++ ) { - const yblock = yblocks.get( j ); - if ( - knownClientIds.has( - yblock.get( 'clientId' ) - ) - ) { - yblock.set( 'clientId', uuidv4() ); - } - knownClientIds.add( - yblock.get( 'clientId' ) - ); - } - } else if ( - ! fun.equalityDeep( ycontent.get( key ), value ) - ) { - ycontent.set( key, value ); } } } ); diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts new file mode 100644 index 00000000000000..e0a8c89c15e07a --- /dev/null +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -0,0 +1,183 @@ +/** + * External dependencies + */ +import { v4 as uuidv4 } from 'uuid'; +import * as math from 'lib0/math'; +import * as fun from 'lib0/function'; + +/** + * WordPress dependencies + */ +import { RichTextData } from '@wordpress/rich-text'; +import { Y } from '@wordpress/sync'; + +interface BlockAttributes { + [ key: string ]: unknown; +} + +interface Block { + attributes: BlockAttributes; + clientId?: string; + innerBlocks: Block[]; + originalContent?: string; // unserializable + validationIssues?: string[]; // unserializable +} + +type Foo = Y.Map< Block[ keyof Block ] >; + +const serializableBlocksCache = new WeakMap< WeakKey, Block[] >(); + +function makeBlockAttributesSerializable( + attributes: BlockAttributes +): BlockAttributes { + const newAttributes = { ...attributes }; + for ( const [ key, value ] of Object.entries( attributes ) ) { + if ( value instanceof RichTextData ) { + newAttributes[ key ] = value.valueOf(); + } + } + return newAttributes; +} + +function makeBlocksSerializable( blocks: Block[] | Y.Array< Foo > ): Block[] { + return blocks.map( ( block: Block | Foo ) => { + const blockAsJson = block instanceof Y.Map ? block.toJSON() : block; + const { innerBlocks, attributes, ...rest } = blockAsJson; + delete rest.validationIssues; + delete rest.originalContent; + // delete rest.isValid + return { + ...rest, + attributes: makeBlockAttributesSerializable( attributes ), + innerBlocks: makeBlocksSerializable( innerBlocks ), + }; + } ); +} + +/** + * @param {any} gblock + * @param {Y.Map} yblock + */ +function areBlocksEqual( gblock: Block, yblock: Foo ): boolean { + const yblockAsJson = yblock.toJSON(); + + // we must not sync clientId, as this can't be generated consistenctly and + // hence will lead to merge conflicts. + const overwrites = { + innerBlocks: null, + clientId: null, + }; + const res = fun.equalityDeep( + Object.assign( {}, gblock, overwrites ), + Object.assign( {}, yblock, overwrites ) + ); + const inners = gblock.innerBlocks || []; + const yinners = yblockAsJson.innerBlocks || []; + return ( + res && + inners.length === yinners.length && + inners.every( ( block: Block, i: number ) => + areBlocksEqual( block, yinners[ i ] ) + ) + ); +} + +export function mergeBlocks( + yblocks: Y.Array< Foo >, + newValue: Block[] | Y.Array< Foo >, + _origin: string // eslint-disable-line @typescript-eslint/no-unused-vars +): void { + // Ensure we are working with serializable block data. + if ( ! serializableBlocksCache.has( newValue ) ) { + serializableBlocksCache.set( + newValue, + makeBlocksSerializable( newValue ) + ); + } + const blocks = serializableBlocksCache.get( newValue ) ?? []; + + // 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` + + const numOfCommonEntries = math.min( blocks.length ?? 0, yblocks.length ); + + let left = 0; + let right = 0; + + // skip equal blocks from left + for ( + ; + left < numOfCommonEntries && + areBlocksEqual( blocks[ left ], yblocks.get( left ) ); + left++ + ) { + /* nop */ + } + + // skip equal blocks from right + for ( + ; + right < numOfCommonEntries - left && + areBlocksEqual( + blocks[ blocks.length - right - 1 ], + yblocks.get( yblocks.length - right - 1 ) + ); + right++ + ) { + /* nop */ + } + + const numOfUpdatesNeeded = numOfCommonEntries - left - right; + const numOfInsertionsNeeded = math.max( 0, blocks.length - yblocks.length ); + const numOfDeletionsNeeded = math.max( 0, yblocks.length - blocks.length ); + + // updates + for ( let i = 0; i < numOfUpdatesNeeded; i++, left++ ) { + const block = blocks[ left ]; + const yblock = yblocks.get( left ); + Object.entries( block ).forEach( ( [ k, v ] ) => { + if ( ! fun.equalityDeep( block[ k ], yblock.get( k ) ) ) { + yblock.set( k, v ); + } + } ); + yblock.forEach( ( _v, k ) => { + if ( ! block.hasOwnProperty( k ) ) { + yblock.delete( k ); + } + } ); + } + + // deletes + yblocks.delete( left, numOfDeletionsNeeded ); + + // inserts + for ( let i = 0; i < numOfInsertionsNeeded; i++, left++ ) { + yblocks.insert( left, [ + new Y.Map< Block[ keyof Block ] >( + Object.entries( blocks[ left ] ) + ), + ] ); + } + + // remove duplicate clientids + const knownClientIds = new Set< string >(); + for ( let j = 0; j < yblocks.length; j++ ) { + const yblock: Y.Map< Block[ keyof Block ] > = yblocks.get( j ); + + let clientId: string = yblock.get( 'clientId' ) as string; + + if ( knownClientIds.has( clientId ) ) { + clientId = uuidv4(); + yblock.set( 'clientId', clientId ); + } + knownClientIds.add( clientId ); + } +} diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts new file mode 100644 index 00000000000000..921eeed9f73e19 --- /dev/null +++ b/packages/core-data/src/utils/crdt.ts @@ -0,0 +1,21 @@ +/** + * External dependencies + */ +import * as fun from 'lib0/function'; + +export { mergeBlocks } from './crdt-blocks'; + +export type SetValueFunction< ValueType = unknown > = ( + value: ValueType +) => ValueType; + +export function mergePrimitiveValue< ValueType = unknown >( + currentValue: ValueType, + newValue: ValueType, + setValue: SetValueFunction< ValueType >, + _origin: string // eslint-disable-line @typescript-eslint/no-unused-vars +): void { + if ( ! fun.equalityDeep( currentValue, newValue ) ) { + setValue( newValue ); + } +} diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index 8a457199556f75..69297d4c5b25e1 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -187,7 +187,11 @@ export class SyncProvider { const initialStateDoc = new Y.Doc( { meta: new Map() } ); const initialData = syncConfig.getInitialObjectData( record ); - syncConfig.applyChangesToCRDTDoc( initialStateDoc, initialData ); + syncConfig.applyChangesToCRDTDoc( + initialStateDoc, + initialData, + 'syncProvider.getInitialCRDTDoc' + ); return initialStateDoc; } @@ -222,10 +226,10 @@ export class SyncProvider { return; } - const entityState = this.getEntityState( objectType, objectId ); + const ydoc = this.getEntityState( objectType, objectId )?.ydoc; - entityState?.ydoc.transact( () => { - syncConfig.applyChangesToCRDTDoc( entityState.ydoc, changes ); + ydoc?.transact( () => { + syncConfig.applyChangesToCRDTDoc( ydoc, changes, origin ); }, origin ); } diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 5b676821d67ac8..7561c8afe1671a 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -25,7 +25,11 @@ export type ConnectDoc = ( ) => Promise< ConnectDocResult >; export type SyncConfig = { - applyChangesToCRDTDoc: ( ydoc: Y.Doc, data: Partial< ObjectData > ) => void; + applyChangesToCRDTDoc: ( + ydoc: Y.Doc, + data: Partial< ObjectData >, + origin: string + ) => void; fromCRDTDoc: ( ydoc: Y.Doc ) => ObjectData; getInitialObjectData: ( record: ObjectData ) => ObjectData; getObjectId: ( data: ObjectData ) => ObjectID; From 44b96cfcfe64680fe35b61804ff0130c33529262 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 18 Aug 2025 18:26:05 -0600 Subject: [PATCH 38/99] Rename Foo type and add comment --- packages/core-data/src/utils/crdt-blocks.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index e0a8c89c15e07a..326deb8768d96e 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -23,7 +23,11 @@ interface Block { validationIssues?: string[]; // unserializable } -type Foo = Y.Map< Block[ keyof Block ] >; +// The Y.Map type is not easy to work with. The generic type it accepts represents +// the possible values of the map, which are varied in our case. This type is +// accurate, but will require aggressive type narrowing when the map values are +// accessed -- or type casting with `as`. +type YBlock = Y.Map< Block[ keyof Block ] >; const serializableBlocksCache = new WeakMap< WeakKey, Block[] >(); @@ -39,8 +43,10 @@ function makeBlockAttributesSerializable( return newAttributes; } -function makeBlocksSerializable( blocks: Block[] | Y.Array< Foo > ): Block[] { - return blocks.map( ( block: Block | Foo ) => { +function makeBlocksSerializable( + blocks: Block[] | Y.Array< YBlock > +): Block[] { + return blocks.map( ( block: Block | YBlock ) => { const blockAsJson = block instanceof Y.Map ? block.toJSON() : block; const { innerBlocks, attributes, ...rest } = blockAsJson; delete rest.validationIssues; @@ -58,7 +64,7 @@ function makeBlocksSerializable( blocks: Block[] | Y.Array< Foo > ): Block[] { * @param {any} gblock * @param {Y.Map} yblock */ -function areBlocksEqual( gblock: Block, yblock: Foo ): boolean { +function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean { const yblockAsJson = yblock.toJSON(); // we must not sync clientId, as this can't be generated consistenctly and @@ -83,8 +89,8 @@ function areBlocksEqual( gblock: Block, yblock: Foo ): boolean { } export function mergeBlocks( - yblocks: Y.Array< Foo >, - newValue: Block[] | Y.Array< Foo >, + yblocks: Y.Array< YBlock >, + newValue: Block[] | Y.Array< YBlock >, _origin: string // eslint-disable-line @typescript-eslint/no-unused-vars ): void { // Ensure we are working with serializable block data. From aab297648a42403a50ad7c24661f0989db2e4e8b Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 20 Aug 2025 21:02:11 -0600 Subject: [PATCH 39/99] Sync post title --- packages/core-data/src/entities.js | 39 +++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 9afa1f18db27e2..115465933f89dd 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -24,11 +24,7 @@ const POST_RAW_ATTRIBUTES = [ 'title', 'excerpt', 'content' ]; * @return {import('@wordpress/sync').ObjectData} The JSON representation of the document. */ const defaultFromCRDTDoc = ( ydoc ) => { - const json = ydoc.getMap( 'document' ).toJSON(); - if ( json.title?.raw ) { - json.title = json.title.raw; - } - return json; + return ydoc.getMap( 'document' ).toJSON(); }; export const rootEntitiesConfig = [ @@ -268,6 +264,7 @@ async function loadPostTypeEntities() { 'sticky', 'tags', 'template', + 'title', ] ); const postTypes = await apiFetch( { @@ -324,6 +321,16 @@ async function loadPostTypeEntities() { return ycontent.get( key ); } + // Set primitive a value (strings, numbers, booleans). + function setPrimitiveValue( primitiveValue ) { + mergePrimitiveValue( + currentValue ?? undefined, + primitiveValue ?? undefined, + setValue, + origin + ); + } + switch ( key ) { case 'blocks': { let currentBlocks = currentValue; @@ -340,15 +347,25 @@ async function loadPostTypeEntities() { break; } + case 'title': { + // Copy logic from prePersistPostType to ensure that the "Auto + // Draft" template title is not synced. + let rawNewValue = newValue?.raw ?? newValue; + if ( + ! currentValue && + 'Auto Draft' === rawNewValue + ) { + rawNewValue = ''; + } + + setPrimitiveValue( rawNewValue ); + break; + } + // Add support for additional data types here. default: { - mergePrimitiveValue( - currentValue ?? undefined, - newValue ?? undefined, - setValue, - origin - ); + setPrimitiveValue( newValue ); } } } ); From 414c61f9ef513cc9205b4bb105d981f59607abf8 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Thu, 21 Aug 2025 20:04:31 +1000 Subject: [PATCH 40/99] Improve block support --- packages/core-data/src/utils/crdt-blocks.ts | 36 +++++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 326deb8768d96e..844a1a31bcf3a6 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -21,6 +21,7 @@ interface Block { innerBlocks: Block[]; originalContent?: string; // unserializable validationIssues?: string[]; // unserializable + name: string; } // The Y.Map type is not easy to work with. The generic type it accepts represents @@ -48,12 +49,13 @@ function makeBlocksSerializable( ): Block[] { return blocks.map( ( block: Block | YBlock ) => { const blockAsJson = block instanceof Y.Map ? block.toJSON() : block; - const { innerBlocks, attributes, ...rest } = blockAsJson; + const { name, innerBlocks, attributes, ...rest } = blockAsJson; delete rest.validationIssues; delete rest.originalContent; // delete rest.isValid return { ...rest, + name, attributes: makeBlockAttributesSerializable( attributes ), innerBlocks: makeBlocksSerializable( innerBlocks ), }; @@ -67,7 +69,7 @@ function makeBlocksSerializable( function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean { const yblockAsJson = yblock.toJSON(); - // we must not sync clientId, as this can't be generated consistenctly and + // we must not sync clientId, as this can't be generated consistently and // hence will lead to merge conflicts. const overwrites = { innerBlocks: null, @@ -100,7 +102,12 @@ export function mergeBlocks( makeBlocksSerializable( newValue ) ); } - const blocks = serializableBlocksCache.get( newValue ) ?? []; + const unfilteredBlocks = serializableBlocksCache.get( newValue ) ?? []; + + // Ensure we skip blocks that we don't want to sync at the moment + const blocks = unfilteredBlocks.filter( ( block ) => + shouldBlockBeSynced( block ) + ); // This is a rudimentary diff implementation similar to the y-prosemirror diffing // approach. @@ -187,3 +194,26 @@ export function mergeBlocks( knownClientIds.add( clientId ); } } + +/** + * Determine if a block should be synced. + * + * Ex: A gallery block should not be synced until the images have been + * uploaded to WordPress, and their url is available. Before that, + * it's not possible to access the blobs on a client as those are + * local. + * + * @param block The block to check. + * @return True if the block should be synced, false otherwise. + */ +function shouldBlockBeSynced( block: Block ): boolean { + switch ( block.name ) { + case 'core/gallery': + return ! block.innerBlocks.some( + ( innerBlock ) => + innerBlock.attributes && innerBlock.attributes.blob + ); + default: + return true; + } +} From a7193cf1ec2570b6ef4890c76d585d6055d43ecf Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 21 Aug 2025 16:12:14 -0600 Subject: [PATCH 41/99] Move applyChangesToDoc core code to utils/crdt --- packages/core-data/src/entities.js | 81 ++++--------------- packages/core-data/src/utils/crdt-blocks.ts | 4 +- packages/core-data/src/utils/crdt.ts | 86 +++++++++++++++++++-- 3 files changed, 94 insertions(+), 77 deletions(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 115465933f89dd..4999feae07d61e 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -9,12 +9,11 @@ import { capitalCase, pascalCase } from 'change-case'; import apiFetch from '@wordpress/api-fetch'; import { __ } from '@wordpress/i18n'; import { parse } from '@wordpress/blocks'; -import { Y } from '@wordpress/sync'; /** * Internal dependencies */ -import { mergeBlocks, mergePrimitiveValue } from './utils/crdt'; +import { defaultApplyChangesToCRDTDoc } from './utils/crdt'; export const DEFAULT_ENTITY_KEY = 'id'; const POST_RAW_ATTRIBUTES = [ 'title', 'excerpt', 'content' ]; @@ -298,77 +297,23 @@ async function loadPostTypeEntities() { syncConfig: { /** * @param {Y.Doc} ydoc - * @param {any} changes + * @param {Object} changes * @param {string} origin */ applyChangesToCRDTDoc: ( ydoc, changes, origin ) => { - // local changes happened. Apply the differences to the ydoc - const ycontent = ydoc.getMap( 'document' ); - - const filteredEntries = Object.entries( changes ).filter( - ( [ key, value ] ) => - syncedProperties.has( key ) && - 'function' !== typeof value // cannot serialize function values + const filteredChanges = Object.fromEntries( + Object.entries( changes ).filter( + ( [ key, value ] ) => + syncedProperties.has( key ) && + 'function' !== typeof value // cannot serialize function values + ) ); - filteredEntries.forEach( ( [ key, newValue ] ) => { - const currentValue = ycontent.get( key ); - - // Return .get() result so that caller can operate on the data type - // without having to call .get() themselves. - function setValue( updatedValue ) { - ycontent.set( key, updatedValue ); - return ycontent.get( key ); - } - - // Set primitive a value (strings, numbers, booleans). - function setPrimitiveValue( primitiveValue ) { - mergePrimitiveValue( - currentValue ?? undefined, - primitiveValue ?? undefined, - setValue, - origin - ); - } - - switch ( key ) { - case 'blocks': { - let currentBlocks = currentValue; - if ( ! ( currentBlocks instanceof Y.Array ) ) { - currentBlocks = setValue( new Y.Array() ); // Initialize - } - - // Block[] from local changes or Y.Array< Y.Map > from peer. - const newBlocks = newValue ?? []; - - // Merge blocks does not need `setValue` because it has been - // called above and the result can be operated on directly. - mergeBlocks( currentBlocks, newBlocks, origin ); - break; - } - - case 'title': { - // Copy logic from prePersistPostType to ensure that the "Auto - // Draft" template title is not synced. - let rawNewValue = newValue?.raw ?? newValue; - if ( - ! currentValue && - 'Auto Draft' === rawNewValue - ) { - rawNewValue = ''; - } - - setPrimitiveValue( rawNewValue ); - break; - } - - // Add support for additional data types here. - - default: { - setPrimitiveValue( newValue ); - } - } - } ); + defaultApplyChangesToCRDTDoc( + ydoc, + filteredChanges, + origin + ); }, fromCRDTDoc: defaultFromCRDTDoc, /** diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 844a1a31bcf3a6..cc10bb99bd0c5f 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -15,7 +15,7 @@ interface BlockAttributes { [ key: string ]: unknown; } -interface Block { +export interface Block { attributes: BlockAttributes; clientId?: string; innerBlocks: Block[]; @@ -28,7 +28,7 @@ interface Block { // the possible values of the map, which are varied in our case. This type is // accurate, but will require aggressive type narrowing when the map values are // accessed -- or type casting with `as`. -type YBlock = Y.Map< Block[ keyof Block ] >; +export type YBlock = Y.Map< Block[ keyof Block ] >; const serializableBlocksCache = new WeakMap< WeakKey, Block[] >(); diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index 921eeed9f73e19..5cfb047a16b310 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -3,17 +3,89 @@ */ import * as fun from 'lib0/function'; -export { mergeBlocks } from './crdt-blocks'; +/** + * WordPress dependencies + */ +import { type CRDTDoc, Y } from '@wordpress/sync'; + +/** + * Internal dependencies + */ +import { mergeBlocks, type Block, type YBlock } from './crdt-blocks'; + +type PrimitiveValue = string | number | boolean | null | undefined; + +interface PostChanges { + blocks?: Y.Array< YBlock > | Block[]; + title?: string | { raw: string }; +} + +export function defaultApplyChangesToCRDTDoc( + ydoc: CRDTDoc, + changes: PostChanges, + origin: string +): void { + const ymap = ydoc.getMap( 'document' ); -export type SetValueFunction< ValueType = unknown > = ( - value: ValueType -) => ValueType; + Object.entries( changes ).forEach( ( [ key, newValue ] ) => { + // Return .get() result so that caller can operate on the data type + // without having to call .get() themselves. + function setValue< T = unknown >( updatedValue: T ): T { + ymap.set( key, updatedValue ); + return ymap.get( key ) as T; + } + + switch ( key ) { + case 'blocks': { + let currentBlocks = ymap.get( + 'blocks' + ) as PostChanges[ 'blocks' ]; + + if ( ! ( currentBlocks instanceof Y.Array ) ) { + currentBlocks = setValue< Y.Array< YBlock > >( + new Y.Array() + ); // Initialize + } + + // Block[] from local changes or Y.Array< Y.Map > from peer. + const newBlocks = newValue ?? []; + + // Merge blocks does not need `setValue` because it has been + // called above and the result can be operated on directly. + mergeBlocks( currentBlocks, newBlocks, origin ); + break; + } + + case 'title': { + const currentValue = ymap.get( + 'title' + ) as PostChanges[ 'title' ]; + + // Copy logic from prePersistPostType to ensure that the "Auto + // Draft" template title is not synced. + let rawNewValue = newValue?.raw ?? newValue; + if ( ! currentValue && 'Auto Draft' === rawNewValue ) { + rawNewValue = ''; + } + + mergePrimitiveValue( currentValue, rawNewValue, setValue ); + break; + } + + // Add support for additional data types here. + + default: { + const currentValue = ymap.get( key ); + mergePrimitiveValue( currentValue, newValue, setValue ); + } + } + } ); +} -export function mergePrimitiveValue< ValueType = unknown >( +export function mergePrimitiveValue< ValueType extends PrimitiveValue >( currentValue: ValueType, newValue: ValueType, - setValue: SetValueFunction< ValueType >, - _origin: string // eslint-disable-line @typescript-eslint/no-unused-vars + setValue: ( value: ValueType ) => ValueType ): void { if ( ! fun.equalityDeep( currentValue, newValue ) ) { setValue( newValue ); From 375e35b2844270c1524236e52bbc4addc42ce30b Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 21 Aug 2025 16:38:46 -0600 Subject: [PATCH 42/99] Add post type support for collaborative editing --- lib/experimental/synchronization.php | 17 +++++++++++++++++ packages/core-data/src/entities.js | 7 +++++-- packages/core-data/src/resolvers.js | 2 +- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/lib/experimental/synchronization.php b/lib/experimental/synchronization.php index 87f13a7c685a59..029d47c8a365a9 100644 --- a/lib/experimental/synchronization.php +++ b/lib/experimental/synchronization.php @@ -22,3 +22,20 @@ function gutenberg_rest_api_init_collaborative_editing() { wp_add_inline_script( 'wp-sync', 'window.__experimentalCollaborativeEditingSecret = "' . $collaborative_editing_secret . '";', 'before' ); } add_action( 'admin_init', 'gutenberg_rest_api_init_collaborative_editing' ); + +/** + * Add support for collaborative editing to a some built-in post types. + */ +function gutenberg_add_collaborative_editing_post_type_support() { + $gutenberg_experiments = get_option( 'gutenberg-experiments' ); + if ( ! $gutenberg_experiments || ! array_key_exists( 'gutenberg-sync-collaboration', $gutenberg_experiments ) ) { + return; + } + + foreach ( array( 'page', 'post' ) as $post_type ) { + if ( post_type_exists( $post_type ) ) { + add_post_type_support( $post_type, 'collaborative-editing' ); + } + } +} +add_action( 'init', 'gutenberg_add_collaborative_editing_post_type_support', 10, 0 ); diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 4999feae07d61e..7793728f696536 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -267,7 +267,7 @@ async function loadPostTypeEntities() { ] ); const postTypes = await apiFetch( { - path: '/wp/v2/types?context=view', + path: '/wp/v2/types?context=edit', } ); return Object.entries( postTypes ?? {} ).map( ( [ name, postType ] ) => { const isTemplate = [ 'wp_template', 'wp_template_part' ].includes( @@ -295,6 +295,9 @@ async function loadPostTypeEntities() { __unstablePrePersist: isTemplate ? undefined : prePersistPostType, __unstable_rest_base: postType.rest_base, syncConfig: { + enabled: Boolean( + postType.supports?.[ 'collaborative-editing' ] + ), /** * @param {Y.Doc} ydoc * @param {Object} changes @@ -338,7 +341,7 @@ async function loadPostTypeEntities() { ); }, getObjectId: ( { id } ) => id, - objectType: 'postType/' + postType.name, + objectType: `postType/${ postType.slug }`, supportsAwareness: true, supportsUndo: true, }, diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 40d61dcd22de57..21e677294c2990 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -149,7 +149,7 @@ export const getEntityRecord = if ( window.__experimentalEnableSync && - entityConfig.syncConfig && + entityConfig.syncConfig?.enabled && ! query ) { if ( globalThis.IS_GUTENBERG_PLUGIN ) { From 059e10705eec5caedb6b9e9d97a14630296ce5f3 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 21 Aug 2025 16:46:03 -0600 Subject: [PATCH 43/99] Add editor support check --- packages/core-data/src/entities.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 7793728f696536..6f2d0d30b18e81 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -296,7 +296,8 @@ async function loadPostTypeEntities() { __unstable_rest_base: postType.rest_base, syncConfig: { enabled: Boolean( - postType.supports?.[ 'collaborative-editing' ] + postType.supports?.[ 'collaborative-editing' ] && + postType.supports?.editor ), /** * @param {Y.Doc} ydoc From 04cb185096c2450a90739657efa1e311c5af9445 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Mon, 25 Aug 2025 14:12:41 +1000 Subject: [PATCH 44/99] Tweak the flow with comments --- packages/core-data/src/utils/crdt-blocks.ts | 28 +++++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index cc10bb99bd0c5f..c270f0cb132626 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -8,6 +8,7 @@ import * as fun from 'lib0/function'; /** * WordPress dependencies */ +import { applyFilters } from '@wordpress/hooks'; import { RichTextData } from '@wordpress/rich-text'; import { Y } from '@wordpress/sync'; @@ -207,13 +208,24 @@ export function mergeBlocks( * @return True if the block should be synced, false otherwise. */ function shouldBlockBeSynced( block: Block ): boolean { - switch ( block.name ) { - case 'core/gallery': - return ! block.innerBlocks.some( - ( innerBlock ) => - innerBlock.attributes && innerBlock.attributes.blob - ); - default: - return true; + // Verify that the gallery block is ready to be synced. + // This means that, all images have had their blobs converted to full URLs. + // Checking for only the blobs ensures that blocks that have just been inserted work as well. + if ( 'core/gallery' === block.name ) { + return ! block.innerBlocks.some( + ( innerBlock ) => + innerBlock.attributes && innerBlock.attributes.blob + ); + } + + // Except the gallery block, all the other core blocks should be synced. + // We don't want anyone to override that, as that'll cause problems. + if ( block.name.startsWith( 'core/' ) ) { + return true; } + + // ToDo: Document this filter once its finalized. + // Allow third party blocks to customize if they are ready to be synced or not. + // It'll always be true by default as we assume it is ready to be synced. + return applyFilters( 'core.shouldBlockBeSynced', true, block ) as boolean; } From bfe801e65bfdb511e98b3da58b7a53be3087852e Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Tue, 26 Aug 2025 07:56:26 +1000 Subject: [PATCH 45/99] Drop the filter --- packages/core-data/src/utils/crdt-blocks.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index c270f0cb132626..7f7327c2782db7 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -8,7 +8,6 @@ import * as fun from 'lib0/function'; /** * WordPress dependencies */ -import { applyFilters } from '@wordpress/hooks'; import { RichTextData } from '@wordpress/rich-text'; import { Y } from '@wordpress/sync'; @@ -218,14 +217,6 @@ function shouldBlockBeSynced( block: Block ): boolean { ); } - // Except the gallery block, all the other core blocks should be synced. - // We don't want anyone to override that, as that'll cause problems. - if ( block.name.startsWith( 'core/' ) ) { - return true; - } - - // ToDo: Document this filter once its finalized. - // Allow third party blocks to customize if they are ready to be synced or not. - // It'll always be true by default as we assume it is ready to be synced. - return applyFilters( 'core.shouldBlockBeSynced', true, block ) as boolean; + // Allow all other blocks to be synced. + return true; } From 5571de6f91abc65f69213f6e082d92e4c3bfe84c Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Wed, 27 Aug 2025 13:31:53 -0600 Subject: [PATCH 46/99] Change 'mergeBlocks()' to 'mergeCrdtBlocks()' to make it distinct --- packages/core-data/src/utils/crdt-blocks.ts | 2 +- packages/core-data/src/utils/crdt.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 7f7327c2782db7..fd54c0a1659903 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -90,7 +90,7 @@ function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean { ); } -export function mergeBlocks( +export function mergeCrdtBlocks( yblocks: Y.Array< YBlock >, newValue: Block[] | Y.Array< YBlock >, _origin: string // eslint-disable-line @typescript-eslint/no-unused-vars diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index 5cfb047a16b310..c9e74a532e36d5 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -11,7 +11,7 @@ import { type CRDTDoc, Y } from '@wordpress/sync'; /** * Internal dependencies */ -import { mergeBlocks, type Block, type YBlock } from './crdt-blocks'; +import { mergeCrdtBlocks, type Block, type YBlock } from './crdt-blocks'; type PrimitiveValue = string | number | boolean | null | undefined; @@ -52,7 +52,7 @@ export function defaultApplyChangesToCRDTDoc( // Merge blocks does not need `setValue` because it has been // called above and the result can be operated on directly. - mergeBlocks( currentBlocks, newBlocks, origin ); + mergeCrdtBlocks( currentBlocks, newBlocks, origin ); break; } From d3ffe0bdd210ae5b65444b6d0af1f3aed110e691 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Wed, 27 Aug 2025 15:59:44 -0600 Subject: [PATCH 47/99] Change mergeCrdtBlocks() to use direct Y types for yblocks. First step to standardizing Y types stored in the ydoc --- packages/core-data/src/utils/crdt-blocks.ts | 77 +++++++++++++++------ packages/core-data/src/utils/crdt.ts | 4 +- 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index fd54c0a1659903..ab10cca89eae7c 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -24,11 +24,22 @@ export interface Block { name: string; } +export type YBlock = Y.Map< + /* name, clientId, and originalContent are strings. */ + | string + /* validationIssues? is an array of strings. */ + | string[] + /* attributes is a Y.Map< unknown >. */ + | Y.Map< unknown > + /* innerBlocks is a Y.Array< YBlock >. */ + | Y.Array< YBlock > +>; + // The Y.Map type is not easy to work with. The generic type it accepts represents // the possible values of the map, which are varied in our case. This type is // accurate, but will require aggressive type narrowing when the map values are // accessed -- or type casting with `as`. -export type YBlock = Y.Map< Block[ keyof Block ] >; +// export type YBlock = Y.Map< Block[ keyof Block ] >; const serializableBlocksCache = new WeakMap< WeakKey, Block[] >(); @@ -90,22 +101,31 @@ function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean { ); } +/** + * 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 _origin The origin of the sync, either 'syncProvider.getInitialCRDTDoc' or 'gutenberg'. + */ + export function mergeCrdtBlocks( - yblocks: Y.Array< YBlock >, - newValue: Block[] | Y.Array< YBlock >, + yblocks: Y.Array< YBlock >, // yblocks represent the blocks in the local Y.Doc + incomingBlocks: Block[], // incomingBlocks represent JSON blocks being synced, either from a peer or from the local editor _origin: string // eslint-disable-line @typescript-eslint/no-unused-vars ): void { // Ensure we are working with serializable block data. - if ( ! serializableBlocksCache.has( newValue ) ) { + if ( ! serializableBlocksCache.has( incomingBlocks ) ) { serializableBlocksCache.set( - newValue, - makeBlocksSerializable( newValue ) + incomingBlocks, + makeBlocksSerializable( incomingBlocks ) ); } - const unfilteredBlocks = serializableBlocksCache.get( newValue ) ?? []; + const allBlocks = serializableBlocksCache.get( incomingBlocks ) ?? []; // Ensure we skip blocks that we don't want to sync at the moment - const blocks = unfilteredBlocks.filter( ( block ) => + const blocksToSync = allBlocks.filter( ( block ) => shouldBlockBeSynced( block ) ); @@ -119,8 +139,10 @@ export function mergeCrdtBlocks( // 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` - - const numOfCommonEntries = math.min( blocks.length ?? 0, yblocks.length ); + const numOfCommonEntries = math.min( + blocksToSync.length ?? 0, + yblocks.length + ); let left = 0; let right = 0; @@ -129,7 +151,7 @@ export function mergeCrdtBlocks( for ( ; left < numOfCommonEntries && - areBlocksEqual( blocks[ left ], yblocks.get( left ) ); + areBlocksEqual( blocksToSync[ left ], yblocks.get( left ) ); left++ ) { /* nop */ @@ -140,7 +162,7 @@ export function mergeCrdtBlocks( ; right < numOfCommonEntries - left && areBlocksEqual( - blocks[ blocks.length - right - 1 ], + blocksToSync[ blocksToSync.length - right - 1 ], yblocks.get( yblocks.length - right - 1 ) ); right++ @@ -149,15 +171,28 @@ export function mergeCrdtBlocks( } const numOfUpdatesNeeded = numOfCommonEntries - left - right; - const numOfInsertionsNeeded = math.max( 0, blocks.length - yblocks.length ); - const numOfDeletionsNeeded = math.max( 0, yblocks.length - blocks.length ); + 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 = blocks[ left ]; + const block = blocksToSync[ left ]; const yblock = yblocks.get( left ); Object.entries( block ).forEach( ( [ k, v ] ) => { if ( ! fun.equalityDeep( block[ k ], yblock.get( k ) ) ) { + if ( k === 'innerBlocks' ) { + // Recursively merge innerBlocks + const yInnerBlocks = yblock.get( k ) as Y.Array< YBlock >; + + mergeCrdtBlocks( yInnerBlocks, v, _origin ); + } + yblock.set( k, v ); } } ); @@ -173,17 +208,17 @@ export function mergeCrdtBlocks( // inserts for ( let i = 0; i < numOfInsertionsNeeded; i++, left++ ) { - yblocks.insert( left, [ - new Y.Map< Block[ keyof Block ] >( - Object.entries( blocks[ left ] ) - ), - ] ); + const newBlock = [ + new Y.Map( Object.entries( blocksToSync[ left ] ) ) as YBlock, + ]; + + yblocks.insert( left, newBlock ); } // remove duplicate clientids const knownClientIds = new Set< string >(); for ( let j = 0; j < yblocks.length; j++ ) { - const yblock: Y.Map< Block[ keyof Block ] > = yblocks.get( j ); + const yblock: YBlock = yblocks.get( j ); let clientId: string = yblock.get( 'clientId' ) as string; diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index c9e74a532e36d5..3ccbd67cafd0ab 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -37,9 +37,7 @@ export function defaultApplyChangesToCRDTDoc( switch ( key ) { case 'blocks': { - let currentBlocks = ymap.get( - 'blocks' - ) as PostChanges[ 'blocks' ]; + let currentBlocks = ymap.get( 'blocks' ) as Y.Array< YBlock >; if ( ! ( currentBlocks instanceof Y.Array ) ) { currentBlocks = setValue< Y.Array< YBlock > >( From f985f4ae2b4c56daf310919486f3fe4ea30f8321 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 27 Aug 2025 13:41:37 -0600 Subject: [PATCH 48/99] Provide bindings for CRDT persistence --- packages/core-data/src/actions.js | 15 +++++++ packages/sync/src/provider.ts | 69 ++++++++++++++++++++++++++----- packages/sync/src/types.ts | 14 +++++-- 3 files changed, 84 insertions(+), 14 deletions(-) diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 3e729a3fbfd7ed..1aa563e64c3890 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -675,6 +675,21 @@ export const saveEntityRecord = ), }; } + if ( + window.__experimentalEnableSync && + entityConfig.syncConfig?.enabled + ) { + // Allow sync provider to create meta for the entity before persisting. + edits.meta = { + ...edits.meta, + ...( await getSyncProvider().createEntityMeta( + entityConfig.syncConfig, + persistedRecord, + edits + ) ), + }; + } + updatedRecord = await __unstableFetch( { path, method: recordId ? 'PUT' : 'POST', diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index 69297d4c5b25e1..b936fa407d014e 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -124,18 +124,14 @@ export class SyncProvider { ydoc, } ); - // Get the initial data to be synced for this record. - const initialCRDTDoc = await this.getInitialCRDTDoc( - syncConfig, - record - ); + // Get the initial document state. + const initialDoc = await this.getInitialCRDTDoc( syncConfig, record ); - // Create the initial document, possible from persisted doc. + // Apply the initial document to the current document as a singular update. Y.transact( ydoc, () => { - // apply remote changes - Y.applyUpdate( ydoc, Y.encodeStateAsUpdate( initialCRDTDoc ) ); + Y.applyUpdate( ydoc, Y.encodeStateAsUpdate( initialDoc ) ); }, 'syncProvider.bootstrap', false @@ -183,9 +179,24 @@ export class SyncProvider { record: ObjectData ): Promise< CRDTDoc > { // IMPORTANT: We use a new Yjs document so that the initial state can be - // applied to the "real" Yjs document as a singular update. + // applied to the "real" Yjs document as a singular update. Therefore, we + // don't need to wrap the changes in a transaction. const initialStateDoc = new Y.Doc( { meta: new Map() } ); + // Load the persisted document from previous sessions. + const persistedDoc = await this.getPersistedCRDTDoc( + syncConfig, + record + ); + + // If it exists, apply it as the base state of the initial document. + if ( persistedDoc ) { + Y.applyUpdate( + initialStateDoc, + Y.encodeStateAsUpdate( persistedDoc ) + ); + } + const initialData = syncConfig.getInitialObjectData( record ); syncConfig.applyChangesToCRDTDoc( initialStateDoc, @@ -196,6 +207,44 @@ export class SyncProvider { return initialStateDoc; } + /* eslint-disable @typescript-eslint/no-unused-vars */ + + /** + * Create meta for the entity, e.g., to persist the CRDT doc against the + * entity. Custom sync providers can override this method to provide their + * implementation. + * + * @param {SyncConfig} _syncConfig Sync configuration for the object type. + * @param {ObjectData} _record Record representing this object type. + * @param {Partial< ObjectData >} _changes Updates to make. + * @return {Promise< Record< string, any > >} Entity meta. + */ + public async createEntityMeta( + _syncConfig: SyncConfig, + _record: ObjectData, + _changes: Partial< ObjectData > + ): Promise< Record< string, any > > { + return Promise.resolve( {} ); + } + + /** + * Get the persisted CRDT document from the object data, e.g., from meta. + * Custom sync providers can override this method to provide their + * implementation. + * + * @param {SyncConfig} _syncConfig Sync configuration for the object type. + * @param {ObjectData} _record Record representing this object type. + * @return {Promise< CRDTDoc | null >} The persisted CRDT document, or null if none exists. + */ + protected async getPersistedCRDTDoc( + _syncConfig: SyncConfig, + _record: ObjectData + ): Promise< CRDTDoc | null > { + return Promise.resolve( null ); + } + + /* eslint-enable @typescript-eslint/no-unused-vars */ + /** * Get the undo manager. * @@ -206,7 +255,7 @@ export class SyncProvider { } /** - * Fetch data from local database or remote source. + * Update CRDT document with changes from the local store. * * @param {ObjectType} objectType Object type to load. * @param {ObjectData} record Record to load. diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 7561c8afe1671a..41643a6d36d94a 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -5,18 +5,24 @@ import type * as Y from 'yjs'; import type { Awareness } from 'y-protocols/awareness'; export type * as Y from 'yjs'; +export type CRDTDoc = Y.Doc; export type EntityID = string; export type ObjectID = string; export type ObjectType = string; -export type ObjectData = object; export type UndoManager = Y.UndoManager; -export type CRDTDoc = Y.Doc; +// Object data represents any entity record, post, term, user, site, etc. There +// are not many expectations that can hold on its shape, but defining some +// optional properties cuts down on the type narrowing. +export interface ObjectData extends Record< string, unknown > { + meta?: Record< string, unknown >; + status?: string; +} -export type ConnectDocResult = { +export interface ConnectDocResult { awareness?: Awareness; destroy: () => void; -}; +} export type ConnectDoc = ( id: ObjectID, From b856db3c7879665fab2b7757b13b9376ee81a137 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 28 Aug 2025 10:29:16 -0600 Subject: [PATCH 49/99] Control and validate CRDT doc version internally --- packages/sync/src/index.ts | 2 +- packages/sync/src/provider.ts | 33 ++++++++++++++++++++++++--------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index a630969d6d49b8..ec0341e9dc44f0 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -12,7 +12,7 @@ import { SyncProvider } from './provider'; export * as Y from 'yjs'; export { connectIndexDb } from './connect-indexdb'; export { createWebRTCConnection } from './create-webrtc-connection'; -export { SyncProvider } from './provider'; +export { CRDT_DOC_VERSION, SyncProvider } from './provider'; export * from './types'; declare global { diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index b936fa407d014e..35e930b8fc5e09 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -23,6 +23,11 @@ interface EntityState { ydoc: CRDTDoc; } +// This version number should be incremented whenever there are breaking changes +// to Yjs doc schema or in how it is interpreted by code in the SyncConfig. This +// allows implementors to invalidate persisted CRDT docs, if any. +export const CRDT_DOC_VERSION = 1; + export class SyncProvider { private connectLocal: ConnectDoc | null; private connectRemote: ConnectDoc | null; @@ -91,7 +96,10 @@ export class SyncProvider { record: ObjectData, handleChanges: ( data: Partial< ObjectData > ) => void ): Promise< void > { - const ydoc = new Y.Doc( { meta: new Map() } ); + const meta = new Map< string, unknown >( [ + [ 'version', CRDT_DOC_VERSION ], + ] ); + const ydoc = new Y.Doc( { meta } ); const objectId = syncConfig.getObjectId( record ); const objectType = syncConfig.objectType; const connections = await this.connect( objectId, objectType, ydoc ); @@ -174,23 +182,28 @@ export class SyncProvider { * @param {SyncConfig} syncConfig Sync configuration for the object type. * @param {ObjectData} record Initial data to apply to the document. */ - protected async getInitialCRDTDoc( + private async getInitialCRDTDoc( syncConfig: SyncConfig, record: ObjectData ): Promise< CRDTDoc > { // IMPORTANT: We use a new Yjs document so that the initial state can be // applied to the "real" Yjs document as a singular update. Therefore, we // don't need to wrap the changes in a transaction. - const initialStateDoc = new Y.Doc( { meta: new Map() } ); + const initialStateDoc = new Y.Doc(); // Load the persisted document from previous sessions. const persistedDoc = await this.getPersistedCRDTDoc( syncConfig, - record + record, + CRDT_DOC_VERSION ); - // If it exists, apply it as the base state of the initial document. - if ( persistedDoc ) { + // If it exists and matches the current version, apply it as the base state + // of the initial document. + if ( + persistedDoc && + CRDT_DOC_VERSION === persistedDoc.meta?.get( 'version' ) + ) { Y.applyUpdate( initialStateDoc, Y.encodeStateAsUpdate( persistedDoc ) @@ -232,13 +245,15 @@ export class SyncProvider { * Custom sync providers can override this method to provide their * implementation. * - * @param {SyncConfig} _syncConfig Sync configuration for the object type. - * @param {ObjectData} _record Record representing this object type. + * @param {SyncConfig} _syncConfig Sync configuration for the object type. + * @param {ObjectData} _record Record representing this object type. + * @param {number} _expectedVersion Expected version of persisted CRDT document. * @return {Promise< CRDTDoc | null >} The persisted CRDT document, or null if none exists. */ protected async getPersistedCRDTDoc( _syncConfig: SyncConfig, - _record: ObjectData + _record: ObjectData, + _expectedVersion: number ): Promise< CRDTDoc | null > { return Promise.resolve( null ); } From 767449150a71c8ecb072196ecb4aeb2ee7362da0 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Thu, 28 Aug 2025 11:03:58 -0600 Subject: [PATCH 50/99] Fix CRDT merge object equality check against yBlockAsJson --- packages/core-data/src/utils/crdt-blocks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index ab10cca89eae7c..033e6c6432394f 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -88,7 +88,7 @@ function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean { }; const res = fun.equalityDeep( Object.assign( {}, gblock, overwrites ), - Object.assign( {}, yblock, overwrites ) + Object.assign( {}, yblockAsJson, overwrites ) ); const inners = gblock.innerBlocks || []; const yinners = yblockAsJson.innerBlocks || []; From a1d1d639f3dd8015ee2c6080f81bb7247c6c2b15 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 28 Aug 2025 11:54:58 -0600 Subject: [PATCH 51/99] Add createNewYBlock for recursive insert --- packages/core-data/src/utils/crdt-blocks.ts | 32 +++++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 033e6c6432394f..40176ef06e3828 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -101,6 +101,34 @@ function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean { ); } +function createNewYBlock( block: Block ): YBlock { + return new Y.Map( + Object.entries( block ).map( ( [ key, value ] ) => { + switch ( key ) { + case 'innerBlocks': { + if ( Array.isArray( value ) ) { + const innerBlocks = new Y.Array(); + + innerBlocks.insert( + 0, + value.map( ( innerBlock: Block ) => + createNewYBlock( innerBlock ) + ) + ); + + return [ key, innerBlocks ]; + } + + return [ key, value ]; + } + + default: + return [ key, value ]; + } + } ) + ); +} + /** * Merge incoming block data into the local Y.Doc. * This function is called to sync local block changes to a shared Y.Doc. @@ -208,9 +236,7 @@ export function mergeCrdtBlocks( // inserts for ( let i = 0; i < numOfInsertionsNeeded; i++, left++ ) { - const newBlock = [ - new Y.Map( Object.entries( blocksToSync[ left ] ) ) as YBlock, - ]; + const newBlock = [ createNewYBlock( blocksToSync[ left ] ) ]; yblocks.insert( left, newBlock ); } From ac4db99e8518fba4d7388a39a89efa09acbe5593 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 28 Aug 2025 11:58:43 -0600 Subject: [PATCH 52/99] Ensure we always recursively merge innerBlocks --- packages/core-data/src/utils/crdt-blocks.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 40176ef06e3828..3bdeeee5b6edcf 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -137,7 +137,6 @@ function createNewYBlock( block: Block ): YBlock { * @param incomingBlocks Gutenberg blocks being synced. * @param _origin The origin of the sync, either 'syncProvider.getInitialCRDTDoc' or 'gutenberg'. */ - export function mergeCrdtBlocks( yblocks: Y.Array< YBlock >, // yblocks represent the blocks in the local Y.Doc incomingBlocks: Block[], // incomingBlocks represent JSON blocks being synced, either from a peer or from the local editor @@ -212,16 +211,21 @@ export function mergeCrdtBlocks( for ( let i = 0; i < numOfUpdatesNeeded; i++, left++ ) { const block = blocksToSync[ left ]; const yblock = yblocks.get( left ); - Object.entries( block ).forEach( ( [ k, v ] ) => { - if ( ! fun.equalityDeep( block[ k ], yblock.get( k ) ) ) { - if ( k === 'innerBlocks' ) { + Object.entries( block ).forEach( ( [ key, value ] ) => { + switch ( key ) { + case 'innerBlocks': { // Recursively merge innerBlocks - const yInnerBlocks = yblock.get( k ) as Y.Array< YBlock >; - - mergeCrdtBlocks( yInnerBlocks, v, _origin ); + const yInnerBlocks = yblock.get( key ) as Y.Array< YBlock >; + mergeCrdtBlocks( yInnerBlocks, value ?? [], _origin ); + break; } - yblock.set( k, v ); + default: + if ( + ! fun.equalityDeep( block[ key ], yblock.get( key ) ) + ) { + yblock.set( key, value ); + } } } ); yblock.forEach( ( _v, k ) => { From 5ad8407f884d7d5939bdb7cf8ae140ba31f5e6dc Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Thu, 28 Aug 2025 12:14:57 -0600 Subject: [PATCH 53/99] In areBlocksEqual(), ensure YBlock type instead of JSON value for comparison --- packages/core-data/src/utils/crdt-blocks.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 3bdeeee5b6edcf..88f1ef87ffed43 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -91,12 +91,12 @@ function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean { Object.assign( {}, yblockAsJson, overwrites ) ); const inners = gblock.innerBlocks || []; - const yinners = yblockAsJson.innerBlocks || []; + const yinners = yblock.get( 'innerBlocks' ) as Y.Array< YBlock >; return ( res && inners.length === yinners.length && inners.every( ( block: Block, i: number ) => - areBlocksEqual( block, yinners[ i ] ) + areBlocksEqual( block, yinners.get( i ) ) ) ); } From fe57ec5c05b995799dfaa87ad141172f4c99f967 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Thu, 28 Aug 2025 12:25:41 -0600 Subject: [PATCH 54/99] Use Y.Map type for attributes in yblocks --- packages/core-data/src/utils/crdt-blocks.ts | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 88f1ef87ffed43..da2897a24c0d9e 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -122,6 +122,19 @@ function createNewYBlock( block: Block ): YBlock { return [ key, value ]; } + case 'attributes': { + const attributes = new Y.Map( + Object.entries( value ).map( + ( [ attributeKey, attributeValue ] ) => { + // Rich-text logic here + return [ attributeKey, attributeValue ]; + } + ) + ); + + return [ key, attributes ]; + } + default: return [ key, value ]; } @@ -220,6 +233,17 @@ export function mergeCrdtBlocks( break; } + case 'attributes': { + const yAttributes = yblock.get( key ) as Y.Map< unknown >; + Object.entries( value ).forEach( + ( [ attributeKey, attributeValue ] ) => { + // Rich-text logic here + yAttributes.set( attributeKey, attributeValue ); + } + ); + break; + } + default: if ( ! fun.equalityDeep( block[ key ], yblock.get( key ) ) From cd9aec3113c61e913409814fe49cfe5024ef215f Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 28 Aug 2025 12:32:26 -0600 Subject: [PATCH 55/99] Bugfix: Don't allow non-array values for innerBlocks --- packages/core-data/src/utils/crdt-blocks.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index da2897a24c0d9e..5c6841d24d9dac 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -106,20 +106,21 @@ function createNewYBlock( block: Block ): YBlock { Object.entries( block ).map( ( [ key, value ] ) => { switch ( key ) { case 'innerBlocks': { - if ( Array.isArray( value ) ) { - const innerBlocks = new Y.Array(); - - innerBlocks.insert( - 0, - value.map( ( innerBlock: Block ) => - createNewYBlock( innerBlock ) - ) - ); + const innerBlocks = new Y.Array(); + // If not an array, set to empty Y.Array. + if ( ! Array.isArray( value ) ) { return [ key, innerBlocks ]; } - return [ key, value ]; + innerBlocks.insert( + 0, + value.map( ( innerBlock: Block ) => + createNewYBlock( innerBlock ) + ) + ); + + return [ key, innerBlocks ]; } case 'attributes': { From daf64b5c3f0436ee30dcce61b3340de86d130547 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Thu, 28 Aug 2025 12:53:39 -0600 Subject: [PATCH 56/99] Cast rich-text types into Y.Text in the ydoc --- packages/core-data/src/utils/crdt-blocks.ts | 76 ++++++++++++++++++++- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 5c6841d24d9dac..1b40ca07c9ed50 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -11,6 +11,9 @@ import * as fun from 'lib0/function'; import { RichTextData } from '@wordpress/rich-text'; import { Y } from '@wordpress/sync'; +// @ts-expect-error - This is a TypeScript file, and @wordpress/blocks doesn't have a tsconfig.json? +import { getBlockTypes } from '@wordpress/blocks'; + interface BlockAttributes { [ key: string ]: unknown; } @@ -127,7 +130,18 @@ function createNewYBlock( block: Block ): YBlock { const attributes = new Y.Map( Object.entries( value ).map( ( [ attributeKey, attributeValue ] ) => { - // Rich-text logic here + const isRichText = isRichTextAttribute( + block.name, + attributeKey + ); + + if ( isRichText ) { + return [ + attributeKey, + new Y.Text( attributeValue as string ), + ]; + } + return [ attributeKey, attributeValue ]; } ) @@ -238,8 +252,19 @@ export function mergeCrdtBlocks( const yAttributes = yblock.get( key ) as Y.Map< unknown >; Object.entries( value ).forEach( ( [ attributeKey, attributeValue ] ) => { - // Rich-text logic here - yAttributes.set( attributeKey, attributeValue ); + const isRichText = isRichTextAttribute( + block.name, + attributeKey + ); + + if ( isRichText ) { + const ytext = new Y.Text( + attributeValue as string + ); + yAttributes.set( attributeKey, ytext ); + } else { + yAttributes.set( attributeKey, attributeValue ); + } } ); break; @@ -310,3 +335,48 @@ function shouldBlockBeSynced( block: Block ): boolean { // Allow all other blocks to be synced. return true; } + +// Cache rich-text attributes for looked-up block types. +const cachedRichTextAttributes = new Map< string, Map< string, true > >(); + +/** + * Given a block name and attribute key, return true if the attribute is rich-text typed. + * + * @param blockName The name of the block, e.g. 'core/paragraph'. + * @param attributeKey The key of the attribute to check, e.g. 'content'. + * @return True if the attribute is rich-text typed, false otherwise. + */ +function isRichTextAttribute( + blockName: string, + attributeKey: string +): boolean { + if ( cachedRichTextAttributes.has( blockName ) ) { + // If we've already cached the rich-text attributes for this block type, + // return the cached value. + return ( + cachedRichTextAttributes.get( blockName )?.has( attributeKey ) ?? + false + ); + } + + const allRegisteredBlockTypes = getBlockTypes(); + const matchingBlockType = allRegisteredBlockTypes.find( + ( blockType ) => blockType.name === blockName + ); + + const isBlockTypeRegistered = matchingBlockType !== undefined; + const richTextAttributeMap = new Map< string, true >(); + + if ( isBlockTypeRegistered ) { + for ( const [ registeredKey, registeredProperties ] of Object.entries( + matchingBlockType.attributes as Record< string, { type: string } > + ) ) { + if ( registeredProperties.type === 'rich-text' ) { + richTextAttributeMap.set( registeredKey, true ); + } + } + } + + cachedRichTextAttributes.set( blockName, richTextAttributeMap ); + return richTextAttributeMap.has( attributeKey ); +} From 038c74e448190adad08756d56eb454f5d9694c5a Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 28 Aug 2025 14:58:04 -0600 Subject: [PATCH 57/99] Improve type safety, DRY up, and slightly more efficient --- packages/core-data/src/utils/crdt-blocks.ts | 148 ++++++++++---------- 1 file changed, 72 insertions(+), 76 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 1b40ca07c9ed50..f4fde8f64e074e 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -18,6 +18,11 @@ interface BlockAttributes { [ key: string ]: unknown; } +interface BlockType { + name: string; + attributes?: Record< string, { type?: string } >; +} + export interface Block { attributes: BlockAttributes; clientId?: string; @@ -104,10 +109,39 @@ function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean { ); } +function createNewYAttributeMap( + blockName: string, + attributes: BlockAttributes +): Y.Map< Y.Text | unknown > { + return new Y.Map( + Object.entries( attributes ).map( + ( [ attributeKey, attributeValue ] ) => { + const isRichText = isRichTextAttribute( + blockName, + attributeKey + ); + + if ( isRichText && 'string' === typeof attributeValue ) { + return [ + attributeKey, + new Y.Text( attributeValue as string ), + ]; + } + + return [ attributeKey, attributeValue ]; + } + ) + ); +} + function createNewYBlock( block: Block ): YBlock { return new Y.Map( Object.entries( block ).map( ( [ key, value ] ) => { switch ( key ) { + case 'attributes': { + return [ key, createNewYAttributeMap( block.name, value ) ]; + } + case 'innerBlocks': { const innerBlocks = new Y.Array(); @@ -126,30 +160,6 @@ function createNewYBlock( block: Block ): YBlock { return [ key, innerBlocks ]; } - case 'attributes': { - const attributes = new Y.Map( - Object.entries( value ).map( - ( [ attributeKey, attributeValue ] ) => { - const isRichText = isRichTextAttribute( - block.name, - attributeKey - ); - - if ( isRichText ) { - return [ - attributeKey, - new Y.Text( attributeValue as string ), - ]; - } - - return [ attributeKey, attributeValue ]; - } - ) - ); - - return [ key, attributes ]; - } - default: return [ key, value ]; } @@ -241,6 +251,18 @@ export function mergeCrdtBlocks( const yblock = yblocks.get( left ); Object.entries( block ).forEach( ( [ key, value ] ) => { switch ( key ) { + case 'attributes': { + if ( + ! fun.equalityDeep( block[ key ], yblock.get( key ) ) + ) { + yblock.set( + key, + createNewYAttributeMap( block.name, value ) + ); + } + break; + } + case 'innerBlocks': { // Recursively merge innerBlocks const yInnerBlocks = yblock.get( key ) as Y.Array< YBlock >; @@ -248,28 +270,6 @@ export function mergeCrdtBlocks( break; } - case 'attributes': { - const yAttributes = yblock.get( key ) as Y.Map< unknown >; - Object.entries( value ).forEach( - ( [ attributeKey, attributeValue ] ) => { - const isRichText = isRichTextAttribute( - block.name, - attributeKey - ); - - if ( isRichText ) { - const ytext = new Y.Text( - attributeValue as string - ); - yAttributes.set( attributeKey, ytext ); - } else { - yAttributes.set( attributeKey, attributeValue ); - } - } - ); - break; - } - default: if ( ! fun.equalityDeep( block[ key ], yblock.get( key ) ) @@ -336,47 +336,43 @@ function shouldBlockBeSynced( block: Block ): boolean { return true; } -// Cache rich-text attributes for looked-up block types. -const cachedRichTextAttributes = new Map< string, Map< string, true > >(); +// Cache rich-text attributes for all block types. +let cachedRichTextAttributes: Map< string, Map< string, true > >; /** * Given a block name and attribute key, return true if the attribute is rich-text typed. * - * @param blockName The name of the block, e.g. 'core/paragraph'. - * @param attributeKey The key of the attribute to check, e.g. 'content'. + * @param blockName The name of the block, e.g. 'core/paragraph'. + * @param attributeName The name of the attribute to check, e.g. 'content'. * @return True if the attribute is rich-text typed, false otherwise. */ function isRichTextAttribute( blockName: string, - attributeKey: string + attributeName: string ): boolean { - if ( cachedRichTextAttributes.has( blockName ) ) { - // If we've already cached the rich-text attributes for this block type, - // return the cached value. - return ( - cachedRichTextAttributes.get( blockName )?.has( attributeKey ) ?? - false - ); - } - - const allRegisteredBlockTypes = getBlockTypes(); - const matchingBlockType = allRegisteredBlockTypes.find( - ( blockType ) => blockType.name === blockName - ); - - const isBlockTypeRegistered = matchingBlockType !== undefined; - const richTextAttributeMap = new Map< string, true >(); - - if ( isBlockTypeRegistered ) { - for ( const [ registeredKey, registeredProperties ] of Object.entries( - matchingBlockType.attributes as Record< string, { type: string } > - ) ) { - if ( registeredProperties.type === 'rich-text' ) { - richTextAttributeMap.set( registeredKey, true ); + if ( ! cachedRichTextAttributes ) { + // Parse the attributes for all blocks once. + cachedRichTextAttributes = new Map< string, Map< string, true > >(); + + for ( const blockType of getBlockTypes() as BlockType[] ) { + const richTextAttributeMap = new Map< string, true >(); + + for ( const [ name, definition ] of Object.entries( + blockType.attributes ?? {} + ) ) { + if ( 'rich-text' === definition.type ) { + richTextAttributeMap.set( name, true ); + } } + + cachedRichTextAttributes.set( + blockType.name, + richTextAttributeMap + ); } } - cachedRichTextAttributes.set( blockName, richTextAttributeMap ); - return richTextAttributeMap.has( attributeKey ); + return ( + cachedRichTextAttributes.get( blockName )?.has( attributeName ) ?? false + ); } From 530fcee98b2f02e9d66afbdf56061e660f03ea66 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 28 Aug 2025 15:30:16 -0600 Subject: [PATCH 58/99] Ensure attributes are deleted when removed --- packages/core-data/src/utils/crdt-blocks.ts | 86 +++++++++++++++------ 1 file changed, 62 insertions(+), 24 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index f4fde8f64e074e..00fc83ba0f8819 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -38,11 +38,13 @@ export type YBlock = Y.Map< /* validationIssues? is an array of strings. */ | string[] /* attributes is a Y.Map< unknown >. */ - | Y.Map< unknown > + | YBlockAttributes /* innerBlocks is a Y.Array< YBlock >. */ | Y.Array< YBlock > >; +export type YBlockAttributes = Y.Map< Y.Text | unknown >; + // The Y.Map type is not easy to work with. The generic type it accepts represents // the possible values of the map, which are varied in our case. This type is // accurate, but will require aggressive type narrowing when the map values are @@ -112,28 +114,37 @@ function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean { function createNewYAttributeMap( blockName: string, attributes: BlockAttributes -): Y.Map< Y.Text | unknown > { +): YBlockAttributes { return new Y.Map( Object.entries( attributes ).map( - ( [ attributeKey, attributeValue ] ) => { - const isRichText = isRichTextAttribute( - blockName, - attributeKey - ); - - if ( isRichText && 'string' === typeof attributeValue ) { - return [ - attributeKey, - new Y.Text( attributeValue as string ), - ]; - } - - return [ attributeKey, attributeValue ]; + ( [ attributeName, attributeValue ] ) => { + return [ + attributeName, + createNewYAttributeValue( + blockName, + attributeName, + attributeValue + ), + ]; } ) ); } +function createNewYAttributeValue( + blockName: string, + attributeName: string, + attributeValue: unknown +): Y.Text | unknown { + const isRichText = isRichTextAttribute( blockName, attributeName ); + + if ( isRichText && 'string' === typeof attributeValue ) { + return new Y.Text( attributeValue ); + } + + return attributeValue; +} + function createNewYBlock( block: Block ): YBlock { return new Y.Map( Object.entries( block ).map( ( [ key, value ] ) => { @@ -252,14 +263,41 @@ export function mergeCrdtBlocks( Object.entries( block ).forEach( ( [ key, value ] ) => { switch ( key ) { case 'attributes': { - if ( - ! fun.equalityDeep( block[ key ], yblock.get( key ) ) - ) { - yblock.set( - key, - createNewYAttributeMap( block.name, value ) - ); - } + const currentAttributes = + ( yblock.get( key ) as YBlockAttributes ) ?? + createNewYAttributeMap( block.name, {} ); + + Object.entries( value ).forEach( + ( [ attributeName, attributeValue ] ) => { + if ( + fun.equalityDeep( + currentAttributes.get( attributeName ), + attributeValue + ) + ) { + return; + } + + currentAttributes.set( + attributeName, + createNewYAttributeValue( + block.name, + attributeName, + attributeValue + ) + ); + } + ); + + // Delete any attributes that are no longer present. + currentAttributes.forEach( + ( _attrValue: unknown, attrName: string ) => { + if ( ! value.hasOwnProperty( attrName ) ) { + currentAttributes.delete( attrName ); + } + } + ); + break; } From a2518656e75bc2e76d9fc7173f8e89e825e36d9d Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 28 Aug 2025 15:58:40 -0600 Subject: [PATCH 59/99] Bugfix: Make sure new attributes are set. --- packages/core-data/src/utils/crdt-blocks.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 00fc83ba0f8819..aaf8cb6ddf4711 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -263,15 +263,24 @@ export function mergeCrdtBlocks( Object.entries( block ).forEach( ( [ key, value ] ) => { switch ( key ) { case 'attributes': { - const currentAttributes = - ( yblock.get( key ) as YBlockAttributes ) ?? - createNewYAttributeMap( block.name, {} ); + const currentAttributes = yblock.get( + key + ) as YBlockAttributes; + + // 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 ] ) => { if ( fun.equalityDeep( - currentAttributes.get( attributeName ), + currentAttributes?.get( attributeName ), attributeValue ) ) { From 1379c8e5563b64bde3f5f70c37517409ccbce42a Mon Sep 17 00:00:00 2001 From: chriszarate Date: Fri, 29 Aug 2025 11:32:26 -0600 Subject: [PATCH 60/99] Disable autosave warnings when collaborative editing is enabled --- .../src/components/local-autosave-monitor/index.js | 13 +++++++++++++ packages/editor/src/components/provider/index.js | 12 +++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/components/local-autosave-monitor/index.js b/packages/editor/src/components/local-autosave-monitor/index.js index ad4e40d15d5c58..4733612e58a31c 100644 --- a/packages/editor/src/components/local-autosave-monitor/index.js +++ b/packages/editor/src/components/local-autosave-monitor/index.js @@ -101,6 +101,19 @@ function useAutosaveNotice() { return; } + // Disable the warning notice if collaborative editing is enabled. + // + // @TODO + // + // In the future, we may wish to implement a more sophisticated check -- for + // example, if collaborative editing is enabled but the provider is + // disconnected, we may want to provide the user with options. For now, + // however, since we effectively lock the editor when the provider is not + // connected, this simplistic approach will work. + if ( window.__experimentalEnableSync ) { + return; + } + const id = 'wpEditorAutosaveRestore'; createWarningNotice( diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index f934aa9fde90fa..e81378b7b523e4 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -293,7 +293,17 @@ export const ExperimentalEditorProvider = withRegistryProvider( updatePostLock( settings.postLock ); setupEditor( post, initialEdits, settings.template ); - if ( settings.autosave ) { + + // Disable the warning notice if collaborative editing is enabled. + // + // @TODO + // + // In the future, we may wish to implement a more sophisticated check -- for + // example, if collaborative editing is enabled but the provider is + // disconnected, we may want to provide the user with options. For now, + // however, since we effectively lock the editor when the provider is not + // connected, this simplistic approach will work. + if ( settings.autosave && ! window.__experimentalEnableSync ) { createWarningNotice( __( 'There is an autosave of this post that is more recent than the version below.' From 13047e9fee704e1eb06be5a05d1b2b6e6d55fa3e Mon Sep 17 00:00:00 2001 From: chriszarate Date: Fri, 29 Aug 2025 13:52:36 -0600 Subject: [PATCH 61/99] Only use current record if persisted doc does not exist (or was invalidated) --- packages/sync/src/provider.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index 35e930b8fc5e09..d8cb712a9ec1dc 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -204,12 +204,10 @@ export class SyncProvider { persistedDoc && CRDT_DOC_VERSION === persistedDoc.meta?.get( 'version' ) ) { - Y.applyUpdate( - initialStateDoc, - Y.encodeStateAsUpdate( persistedDoc ) - ); + return persistedDoc; } + // Otherwise, use the current record. const initialData = syncConfig.getInitialObjectData( record ); syncConfig.applyChangesToCRDTDoc( initialStateDoc, From 261222b8d326460c88dd4ba79a94f363cb457feb Mon Sep 17 00:00:00 2001 From: chriszarate Date: Fri, 29 Aug 2025 17:06:05 -0600 Subject: [PATCH 62/99] Improve SyncConfig function docblocks --- packages/core-data/src/entities.js | 35 +++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 6f2d0d30b18e81..4dea949f01782a 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -295,16 +295,24 @@ async function loadPostTypeEntities() { __unstablePrePersist: isTemplate ? undefined : prePersistPostType, __unstable_rest_base: postType.rest_base, syncConfig: { + /** + * Is syncing enabled for this entity? + */ enabled: Boolean( postType.supports?.[ 'collaborative-editing' ] && postType.supports?.editor ), + /** - * @param {Y.Doc} ydoc - * @param {Object} changes - * @param {string} origin + * Apply changes from the local editor and to the local CRDT document so + * that those changes can be synced to other peers (via the provider). + * + * @param {import('@wordpress/sync').CRDTDoc} crdtDoc + * @param {import('@wordpress/sync').ObjectData} changes + * @param {string} origin + * @return {void} */ - applyChangesToCRDTDoc: ( ydoc, changes, origin ) => { + applyChangesToCRDTDoc: ( crdtDoc, changes, origin ) => { const filteredChanges = Object.fromEntries( Object.entries( changes ).filter( ( [ key, value ] ) => @@ -314,12 +322,21 @@ async function loadPostTypeEntities() { ); defaultApplyChangesToCRDTDoc( - ydoc, + crdtDoc, filteredChanges, origin ); }, + + /** + * Transform a CRDT document into a partial record that can be used to + * update the local editor state. + * + * @param {import('@wordpress/sync').CRDTDoc} crdtDoc + * @return {import('@wordpress/sync').ObjectData} Object data + */ fromCRDTDoc: defaultFromCRDTDoc, + /** * This initial object data represents the data that will be synced via * the CRDT document, which may differ from the entity record. There may @@ -341,7 +358,15 @@ async function loadPostTypeEntities() { ) ); }, + + /** + * Get the immutable identifier for an entity record. + * + * @param {import('@wordpress/sync').ObjectData} record + * @return {import('@wordpress/sync').ObjectID} The entity's ID + */ getObjectId: ( { id } ) => id, + objectType: `postType/${ postType.slug }`, supportsAwareness: true, supportsUndo: true, From f42a4053bedf8f6556daced9a8002e2816da82e4 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Sat, 30 Aug 2025 10:14:17 -0600 Subject: [PATCH 63/99] Add comparison logic to sync only known changes - Add `status` and other fields to synced properties - Rename `fromCRDTDoc` to `getChangesFromCRDTDoc` - Pass in `getEditedRecord` to `SyncProvider#bootstrap` - Provide comparison logic in `getChangesFromCRDTDoc` so that we only dispatch known changes --- packages/core-data/src/actions.js | 4 +- packages/core-data/src/entities.js | 61 +++++----- packages/core-data/src/resolvers.js | 34 ++++-- packages/core-data/src/utils/crdt.ts | 176 ++++++++++++++++++++++++--- packages/sync/src/config.ts | 4 + packages/sync/src/index.ts | 3 +- packages/sync/src/provider.ts | 94 ++++++++++---- packages/sync/src/types.ts | 14 ++- packages/sync/src/utils.ts | 20 +++ 9 files changed, 315 insertions(+), 95 deletions(-) create mode 100644 packages/sync/src/config.ts create mode 100644 packages/sync/src/utils.ts diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 1aa563e64c3890..2377572f0530a1 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -400,9 +400,7 @@ export const editEntityRecord = }; if ( window.__experimentalEnableSync && entityConfig.syncConfig ) { if ( globalThis.IS_GUTENBERG_PLUGIN ) { - // @todo this always updates the Yjs doc, which is undesirable, probably we can read the yjs - // content from the comment tag here - getSyncProvider().update( + getSyncProvider().updateCRDTDoc( entityConfig.syncConfig.objectType, record, edit.edits, diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 4dea949f01782a..05208b984771f6 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -13,19 +13,14 @@ import { parse } from '@wordpress/blocks'; /** * Internal dependencies */ -import { defaultApplyChangesToCRDTDoc } from './utils/crdt'; +import { + applyPostChangesToCRDTDoc, + getPostChangesFromCRDTDoc, +} from './utils/crdt'; export const DEFAULT_ENTITY_KEY = 'id'; const POST_RAW_ATTRIBUTES = [ 'title', 'excerpt', 'content' ]; -/** - * @param {Y.Doc} ydoc - * @return {import('@wordpress/sync').ObjectData} The JSON representation of the document. - */ -const defaultFromCRDTDoc = ( ydoc ) => { - return ydoc.getMap( 'document' ).toJSON(); -}; - export const rootEntitiesConfig = [ { label: __( 'Base' ), @@ -254,15 +249,18 @@ export const prePersistPostType = ( persistedRecord, edits ) => { */ async function loadPostTypeEntities() { const syncedProperties = new Set( [ + 'author', 'blocks', + 'comment_status', + 'date', + 'excerpt', 'featured_media', 'format', - 'generated_slug', - 'password', - 'slug', - 'sticky', + 'ping_status', + 'status', 'tags', 'template', + 'slug', 'title', ] ); @@ -304,26 +302,21 @@ async function loadPostTypeEntities() { ), /** - * Apply changes from the local editor and to the local CRDT document so + * Apply changes from the local editor to the local CRDT document so * that those changes can be synced to other peers (via the provider). * - * @param {import('@wordpress/sync').CRDTDoc} crdtDoc - * @param {import('@wordpress/sync').ObjectData} changes - * @param {string} origin + * @param {import('@wordpress/sync').CRDTDoc} crdtDoc + * @param {Partial< import('@wordpress/sync').ObjectData >} changes + * @param {import('@wordpress/sync').ObjectData} record + * @param {string} origin * @return {void} */ - applyChangesToCRDTDoc: ( crdtDoc, changes, origin ) => { - const filteredChanges = Object.fromEntries( - Object.entries( changes ).filter( - ( [ key, value ] ) => - syncedProperties.has( key ) && - 'function' !== typeof value // cannot serialize function values - ) - ); - - defaultApplyChangesToCRDTDoc( + applyChangesToCRDTDoc: ( crdtDoc, changes, record, origin ) => { + applyPostChangesToCRDTDoc( crdtDoc, - filteredChanges, + changes, + record, + syncedProperties, origin ); }, @@ -332,10 +325,16 @@ async function loadPostTypeEntities() { * Transform a CRDT document into a partial record that can be used to * update the local editor state. * - * @param {import('@wordpress/sync').CRDTDoc} crdtDoc - * @return {import('@wordpress/sync').ObjectData} Object data + * @param {import('@wordpress/sync').CRDTDoc} crdtDoc + * @param {import('@wordpress/sync').ObjectData} record + * @return {Partial< import('@wordpress/sync').ObjectData >} Changes to record */ - fromCRDTDoc: defaultFromCRDTDoc, + getChangesFromCRDTDoc: ( crdtDoc, record ) => + getPostChangesFromCRDTDoc( + crdtDoc, + record, + syncedProperties + ), /** * This initial object data represents the data that will be synced via diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 21e677294c2990..23f09c74a2154b 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -153,21 +153,31 @@ export const getEntityRecord = ! query ) { if ( globalThis.IS_GUTENBERG_PLUGIN ) { - // Loads the persisted document. await getSyncProvider().bootstrap( + // Bootstrap syncing for the entity. entityConfig.syncConfig, record, - ( edits ) => { - dispatch( { - type: 'EDIT_ENTITY_RECORD', - kind, - name, - recordId: key, - edits, - meta: { - undo: undefined, - }, - } ); + { + // Handle edits sourced from the sync provider. + editRecord: ( edits ) => { + dispatch( { + type: 'EDIT_ENTITY_RECORD', + kind, + name, + recordId: key, + edits, + meta: { + undo: undefined, + }, + } ); + }, + // Get the current entity record. + getEditedRecord: async () => + await resolveSelect.getEditedEntityRecord( + kind, + name, + key + ), } ); } diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index 3ccbd67cafd0ab..1f7198f7a583fe 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -6,28 +6,45 @@ import * as fun from 'lib0/function'; /** * WordPress dependencies */ -import { type CRDTDoc, Y } from '@wordpress/sync'; +import { type CRDTDoc, type ObjectData, Y } from '@wordpress/sync'; /** * Internal dependencies */ import { mergeCrdtBlocks, type Block, type YBlock } from './crdt-blocks'; -type PrimitiveValue = string | number | boolean | null | undefined; +type MaybeRawValue = string | { raw: string }; interface PostChanges { - blocks?: Y.Array< YBlock > | Block[]; - title?: string | { raw: string }; + blocks?: Block[]; + excerpt?: MaybeRawValue; + status?: string; + title?: MaybeRawValue; } -export function defaultApplyChangesToCRDTDoc( +// Key used to store the document map in the Y.Doc. +const DOCUMENT_MAP_KEY = 'document'; + +export function applyPostChangesToCRDTDoc( ydoc: CRDTDoc, changes: PostChanges, + record: ObjectData, + syncedProperties: Set< string >, origin: string ): void { - const ymap = ydoc.getMap( 'document' ); + const ymap = ydoc.getMap( DOCUMENT_MAP_KEY ); Object.entries( changes ).forEach( ( [ key, newValue ] ) => { + if ( ! syncedProperties.has( key ) ) { + ymap.delete( key ); + return; + } + + // Cannot serialize function values, so cannot sync them. + if ( 'function' === typeof newValue ) { + return; + } + // Return .get() result so that caller can operate on the data type // without having to call .get() themselves. function setValue< T = unknown >( updatedValue: T ): T { @@ -45,28 +62,62 @@ export function defaultApplyChangesToCRDTDoc( ); // Initialize } - // Block[] from local changes or Y.Array< Y.Map > from peer. + // Block[] from local changes. const newBlocks = newValue ?? []; - // Merge blocks does not need `setValue` because it has been - // called above and the result can be operated on directly. + // Merge blocks does not need `setValue` because it is operating on a + // Yjs type that is already in the Y.Doc. mergeCrdtBlocks( currentBlocks, newBlocks, origin ); break; } + case 'excerpt': { + const currentValue = ymap.get( 'excerpt' ) as + | string + | undefined; + const rawNewValue = getRawValue( newValue ); + + mergeValue( currentValue, rawNewValue, setValue ); + break; + } + + case 'slug': { + // Do not sync an empty slug. This indicates that the post is using + // the default auto-generated slug. + if ( ! newValue ) { + break; + } + + const currentValue = ymap.get( 'slug' ) as string; + mergeValue( currentValue, newValue, setValue ); + break; + } + + case 'status': { + const currentValue = ymap.get( 'status' ) as string | undefined; + let newStatus = newValue; + + // Undefined status indicates that we want to reset to the current + // persisted value. + if ( undefined === newStatus ) { + newStatus = record.status; + } + + mergeValue( currentValue, newStatus, setValue ); + break; + } + case 'title': { - const currentValue = ymap.get( - 'title' - ) as PostChanges[ 'title' ]; + const currentValue = ymap.get( 'title' ) as string | undefined; // Copy logic from prePersistPostType to ensure that the "Auto // Draft" template title is not synced. - let rawNewValue = newValue?.raw ?? newValue; + let rawNewValue = getRawValue( newValue ); if ( ! currentValue && 'Auto Draft' === rawNewValue ) { rawNewValue = ''; } - mergePrimitiveValue( currentValue, rawNewValue, setValue ); + mergeValue( currentValue, rawNewValue, setValue ); break; } @@ -74,18 +125,109 @@ export function defaultApplyChangesToCRDTDoc( default: { const currentValue = ymap.get( key ); - mergePrimitiveValue( currentValue, newValue, setValue ); + mergeValue( currentValue, newValue, setValue ); } } } ); } -export function mergePrimitiveValue< ValueType extends PrimitiveValue >( +/** + * 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 + * to dispatch. + * + * @param {CRDTDoc} ydoc + * @param {ObjectData} record + * @param {Set< string >} syncedProperties + */ +export function getPostChangesFromCRDTDoc( + ydoc: CRDTDoc, + record: ObjectData, + syncedProperties: Set< string > +): Partial< PostChanges > { + const ymap = ydoc.getMap( DOCUMENT_MAP_KEY ); + + return Object.fromEntries( + Object.entries( ymap.toJSON() ).filter( ( [ key, newValue ] ) => { + if ( ! syncedProperties.has( key ) ) { + return false; + } + + const currentValue = record[ key ]; + + switch ( key ) { + case 'blocks': { + // We don't need to add special equality checks for `blocks` here + // since that is done by the store for us! + return true; + } + + case 'date': { + // Do not sync an empty date if our current value is a "floating" date. + // Borrowing logic from the isEditedPostDateFloating selector. + const currentDateIsFloating = + [ 'draft', 'auto-draft', 'pending' ].includes( + ymap.get( 'status' ) as string + ) && + ( null === currentValue || + record.modified === currentValue ); + + if ( ! newValue && currentDateIsFloating ) { + return false; + } + + return haveValuesChanged( currentValue, newValue ); + } + + case 'status': { + // Do not sync an invalid status. + if ( 'auto-draft' === newValue ) { + return false; + } + + return haveValuesChanged( currentValue, newValue ); + } + + case 'excerpt': + case 'title': { + return haveValuesChanged( + getRawValue( currentValue as MaybeRawValue ), + newValue + ); + } + + // Add support for additional data types here. + + default: { + return haveValuesChanged( currentValue, newValue ); + } + } + } ) + ); +} + +function getRawValue( value?: MaybeRawValue ): string | undefined { + // Value may be a string property or a nested object with a `raw` property. + if ( 'string' === typeof value ) { + return value; + } + + return value?.raw; +} + +function haveValuesChanged< ValueType = any >( + currentValue: ValueType, + newValue: ValueType +): boolean { + return ! fun.equalityDeep( currentValue, newValue ); +} + +function mergeValue< ValueType = any >( currentValue: ValueType, newValue: ValueType, setValue: ( value: ValueType ) => ValueType ): void { - if ( ! fun.equalityDeep( currentValue, newValue ) ) { + if ( haveValuesChanged< ValueType >( currentValue, newValue ) ) { setValue( newValue ); } } diff --git a/packages/sync/src/config.ts b/packages/sync/src/config.ts new file mode 100644 index 00000000000000..2290c0756263fb --- /dev/null +++ b/packages/sync/src/config.ts @@ -0,0 +1,4 @@ +// This version number should be incremented whenever there are breaking changes +// to Yjs doc schema or in how it is interpreted by code in the SyncConfig. This +// allows implementors to invalidate persisted CRDT docs, if any. +export const CRDT_DOC_VERSION = 1; diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index ec0341e9dc44f0..f1e715d74f8fa4 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -10,9 +10,10 @@ import { createWebRTCConnection } from './create-webrtc-connection'; import { SyncProvider } from './provider'; export * as Y from 'yjs'; +export { CRDT_DOC_VERSION } from './config'; export { connectIndexDb } from './connect-indexdb'; export { createWebRTCConnection } from './create-webrtc-connection'; -export { CRDT_DOC_VERSION, SyncProvider } from './provider'; +export { SyncProvider } from './provider'; export * from './types'; declare global { diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index d8cb712a9ec1dc..b118ef1a3d19ca 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -6,7 +6,7 @@ import * as Y from 'yjs'; /** * Internal dependencies */ -import { UndoManager } from './undo-manager'; +import { CRDT_DOC_VERSION } from './config'; import type { ConnectDoc, ConnectDocResult, @@ -16,18 +16,16 @@ import type { ObjectData, ObjectType, SyncConfig, + RecordHandlers, } from './types'; +import { UndoManager } from './undo-manager'; +import { createYjsDoc } from './utils'; interface EntityState { destroy: () => void; ydoc: CRDTDoc; } -// This version number should be incremented whenever there are breaking changes -// to Yjs doc schema or in how it is interpreted by code in the SyncConfig. This -// allows implementors to invalidate persisted CRDT docs, if any. -export const CRDT_DOC_VERSION = 1; - export class SyncProvider { private connectLocal: ConnectDoc | null; private connectRemote: ConnectDoc | null; @@ -85,23 +83,20 @@ export class SyncProvider { } /** - * Fetch data from local database or remote source. + * Bootstrap an entity for syncing and manage its lifecycle. * - * @param {SyncConfig} syncConfig Sync configuration for the object type. - * @param {ObjectData} record Record representing this object type. - * @param {Function} handleChanges Callback to call when data changes. + * @param {SyncConfig} syncConfig Sync configuration for the object type. + * @param {ObjectData} record Record representing this object type. + * @param {RecordHandlers} handlers Handlers for updating and fetching the record. */ public async bootstrap( syncConfig: SyncConfig, record: ObjectData, - handleChanges: ( data: Partial< ObjectData > ) => void + handlers: RecordHandlers ): Promise< void > { - const meta = new Map< string, unknown >( [ - [ 'version', CRDT_DOC_VERSION ], - ] ); - const ydoc = new Y.Doc( { meta } ); const objectId = syncConfig.getObjectId( record ); const objectType = syncConfig.objectType; + const ydoc = createYjsDoc( objectType ); const connections = await this.connect( objectId, objectType, ydoc ); const entityId = this.getEntityId( objectType, objectId ); @@ -114,20 +109,17 @@ export class SyncProvider { const onUpdate = ( _update: Uint8Array, origin: string ): void => { if ( origin !== 'gutenberg' ) { - const data = syncConfig.fromCRDTDoc( ydoc ); - handleChanges( data ); + void this.updateEntityRecord( syncConfig, handlers ); } }; - ydoc.on( 'update', onUpdate ); - if ( syncConfig.supportsUndo ) { this.undoManager = new UndoManager( ydoc ); } this.configs.set( objectType, syncConfig ); this.connections.set( entityId, connections ); - this.entityStates.set( entityId, { + this.setEntityState( objectType, objectId, { destroy: onDestroy, ydoc, } ); @@ -135,6 +127,8 @@ export class SyncProvider { // Get the initial document state. const initialDoc = await this.getInitialCRDTDoc( syncConfig, record ); + ydoc.on( 'update', onUpdate ); + // Apply the initial document to the current document as a singular update. Y.transact( ydoc, @@ -175,6 +169,22 @@ export class SyncProvider { ); } + /** + * Set the entity state for the given object type and object ID. + * + * @param {ObjectType} objectType Object type. + * @param {ObjectID} objectId Object ID. + * @param {Partial< EntityState >} state Partial entity state to set. + */ + private setEntityState( + objectType: ObjectType, + objectId: ObjectID, + state: EntityState + ): void { + const entityId = this.getEntityId( objectType, objectId ); + this.entityStates.set( entityId, state ); + } + /** * Get the CRDTDoc that represents the initial state of the object data. Custom * sync providers can override this method to provide a custom initial state. @@ -186,11 +196,6 @@ export class SyncProvider { syncConfig: SyncConfig, record: ObjectData ): Promise< CRDTDoc > { - // IMPORTANT: We use a new Yjs document so that the initial state can be - // applied to the "real" Yjs document as a singular update. Therefore, we - // don't need to wrap the changes in a transaction. - const initialStateDoc = new Y.Doc(); - // Load the persisted document from previous sessions. const persistedDoc = await this.getPersistedCRDTDoc( syncConfig, @@ -209,9 +214,16 @@ export class SyncProvider { // Otherwise, use the current record. const initialData = syncConfig.getInitialObjectData( record ); + + // IMPORTANT: We use a new Yjs document so that the initial state can be + // applied to the "real" Yjs document as a singular update. Therefore, we + // don't need to wrap the changes in a transaction. + const initialStateDoc = createYjsDoc( syncConfig.objectType ); + syncConfig.applyChangesToCRDTDoc( initialStateDoc, initialData, + record, 'syncProvider.getInitialCRDTDoc' ); @@ -275,7 +287,7 @@ export class SyncProvider { * @param {Partial< ObjectData >} changes Updates to make. * @param {string} origin The source of change. */ - public update( + public updateCRDTDoc( objectType: ObjectType, record: ObjectData, changes: Partial< ObjectData >, @@ -291,10 +303,38 @@ export class SyncProvider { const ydoc = this.getEntityState( objectType, objectId )?.ydoc; ydoc?.transact( () => { - syncConfig.applyChangesToCRDTDoc( ydoc, changes, origin ); + syncConfig.applyChangesToCRDTDoc( ydoc, changes, record, origin ); }, origin ); } + private async updateEntityRecord( + syncConfig: SyncConfig, + handlers: RecordHandlers + ): Promise< void > { + const currentRecord = await handlers.getEditedRecord(); + + const objectId = syncConfig.getObjectId( currentRecord ); + const objectType = syncConfig.objectType; + + const entityState = this.getEntityState( objectType, objectId ); + + if ( ! entityState ) { + return; + } + + const { ydoc } = entityState; + + // Determine which synced properties have actually changed by comparing + // them against the current entity record. + const changes = syncConfig.getChangesFromCRDTDoc( ydoc, currentRecord ); + + // This is a good spot to debug to see which changes are being synced. Note + // that `blocks` will always appear in the changes, but will only result + // in an update to the store if the blocks have changed. + + handlers.editRecord( changes ); + } + /** * Stop updating a document and discard it. * diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 41643a6d36d94a..ff050afabea300 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -30,16 +30,22 @@ export type ConnectDoc = ( ydoc: Y.Doc ) => Promise< ConnectDocResult >; -export type SyncConfig = { +export interface RecordHandlers { + editRecord: ( data: Partial< ObjectData > ) => void; + getEditedRecord: () => Promise< ObjectData >; +} + +export interface SyncConfig { applyChangesToCRDTDoc: ( ydoc: Y.Doc, - data: Partial< ObjectData >, + changes: Partial< ObjectData >, + record: ObjectData, origin: string ) => void; - fromCRDTDoc: ( ydoc: Y.Doc ) => ObjectData; + getChangesFromCRDTDoc: ( ydoc: Y.Doc, record: ObjectData ) => ObjectData; getInitialObjectData: ( record: ObjectData ) => ObjectData; getObjectId: ( data: ObjectData ) => ObjectID; objectType: ObjectType; supportsAwareness?: boolean; supportsUndo?: boolean; -}; +} diff --git a/packages/sync/src/utils.ts b/packages/sync/src/utils.ts new file mode 100644 index 00000000000000..5c50fea8455fef --- /dev/null +++ b/packages/sync/src/utils.ts @@ -0,0 +1,20 @@ +/** + * External dependencies + */ +import * as Y from 'yjs'; + +/** + * Internal dependencies + */ +import { CRDT_DOC_VERSION } from './config'; +import { type ObjectType } from './types'; + +export function createYjsDoc( objectType: ObjectType ): Y.Doc { + // Meta is not synced and does not get persisted with the document. + const meta = new Map< string, unknown >( [ + [ 'objectType', objectType ], + [ 'version', CRDT_DOC_VERSION ], + ] ); + + return new Y.Doc( { meta } ); +} From 8cd02568e8912fd043b40251f29305943f5dd125 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 3 Sep 2025 16:11:55 -0600 Subject: [PATCH 64/99] Add a state map and sync last persisted date --- packages/core-data/src/actions.js | 9 +++++++++ packages/core-data/src/resolvers.js | 11 +++++++++++ packages/sync/src/provider.ts | 29 ++++++++++++++++++++++++++++- packages/sync/src/types.ts | 1 + 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 2377572f0530a1..6720df45802891 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -701,6 +701,15 @@ export const saveEntityRecord = true, edits ); + if ( + window.__experimentalEnableSync && + entityConfig.syncConfig?.enabled + ) { + getSyncProvider().markEntityAsPersisted( + entityConfig.syncConfig, + persistedRecord + ); + } } } catch ( _error ) { hasError = true; diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 23f09c74a2154b..c76f1b82b25c49 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -178,6 +178,17 @@ export const getEntityRecord = name, key ), + // Refetch the persisted entity record. + refetchPersistedRecord: () => { + void ( async () => { + dispatch.receiveEntityRecords( + kind, + name, + await apiFetch( { path, parse: true } ), + query + ); + } )(); + }, } ); } diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index b118ef1a3d19ca..4015a909edf5be 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -23,9 +23,13 @@ import { createYjsDoc } from './utils'; interface EntityState { destroy: () => void; + lastPersistedAt: number; ydoc: CRDTDoc; } +const CRDT_STATE_MAP_KEY = 'state'; +const CRDT_STATE_PERSISTED_AT_KEY = 'persistedAt'; + export class SyncProvider { private connectLocal: ConnectDoc | null; private connectRemote: ConnectDoc | null; @@ -121,6 +125,7 @@ export class SyncProvider { this.connections.set( entityId, connections ); this.setEntityState( objectType, objectId, { destroy: onDestroy, + lastPersistedAt: Date.now(), ydoc, } ); @@ -279,6 +284,17 @@ export class SyncProvider { return this.undoManager; } + public markEntityAsPersisted( + syncConfig: SyncConfig, + record: ObjectData + ): void { + const objectId = syncConfig.getObjectId( record ); + const objectType = syncConfig.objectType; + const ydoc = this.getEntityState( objectType, objectId )?.ydoc; + + ydoc?.getMap( 'state' ).set( CRDT_STATE_PERSISTED_AT_KEY, Date.now() ); + } + /** * Update CRDT document with changes from the local store. * @@ -322,7 +338,7 @@ export class SyncProvider { return; } - const { ydoc } = entityState; + const { lastPersistedAt, ydoc } = entityState; // Determine which synced properties have actually changed by comparing // them against the current entity record. @@ -333,6 +349,17 @@ export class SyncProvider { // in an update to the store if the blocks have changed. handlers.editRecord( changes ); + + // Determine if we should refetch the persisted entity record from the + // REST API because another client has persisted changes. + const ystateMap = ydoc.getMap( CRDT_STATE_MAP_KEY ); + const persistedAt = + ( ystateMap.get( CRDT_STATE_PERSISTED_AT_KEY ) as number ) ?? 0; + if ( persistedAt > lastPersistedAt ) { + entityState.lastPersistedAt = persistedAt; + this.setEntityState( objectType, objectId, entityState ); + void handlers.refetchPersistedRecord(); + } } /** diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index ff050afabea300..75e5f1e1c0f47d 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -33,6 +33,7 @@ export type ConnectDoc = ( export interface RecordHandlers { editRecord: ( data: Partial< ObjectData > ) => void; getEditedRecord: () => Promise< ObjectData >; + refetchPersistedRecord: () => void; } export interface SyncConfig { From 3b92193e2509bdeb9cc7390c2447caa9887d394f Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 4 Sep 2025 12:46:07 -0600 Subject: [PATCH 65/99] Improve comments and types. Simplication of interfaces --- packages/core-data/src/actions.js | 4 +- packages/core-data/src/entities.js | 17 +- packages/core-data/src/sync.ts | 2 +- packages/sync/src/index.ts | 6 +- packages/sync/src/provider.ts | 239 +++++++++++++++-------------- packages/sync/src/types.ts | 14 +- 6 files changed, 152 insertions(+), 130 deletions(-) diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 6720df45802891..8c6651978e5709 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -401,7 +401,7 @@ export const editEntityRecord = if ( window.__experimentalEnableSync && entityConfig.syncConfig ) { if ( globalThis.IS_GUTENBERG_PLUGIN ) { getSyncProvider().updateCRDTDoc( - entityConfig.syncConfig.objectType, + entityConfig.syncConfig, record, edit.edits, 'gutenberg' @@ -705,7 +705,7 @@ export const saveEntityRecord = window.__experimentalEnableSync && entityConfig.syncConfig?.enabled ) { - getSyncProvider().markEntityAsPersisted( + getSyncProvider().updateLastPersistedDate( entityConfig.syncConfig, persistedRecord ); diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 05208b984771f6..7fad22ec8381e6 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -322,8 +322,8 @@ async function loadPostTypeEntities() { }, /** - * Transform a CRDT document into a partial record that can be used to - * update the local editor state. + * Extract changes from a CRDT document that can be used to update the + * local editor state. * * @param {import('@wordpress/sync').CRDTDoc} crdtDoc * @param {import('@wordpress/sync').ObjectData} record @@ -366,9 +366,18 @@ async function loadPostTypeEntities() { */ getObjectId: ( { id } ) => id, + /** + * The object type for the entity, used to scope CRDT documents. + */ objectType: `postType/${ postType.slug }`, - supportsAwareness: true, - supportsUndo: true, + + /** + * Sync features supported by the entity. + */ + supports: { + awareness: true, + undo: true, + }, }, supportsPagination: true, getRevisionsUrl: ( parentId, revisionId ) => diff --git a/packages/core-data/src/sync.ts b/packages/core-data/src/sync.ts index b52faff570dc4c..722b620d66eb7c 100644 --- a/packages/core-data/src/sync.ts +++ b/packages/core-data/src/sync.ts @@ -25,7 +25,7 @@ export function getSyncProvider(): SyncProvider { return syncProvider; } - const fallbackNoOpSyncProvider = new SyncProvider( null, null ); + const fallbackNoOpSyncProvider = new SyncProvider(); syncProvider = applyFilters( 'core.getSyncProvider', diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index f1e715d74f8fa4..3ecb0c2307a121 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -35,7 +35,7 @@ declare global { * @return {SyncProvider} The WebRTC sync provider. */ export function getWebRTCSyncProvider(): SyncProvider { - return new SyncProvider( + return new SyncProvider( [ connectIndexDb, createWebRTCConnection( { password: window?.__experimentalCollaborativeEditingSecret, @@ -43,6 +43,6 @@ export function getWebRTCSyncProvider(): SyncProvider { //'ws://localhost:4444', window?.wp?.ajax?.settings?.url, ], - } ) - ); + } ), + ] ); } diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index 4015a909edf5be..e05fa97f2127dc 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -22,17 +22,21 @@ import { UndoManager } from './undo-manager'; import { createYjsDoc } from './utils'; interface EntityState { - destroy: () => void; + discard: () => void; + handlers: RecordHandlers; lastPersistedAt: number; + syncConfig: SyncConfig; + undoManager?: UndoManager; ydoc: CRDTDoc; } const CRDT_STATE_MAP_KEY = 'state'; const CRDT_STATE_PERSISTED_AT_KEY = 'persistedAt'; +const LOCAL_ORIGINS = [ 'gutenberg', 'syncProvider' ]; + export class SyncProvider { - private connectLocal: ConnectDoc | null; - private connectRemote: ConnectDoc | null; + private connectionCreators: ConnectDoc[]; /** * CAUTION: We currently store a single UndoManager instance under these @@ -40,50 +44,24 @@ export class SyncProvider { * * 1. Only entities loaded by the block editor support an undo manager. * 2. Only one such entity is loaded at a time. - * 3. The entity's SyncConfig has `supportsUndo` set to true. + * 3. The entity's SyncConfig has `supports.undo` set to true. * * If these assumptions fail, we will need to refactor the selectors provided * by `@wordpress/core-data` (e.g., `getUndoManager`) to support multiple * UndoManager instances by requiring the entity type and ID as parameters. */ - private undoManager: UndoManager | null = null; + private undoManager: UndoManager | undefined; - protected configs: Map< ObjectType, SyncConfig > = new Map(); protected connections: Map< EntityID, ConnectDocResult[] > = new Map(); protected entityStates: Map< EntityID, EntityState > = new Map(); /** * Constructor. * - * @param {ConnectDoc | null} connectLocal Connect the document to a local database. - * @param {ConnectDoc | null} connectRemote Connect the document to a remote sync connection. - */ - public constructor( - connectLocal: ConnectDoc | null, - connectRemote: ConnectDoc | null - ) { - this.connectLocal = connectLocal; - this.connectRemote = connectRemote; - } - - /** - * Connect to a document. - * - * @param {ObjectID} objectId Object ID to connect. - * @param {ObjectType} objectType Object type to connect. - * @param {CRDTDoc} ydoc Yjs document for the object. + * @param {ConnectDoc[]} connectionCreators Functions that create Yjs connection providers. */ - private async connect( - objectId: ObjectID, - objectType: ObjectType, - ydoc: CRDTDoc - ): Promise< ConnectDocResult[] > { - return ( - await Promise.all( [ - this.connectLocal?.( objectId, objectType, ydoc ), - this.connectRemote?.( objectId, objectType, ydoc ), - ] ) - ).filter( ( result ): result is ConnectDocResult => Boolean( result ) ); + public constructor( connectionCreators: ConnectDoc[] = [] ) { + this.connectionCreators = connectionCreators; } /** @@ -104,34 +82,49 @@ export class SyncProvider { const connections = await this.connect( objectId, objectType, ydoc ); const entityId = this.getEntityId( objectType, objectId ); - const onDestroy = (): void => { + // Clean up connections and in-memory state when the entity is discarded. + const onDiscard = (): void => { connections.forEach( ( result ) => result.destroy() ); ydoc.off( 'update', onUpdate ); ydoc.destroy(); + this.connections.delete( entityId ); this.entityStates.delete( entityId ); }; + // When the CRDT document is updated by a connection (not a local origin like + // Gutenberg or this SyncProvider), update the local store. const onUpdate = ( _update: Uint8Array, origin: string ): void => { - if ( origin !== 'gutenberg' ) { - void this.updateEntityRecord( syncConfig, handlers ); + if ( LOCAL_ORIGINS.includes( origin ) ) { + return; } + + void this.updateEntityRecord( objectType, objectId ); }; - if ( syncConfig.supportsUndo ) { - this.undoManager = new UndoManager( ydoc ); + const entityState: EntityState = { + discard: onDiscard, + handlers, + lastPersistedAt: Date.now(), + syncConfig, + ydoc, + }; + + if ( syncConfig.supports?.undo ) { + entityState.undoManager = new UndoManager( ydoc ); + this.undoManager = entityState.undoManager; } - this.configs.set( objectType, syncConfig ); this.connections.set( entityId, connections ); - this.setEntityState( objectType, objectId, { - destroy: onDestroy, - lastPersistedAt: Date.now(), - ydoc, - } ); + this.entityStates.set( + this.getEntityId( objectType, objectId ), + entityState + ); // Get the initial document state. const initialDoc = await this.getInitialCRDTDoc( syncConfig, record ); + // Attach the update listener before applying the initial state so that + // we update the entity record in the local store. ydoc.on( 'update', onUpdate ); // Apply the initial document to the current document as a singular update. @@ -140,54 +133,53 @@ export class SyncProvider { () => { Y.applyUpdate( ydoc, Y.encodeStateAsUpdate( initialDoc ) ); }, - 'syncProvider.bootstrap', + 'syncProvider', false ); } /** - * Get the entity ID for the given object type and object ID. + * Establish connections for the given entity and its Yjs document. * - * @param {ObjectType} objectType Object type. - * @param {ObjectID} objectId Object ID. + * @param {ObjectID} objectId Object ID to connect. + * @param {ObjectType} objectType Object type to connect. + * @param {CRDTDoc} ydoc Yjs document for the object. */ - protected getEntityId( + private async connect( + objectId: ObjectID, objectType: ObjectType, - objectId: ObjectID - ): EntityID { - return `${ objectType }_${ objectId }`; + ydoc: CRDTDoc + ): Promise< ConnectDocResult[] > { + return await Promise.all( + this.connectionCreators?.map( ( create ) => + create( objectId, objectType, ydoc ) + ) + ); } /** - * Get the entity state for the given object type and object ID. + * Stop syncing an entity and destroy its in-memory state. * - * @param {ObjectType} objectType Object type. - * @param {ObjectID} objectId Object ID. + * @param {ObjectType} objectType Object type to discard. + * @param {ObjectID} objectId Object ID to discard. */ - protected getEntityState( - objectType: ObjectType, - objectId: ObjectID - ): EntityState | null { - return ( - this.entityStates.get( this.getEntityId( objectType, objectId ) ) ?? - null - ); + public discard( objectType: ObjectType, objectId: ObjectID ): void { + this.entityStates + .get( this.getEntityId( objectType, objectId ) ) + ?.discard(); } /** - * Set the entity state for the given object type and object ID. + * Get the entity ID for the given object type and object ID. * - * @param {ObjectType} objectType Object type. - * @param {ObjectID} objectId Object ID. - * @param {Partial< EntityState >} state Partial entity state to set. + * @param {ObjectType} objectType Object type. + * @param {ObjectID} objectId Object ID. */ - private setEntityState( + protected getEntityId( objectType: ObjectType, - objectId: ObjectID, - state: EntityState - ): void { - const entityId = this.getEntityId( objectType, objectId ); - this.entityStates.set( entityId, state ); + objectId: ObjectID + ): EntityID { + return `${ objectType }_${ objectId }`; } /** @@ -281,64 +273,54 @@ export class SyncProvider { * @return {UndoManager | null} The undo manager, or null if unsupported. */ public getUndoManager(): UndoManager | null { - return this.undoManager; - } - - public markEntityAsPersisted( - syncConfig: SyncConfig, - record: ObjectData - ): void { - const objectId = syncConfig.getObjectId( record ); - const objectType = syncConfig.objectType; - const ydoc = this.getEntityState( objectType, objectId )?.ydoc; - - ydoc?.getMap( 'state' ).set( CRDT_STATE_PERSISTED_AT_KEY, Date.now() ); + return this.undoManager ?? null; } /** * Update CRDT document with changes from the local store. * - * @param {ObjectType} objectType Object type to load. + * @param {SyncConfig} syncConfig Sync configuration for the object type. * @param {ObjectData} record Record to load. * @param {Partial< ObjectData >} changes Updates to make. * @param {string} origin The source of change. */ public updateCRDTDoc( - objectType: ObjectType, + syncConfig: SyncConfig, record: ObjectData, changes: Partial< ObjectData >, origin: string ): void { - const syncConfig = this.configs.get( objectType ); - const objectId = syncConfig?.getObjectId( record ); - - if ( ! syncConfig || ! objectId ) { - return; - } - - const ydoc = this.getEntityState( objectType, objectId )?.ydoc; + const objectType = syncConfig.objectType; + const objectId = syncConfig.getObjectId( record ); + const entityId = this.getEntityId( objectType, objectId ); + const ydoc = this.entityStates.get( entityId )?.ydoc; ydoc?.transact( () => { syncConfig.applyChangesToCRDTDoc( ydoc, changes, record, origin ); }, origin ); } + /** + * 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. + */ private async updateEntityRecord( - syncConfig: SyncConfig, - handlers: RecordHandlers + objectType: ObjectType, + objectId: ObjectID ): Promise< void > { - const currentRecord = await handlers.getEditedRecord(); - - const objectId = syncConfig.getObjectId( currentRecord ); - const objectType = syncConfig.objectType; - - const entityState = this.getEntityState( objectType, objectId ); + const entityId = this.getEntityId( objectType, objectId ); + const entityState = this.entityStates.get( entityId ); if ( ! entityState ) { return; } - const { lastPersistedAt, ydoc } = entityState; + const { handlers, lastPersistedAt, syncConfig, ydoc } = entityState; + + const currentRecord = await handlers.getEditedRecord(); // Determine which synced properties have actually changed by comparing // them against the current entity record. @@ -356,19 +338,52 @@ export class SyncProvider { const persistedAt = ( ystateMap.get( CRDT_STATE_PERSISTED_AT_KEY ) as number ) ?? 0; if ( persistedAt > lastPersistedAt ) { - entityState.lastPersistedAt = persistedAt; - this.setEntityState( objectType, objectId, entityState ); + this.entityStates.set( entityId, { + ...entityState, + lastPersistedAt: persistedAt, + } ); + void handlers.refetchPersistedRecord(); } } /** - * Stop updating a document and discard it. + * Update the last persisted timestamp in the CRDT document state map. This is + * used by peers as a signal that they need to refetch the persisted entity. * - * @param {ObjectType} objectType Object type to discard. - * @param {ObjectID} objectId Object ID to discard. + * @param {SyncConfig} syncConfig Sync configuration for the object type. + * @param {ObjectData} record Record representing this object type. */ - public discard( objectType: ObjectType, objectId: ObjectID ): void { - this.getEntityState( objectType, objectId )?.destroy(); + public updateLastPersistedDate( + syncConfig: SyncConfig, + record: ObjectData + ): void { + const objectId = syncConfig.getObjectId( record ); + const objectType = syncConfig.objectType; + const entityId = this.getEntityId( objectType, objectId ); + const entityState = this.entityStates.get( entityId ); + + if ( ! entityState ) { + return; + } + + const ydoc = entityState.ydoc; + const lastPersistedAt = Date.now(); + + // Update in-memory state. + this.entityStates.set( entityId, { + ...entityState, + lastPersistedAt, + } ); + + Y.transact( + ydoc, + () => { + const stateMap = ydoc.getMap( 'state' ); + stateMap.set( CRDT_STATE_PERSISTED_AT_KEY, lastPersistedAt ); + }, + 'syncProvider', + true + ); } } diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 75e5f1e1c0f47d..b3918508d56291 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -12,12 +12,8 @@ export type ObjectType = string; export type UndoManager = Y.UndoManager; // Object data represents any entity record, post, term, user, site, etc. There -// are not many expectations that can hold on its shape, but defining some -// optional properties cuts down on the type narrowing. -export interface ObjectData extends Record< string, unknown > { - meta?: Record< string, unknown >; - status?: string; -} +// are not many expectations that can hold on its shape. +export interface ObjectData extends Record< string, unknown > {} export interface ConnectDocResult { awareness?: Awareness; @@ -47,6 +43,8 @@ export interface SyncConfig { getInitialObjectData: ( record: ObjectData ) => ObjectData; getObjectId: ( data: ObjectData ) => ObjectID; objectType: ObjectType; - supportsAwareness?: boolean; - supportsUndo?: boolean; + supports?: { + awareness?: boolean; + undo?: boolean; + }; } From 6649558390582d71a8601576aa508f5202703d02 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 4 Sep 2025 16:58:53 -0600 Subject: [PATCH 66/99] Update CODE.md --- packages/sync/CODE.md | 95 +++++++++++++++++++------------------------ 1 file changed, 41 insertions(+), 54 deletions(-) diff --git a/packages/sync/CODE.md b/packages/sync/CODE.md index d61dac12824e89..6a877a716d2aa2 100644 --- a/packages/sync/CODE.md +++ b/packages/sync/CODE.md @@ -1,72 +1,59 @@ # Status of the sync experiment in Gutenberg -The sync package is part of an ongoing effort to lay the groundwork of Real-Time Collaboration in Gutenberg. +The sync package provides an implementation of real-time collaboration in Gutenberg. -Relevant docs: +Relevant docs and discussions: -- https://make.wordpress.org/core/2023/07/13/real-time-collaboration-architecture/ -- https://github.com/WordPress/gutenberg/issues/52593 -- https://docs.yjs.dev/ +- https://make.wordpress.org/core/2023/07/13/real-time-collaboration-architecture/ +- https://github.com/WordPress/gutenberg/issues/52593 +- https://github.com/WordPress/gutenberg/discussions/65012 +- https://docs.yjs.dev/ -## Enable the experiments +## Enable the experiment -There are two experiments that must be enabled in the "Guteberg > Experiments" page: - -- Collaboration: enable real-time collaboration -- Collaboration: WebRTC provider - -Alternatively, you can enable just the "Collaboration: enable real-time collaboration" experiment and load a custom provider (transport) via a filter: +The real-time collaboration experiment must be enabled on the "Gutenberg > Experiments" page. By default, a WebRTC provider with HTTP signaling is used to connect peers. Alternatively, you can load a custom sync provider via a filter: ```js -addFilter( 'core.getSyncProvider', 'my-plugin/custom-sync-provider', ( getSyncProvider ) => { - return { - // Custom sync provider implementation - bootstrap: () => {}, - discard: () => {}, - update: () => {}, - }; +addFilter( 'core.getSyncProvider', 'my-plugin/custom-sync-provider', () => { + return new SyncProvider( /* ...args */ ); } ); ``` -When it is enabled (search for `gutenberg-sync-collaboration` in the codebase), the client receives two new pieces of data: +When it is enabled, the following global variables are defined:: -- `window.__experimentalEnableSync`: boolean. Used by the `core-data` package to determine whether to bootstrap and use the sync provider offered by the `sync` package. -- `window.__experimentalCollaborativeEditingSecret`: string. A secret used by the `sync` package to create a secure connection among peers. +- `window.__experimentalEnableSync` (`boolean`): Used by the `core-data` package to determine whether entity syncing is available. +- `window.__experimentalCollaborativeEditingSecret` (`string`). A secret (stored in a WordPress option) used by the WebRTC provider to create a secure connection between peers. ## The data flow -The current experiment updates `core-data` to leverage the YJS library for synchronization and merging changes. Each core-data entity record represents a YJS document and updates to the `--edit` record are broadcasted among peers. +Each entity with sync enabled is represented by a CRDT (Yjs) document. Local edits (unsaved changes) to an entity record are applied to its CRDT document, which is synced with other peers via a provider. Those peers use the CRDT document to update their local state. These are the specific checkpoints: -1. REGISTER. - - See `getSyncProvider().register( ... )` in `registerSyncConfigs`. - - Not all entity types are sync-enabled at the moment, look at those that declare a `syncConfig` and `syncObjectType` in `rootEntitiesConfig`. -2. BOOTSTRAP. - - See `getSyncProvider().bootstrap( ... )` in `getEntityRecord`. - - The `bootstrap` function fetches the entity and sets up the callback that will dispatch the relevant Redux action when document changes are broadcasted from other peers. -3. UPDATE. - - See `getSyncProvider().update( ... )` in `editEntityRecord`. - - Each change done by a peer to the `--edit` entity record (local changes, not persisted ones) is broadcasted to the others. - - The data that is shared is the whole block list. - -This is the data flow when the peer A makes a local change: - -- Peer A makes a local change. -- Peer A triggers a `getSyncProvider().update( ... )` request (see `editEntityRecord`). -- All peers (including A) receive the broadcasted change and execute the callback (see `updateHandler` in `createSyncProvider.bootstrap`). -- All peers (including A) trigger a `EDIT_ENTITY_RECORD` redux action. - -## What works and what doesn't - -- Undo/redo does not work. -- Changes can be persisted and the publish/update button should react accordingly for all peers. -- Offline. - - Changes are stored in the browser's local storage (indexedDB) for each user/peer. Users can navigate away from the document and they'll see the changes when they come back. - - Offline changes can be deleted via visiting the browser's database in all peers, then reload the document. -- Documents can get out of sync. For example: - - Two peers open the same document. - - One of them (A) leaves the document. Then, the remaining user (B) makes changes. - - When A comes back to the document, the changes B made are not visible to A. -- Entities - - Not all entities are synced. For example, global styles are not. Look at the `base` entity config for an example (it declares `syncConfig` and `syncObjectType` properties). +1. **CONFIG**: The entity's config defines a `syncConfig` property to enable syncing for that entity type and define its behavior. + - See `packages/core-data/src/entities.js`. + - Not all entities are sync-enabled; look for those that define a `syncConfig` property. + - Not all properties are synced; look for the `syncProperties` set that is passed as an argument to various functions. +2. **BOOTSTRAP**: When an entity record is loaded for the first time and it supports syncing, it is "bootstrapped" to provide handlers for various lifecycle events. + - See `getEntityRecord` in `packages/core-data/src/resolvers.js`. + - See `SyncProvider#bootstrap()` in this package. +3. **LOCAL CHANGES**: When local changes are made to an entity record, it is applied to the entity's CRDT document, which is synced with peers. + - See `editEntityRecord` in `packages/core-data/src/actions.js`. + - See `SyncProvider#updateCRDTDoc()` in this package. +4. **REMOTE CHANGES**: When an entity's CRDT document is updated by a remote peer, changes are extracted and the entity record is updated in the local store. + - See `SyncProvider#updateEntityRecord` in this package. +5. **PERSISTED CHANGES**: When an entity record is persisted (saved) to the database, other peers receive a signal that they should refresh their local copy of the entity record. + - See `saveEntityRecord` in `packages/core-data/src/actions.js`. + - See `SyncProvider#updateLastPersistedDate` in this package. + +While the Redux actions in `core-data` and the `SyncProvider` orchestrate this data flow, the behavior of what gets synced is controlled by the entity's `syncConfig`: + +- `enabled` determines whether syncing is enabled for the entity type. This could vary based on context (e.g., post type). +- `applyChangesToCRDTDoc` determines how (or if) local changes are applied to the CRDT document. +- `getChangesFromCRDTDoc` determines how (or if) changes from the CRDT document are extracted and applied to the entity record. +- `getInitialObjectData` determines the initial state of the CRDT document when it is first created and can be used to create computed or meta properties for syncing (e.g., `blocks` are computed from `content`). +- `getObjectId` extracts an entity's immutable ID from an entity record. +- `objectType` is a unique string that identifies the entity type. +- `supports` is a hash that declares support for various sync features, present and future. + +While an entity should internally define which of its properties are synced, that set is not exported. An entity's `syncConfig` "owns" its behavior and it should not delegate or leak that responsibility to other parts of the codebase. From 2e7b61d66fb0233916d850c32cdd85c6caf7a92c Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 4 Sep 2025 17:30:57 -0600 Subject: [PATCH 67/99] Remove redundant exports --- packages/sync/src/types.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index b3918508d56291..e02de4ca9841ef 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -4,12 +4,10 @@ import type * as Y from 'yjs'; import type { Awareness } from 'y-protocols/awareness'; -export type * as Y from 'yjs'; export type CRDTDoc = Y.Doc; export type EntityID = string; export type ObjectID = string; export type ObjectType = string; -export type UndoManager = Y.UndoManager; // Object data represents any entity record, post, term, user, site, etc. There // are not many expectations that can hold on its shape. From 140dbb82948ec69d7b93fbef0a1d70f7bcbbcff3 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 4 Sep 2025 17:31:18 -0600 Subject: [PATCH 68/99] Remove unused sync dependency --- package-lock.json | 50 -------------------------------------- packages/sync/package.json | 1 - 2 files changed, 51 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2fc0abcbb06169..3e4e2ab3e23fe0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52249,7 +52249,6 @@ "simple-peer": "^9.11.0", "y-indexeddb": "^9.0.12", "y-protocols": "^1.0.6", - "y-webrtc": "^10.3.0", "yjs": "13.6.27" }, "engines": { @@ -52257,28 +52256,6 @@ "npm": ">=8.19.2" } }, - "packages/sync/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "packages/sync/node_modules/y-indexeddb": { "version": "9.0.12", "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz", @@ -52319,33 +52296,6 @@ "yjs": "^13.0.0" } }, - "packages/sync/node_modules/y-webrtc": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/y-webrtc/-/y-webrtc-10.3.0.tgz", - "integrity": "sha512-KalJr7dCgUgyVFxoG3CQYbpS0O2qybegD0vI4bYnYHI0MOwoVbucED3RZ5f2o1a5HZb1qEssUKS0H/Upc6p1lA==", - "license": "MIT", - "dependencies": { - "lib0": "^0.2.42", - "simple-peer": "^9.11.0", - "y-protocols": "^1.0.6" - }, - "bin": { - "y-webrtc-signaling": "bin/server.js" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - }, - "optionalDependencies": { - "ws": "^8.14.2" - }, - "peerDependencies": { - "yjs": "^13.6.8" - } - }, "packages/sync/node_modules/yjs": { "version": "13.6.27", "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", diff --git a/packages/sync/package.json b/packages/sync/package.json index 9c53e905e2987a..314b070b882ebd 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -36,7 +36,6 @@ "simple-peer": "^9.11.0", "y-indexeddb": "^9.0.12", "y-protocols": "^1.0.6", - "y-webrtc": "^10.3.0", "yjs": "13.6.27" }, "publishConfig": { From 59d7e35049a6a2eba71638265df1d2df4da33a74 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 10 Sep 2025 11:08:03 -0600 Subject: [PATCH 69/99] Add `syncedProperties` to `syncConfig` --- packages/core-data/src/entities.js | 13 +++++++++++++ packages/sync/CODE.md | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 7fad22ec8381e6..c6268643dc494b 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -295,6 +295,8 @@ async function loadPostTypeEntities() { syncConfig: { /** * Is syncing enabled for this entity? + * + * @type {boolean} */ enabled: Boolean( postType.supports?.[ 'collaborative-editing' ] && @@ -368,16 +370,27 @@ async function loadPostTypeEntities() { /** * The object type for the entity, used to scope CRDT documents. + * + * @type {string} */ objectType: `postType/${ postType.slug }`, /** * Sync features supported by the entity. + * + * @type {Record< string, boolean >} */ supports: { awareness: true, undo: true, }, + + /** + * The properties that should be synced via the CRDT document. + * + * @type {Set< string >} + */ + syncedProperties, }, supportsPagination: true, getRevisionsUrl: ( parentId, revisionId ) => diff --git a/packages/sync/CODE.md b/packages/sync/CODE.md index 6a877a716d2aa2..e129a41f3a7cd2 100644 --- a/packages/sync/CODE.md +++ b/packages/sync/CODE.md @@ -55,5 +55,6 @@ While the Redux actions in `core-data` and the `SyncProvider` orchestrate this d - `getObjectId` extracts an entity's immutable ID from an entity record. - `objectType` is a unique string that identifies the entity type. - `supports` is a hash that declares support for various sync features, present and future. +- `syncedProperties` is the set of entity properties that should be synced (possibly including computed or meta properties from `getInitialObjectData`). -While an entity should internally define which of its properties are synced, that set is not exported. An entity's `syncConfig` "owns" its behavior and it should not delegate or leak that responsibility to other parts of the codebase. +An entity's `syncConfig` "owns" the sync behavior of the entity (especially via `applyChangesToCRDTDoc` and `getChangesFromCRDTDoc`) and it should not delegate or leak that responsibility to other parts of the codebase. From 252b46456ac06c2a8b4f9454812b743a1c9c99c3 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 10 Sep 2025 11:10:30 -0600 Subject: [PATCH 70/99] Add `syncedProperties` to `SyncConfig` type --- packages/core-data/src/entities.js | 2 +- packages/sync/src/types.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index c6268643dc494b..06d9de1add9b8b 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -371,7 +371,7 @@ async function loadPostTypeEntities() { /** * The object type for the entity, used to scope CRDT documents. * - * @type {string} + * @type {import('@wordpress/sync').ObjectType} */ objectType: `postType/${ postType.slug }`, diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index e02de4ca9841ef..d8c53f6f066bd7 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -45,4 +45,5 @@ export interface SyncConfig { awareness?: boolean; undo?: boolean; }; + syncedProperties: Set< string >; } From af5f15e3afe868e6a2031913e2f9da4007bc412c Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 11 Sep 2025 11:27:11 -0600 Subject: [PATCH 71/99] Allow meta properties to be synced via an opt-in filter --- packages/core-data/src/entities.js | 26 ++-- packages/core-data/src/utils/crdt.ts | 199 ++++++++++++++++++++++++--- 2 files changed, 189 insertions(+), 36 deletions(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 06d9de1add9b8b..f1e878bf66e65a 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -16,6 +16,8 @@ import { parse } from '@wordpress/blocks'; import { applyPostChangesToCRDTDoc, getPostChangesFromCRDTDoc, + getSyncedMetaPropertiesForPostType, + getSyncedPropertiesForPostType, } from './utils/crdt'; export const DEFAULT_ENTITY_KEY = 'id'; @@ -248,22 +250,6 @@ export const prePersistPostType = ( persistedRecord, edits ) => { * @return {Promise} Entities promise */ async function loadPostTypeEntities() { - const syncedProperties = new Set( [ - 'author', - 'blocks', - 'comment_status', - 'date', - 'excerpt', - 'featured_media', - 'format', - 'ping_status', - 'status', - 'tags', - 'template', - 'slug', - 'title', - ] ); - const postTypes = await apiFetch( { path: '/wp/v2/types?context=edit', } ); @@ -272,6 +258,10 @@ async function loadPostTypeEntities() { name ); const namespace = postType?.rest_namespace ?? 'wp/v2'; + const syncedProperties = getSyncedPropertiesForPostType( postType ); + const syncedMetaProperties = + getSyncedMetaPropertiesForPostType( postType ); + return { kind: 'postType', baseURL: `/${ namespace }/${ postType.rest_base }`, @@ -319,6 +309,7 @@ async function loadPostTypeEntities() { changes, record, syncedProperties, + syncedMetaProperties, origin ); }, @@ -335,7 +326,8 @@ async function loadPostTypeEntities() { getPostChangesFromCRDTDoc( crdtDoc, record, - syncedProperties + syncedProperties, + syncedMetaProperties ), /** diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index 1f7198f7a583fe..a939ef4e68effe 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -6,30 +6,39 @@ import * as fun from 'lib0/function'; /** * WordPress dependencies */ -import { type CRDTDoc, type ObjectData, Y } from '@wordpress/sync'; +import { applyFilters } from '@wordpress/hooks'; +import { type CRDTDoc, Y } from '@wordpress/sync'; /** * Internal dependencies */ import { mergeCrdtBlocks, type Block, type YBlock } from './crdt-blocks'; +import { type Post } from '../entity-types/post'; +import { type Type } from '../entity-types'; -type MaybeRawValue = string | { raw: string }; - -interface PostChanges { - blocks?: Block[]; - excerpt?: MaybeRawValue; - status?: string; - title?: MaybeRawValue; -} +type PostChanges = Partial< Post > & { blocks?: Block[] }; // Key used to store the document map in the Y.Doc. const DOCUMENT_MAP_KEY = 'document'; +/** + * Given a set of local changes to a post record, apply those changes to the + * local Y.Doc. + * + * @param {CRDTDoc} ydoc + * @param {PostChanges} changes + * @param {Post} record + * @param {Set< string >} syncedProperties + * @param {Set< string >} syncedMetaProperties + * @param {string} origin + * @return {void} + */ export function applyPostChangesToCRDTDoc( ydoc: CRDTDoc, changes: PostChanges, - record: ObjectData, + record: Post, syncedProperties: Set< string >, + syncedMetaProperties: Set< string >, origin: string ): void { const ymap = ydoc.getMap( DOCUMENT_MAP_KEY ); @@ -63,7 +72,7 @@ export function applyPostChangesToCRDTDoc( } // Block[] from local changes. - const newBlocks = newValue ?? []; + const newBlocks = ( newValue as PostChanges[ 'blocks' ] ) ?? []; // Merge blocks does not need `setValue` because it is operating on a // Yjs type that is already in the Y.Doc. @@ -81,6 +90,36 @@ export function applyPostChangesToCRDTDoc( break; } + // Meta is overloaded term in Core; here, it refers to post meta. + case 'meta': { + let metaMap = ymap.get( 'meta' ) as Y.Map< unknown >; + + // Initialize. + if ( ! ( metaMap instanceof Y.Map ) ) { + metaMap = new Y.Map(); + setValue( metaMap ); + } + + // Iterate over each meta property in the new value and merge it (if it + // is a synced meta property). + Object.entries( newValue ?? {} ).forEach( + ( [ metaKey, metaValue ] ) => { + if ( ! syncedMetaProperties.has( metaKey ) ) { + return; + } + + mergeValue( + metaMap.get( metaKey ), // current value in CRDT + metaValue, // new value from local changes + ( updatedMetaValue: unknown ): void => { + metaMap.set( metaKey, updatedMetaValue ); + } + ); + } + ); + break; + } + case 'slug': { // Do not sync an empty slug. This indicates that the post is using // the default auto-generated slug. @@ -137,14 +176,17 @@ export function applyPostChangesToCRDTDoc( * to dispatch. * * @param {CRDTDoc} ydoc - * @param {ObjectData} record + * @param {Post} record * @param {Set< string >} syncedProperties + * @param {Set< string >} syncedMetaProperties + * @return {Partial} The changes that should be applied to the local record. */ export function getPostChangesFromCRDTDoc( ydoc: CRDTDoc, - record: ObjectData, - syncedProperties: Set< string > -): Partial< PostChanges > { + record: Post, + syncedProperties: Set< string >, + syncedMetaProperties: Set< string > +): PostChanges { const ymap = ydoc.getMap( DOCUMENT_MAP_KEY ); return Object.fromEntries( @@ -179,6 +221,24 @@ export function getPostChangesFromCRDTDoc( return haveValuesChanged( currentValue, newValue ); } + case 'meta': { + const allowedMeta = Object.fromEntries( + Object.entries( newValue ?? {} ).filter( + ( [ metaKey ] ) => + syncedMetaProperties.has( metaKey ) + ) + ); + + // Merge the allowed meta changes with the current meta values since + // not all meta properties are synced. + const mergedValue = { + ...( currentValue as PostChanges[ 'meta' ] ), + ...allowedMeta, + }; + + return haveValuesChanged( currentValue, mergedValue ); + } + case 'status': { // Do not sync an invalid status. if ( 'auto-draft' === newValue ) { @@ -191,7 +251,7 @@ export function getPostChangesFromCRDTDoc( case 'excerpt': case 'title': { return haveValuesChanged( - getRawValue( currentValue as MaybeRawValue ), + getRawValue( currentValue ), newValue ); } @@ -206,13 +266,29 @@ export function getPostChangesFromCRDTDoc( ); } -function getRawValue( value?: MaybeRawValue ): string | undefined { +/** + * Extract the raw string value from a property that may be a string or an object + * with a `raw` property (`RenderedText`). + * + * @param {unknown} value The value to extract from. + * @return {string|undefined} The raw string value, or undefined if it could not be determined. + */ +function getRawValue( value?: unknown ): string | undefined { // Value may be a string property or a nested object with a `raw` property. if ( 'string' === typeof value ) { return value; } - return value?.raw; + if ( + value && + 'object' === typeof value && + 'raw' in value && + 'string' === typeof value.raw + ) { + return value.raw; + } + + return undefined; } function haveValuesChanged< ValueType = any >( @@ -225,9 +301,94 @@ function haveValuesChanged< ValueType = any >( function mergeValue< ValueType = any >( currentValue: ValueType, newValue: ValueType, - setValue: ( value: ValueType ) => ValueType + setValue: ( value: ValueType ) => void ): void { if ( haveValuesChanged< ValueType >( currentValue, newValue ) ) { setValue( newValue ); } } + +/** + * Given a post type definition, return the set of properties that should be + * synced for that post type. + * + * @param {Type} postType The post type definition. + * @return {Set} The set of properties that should be synced. + */ +export function getSyncedPropertiesForPostType( + postType: Type +): Set< string > { + const syncedProperties = new Set< string >( [ + 'date', + 'status', + 'tags', + 'template', + 'slug', + 'sticky', + ] ); + + Object.entries( postType.supports || {} ).forEach( + ( [ feature, isSupported ] ) => { + if ( ! isSupported ) { + return; + } + + switch ( feature ) { + case 'author': + syncedProperties.add( 'author' ); + break; + case 'comments': + syncedProperties.add( 'comment_status' ); + break; + case 'custom-fields': + syncedProperties.add( 'meta' ); + break; + case 'editor': + syncedProperties.add( 'blocks' ); + break; + case 'excerpt': + syncedProperties.add( 'excerpt' ); + break; + case 'post-formats': + syncedProperties.add( 'format' ); + break; + case 'thumbnail': + syncedProperties.add( 'featured_media' ); + break; + case 'trackbacks': + syncedProperties.add( 'ping_status' ); + break; + case 'title': + syncedProperties.add( 'title' ); + break; + } + } + ); + + return syncedProperties; +} + +/** + * Given a post type definition, return the set of meta properties that should + * be synced for that post type. + * + * @param {Type} postType The post type definition. + * @return {Set} The set of meta properties that should be synced. + */ +export function getSyncedMetaPropertiesForPostType( + postType: Type +): Set< string > { + // Return empty set if the post type does not support custom fields. + if ( ! postType.supports?.[ 'custom-fields' ] ) { + return new Set(); + } + + const syncedMetaProperties: string[] = applyFilters( + 'sync.metaProperties', + [], + postType.slug, + postType + ) as string[]; + + return new Set( syncedMetaProperties ); +} From caab5b7306cfd602f7299e3e48f660b91dacdb93 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 11 Sep 2025 11:28:11 -0600 Subject: [PATCH 72/99] Remove confusing get inside setValue function --- packages/core-data/src/utils/crdt.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index a939ef4e68effe..d7f2b218bfdb68 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -45,7 +45,6 @@ export function applyPostChangesToCRDTDoc( Object.entries( changes ).forEach( ( [ key, newValue ] ) => { if ( ! syncedProperties.has( key ) ) { - ymap.delete( key ); return; } @@ -54,21 +53,19 @@ export function applyPostChangesToCRDTDoc( return; } - // Return .get() result so that caller can operate on the data type - // without having to call .get() themselves. - function setValue< T = unknown >( updatedValue: T ): T { + // Set the value in the root document. + function setValue< T = unknown >( updatedValue: T ): void { ymap.set( key, updatedValue ); - return ymap.get( key ) as T; } switch ( key ) { case 'blocks': { let currentBlocks = ymap.get( 'blocks' ) as Y.Array< YBlock >; + // Initialize. if ( ! ( currentBlocks instanceof Y.Array ) ) { - currentBlocks = setValue< Y.Array< YBlock > >( - new Y.Array() - ); // Initialize + currentBlocks = new Y.Array(); + setValue( currentBlocks ); } // Block[] from local changes. From 90642d4226fd3fb28480734396a6d28577b0dd56 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 11 Sep 2025 11:32:42 -0600 Subject: [PATCH 73/99] Add filter comment --- packages/core-data/src/utils/crdt.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index d7f2b218bfdb68..5fab8b1d9a319e 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -380,6 +380,16 @@ export function getSyncedMetaPropertiesForPostType( return new Set(); } + /** + * In order to be available to the sync module, meta properties must be + * registered against the post type and made available via the REST API + * (`'show_in_rest' => true`). + * + * @param {string[]} syncedMetaProperties List of meta properties to sync. + * @param {string} postTypeSlug The post type slug. + * @param {Type} postType The post type definition. + * @return {string[]} The filtered list of meta properties to sync. + */ const syncedMetaProperties: string[] = applyFilters( 'sync.metaProperties', [], From ffce7ef42e1d01577d9d6431108a6c0e504d230f Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 11 Sep 2025 12:54:46 -0600 Subject: [PATCH 74/99] Add footnotes to default set of synced meta properties --- packages/core-data/src/utils/crdt.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index 5fab8b1d9a319e..17a41708daa34b 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -385,6 +385,8 @@ export function getSyncedMetaPropertiesForPostType( * registered against the post type and made available via the REST API * (`'show_in_rest' => true`). * + * Pass an Array instead of Set as a more familiar data structure. + * * @param {string[]} syncedMetaProperties List of meta properties to sync. * @param {string} postTypeSlug The post type slug. * @param {Type} postType The post type definition. @@ -392,7 +394,7 @@ export function getSyncedMetaPropertiesForPostType( */ const syncedMetaProperties: string[] = applyFilters( 'sync.metaProperties', - [], + [ 'footnotes' ], postType.slug, postType ) as string[]; From 6dcd1ed7e4b36eabe8039d36abd03843728d2d37 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 11 Sep 2025 12:58:02 -0600 Subject: [PATCH 75/99] Add crdtPersistence support (tangentially related) --- packages/core-data/src/entities.js | 7 ++++++- packages/sync/src/types.ts | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index f1e878bf66e65a..129315f81ac429 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -368,12 +368,17 @@ async function loadPostTypeEntities() { objectType: `postType/${ postType.slug }`, /** - * Sync features supported by the entity. + * Sync features supported by the entity. Since overall syncing support + * is gated by the `enabled` property, we don't need to check for + * "editor" support here. * * @type {Record< string, boolean >} */ supports: { awareness: true, + crdtPersistence: Boolean( + postType.supports?.[ 'custom-fields' ] + ), undo: true, }, diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index d8c53f6f066bd7..abd1e36f690054 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -43,6 +43,7 @@ export interface SyncConfig { objectType: ObjectType; supports?: { awareness?: boolean; + crdtPersistence?: boolean; undo?: boolean; }; syncedProperties: Set< string >; From 72c79b85d728916b67c6623a956ac3bd6e28b1a2 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Fri, 12 Sep 2025 09:39:28 -0600 Subject: [PATCH 76/99] Switch meta to opt-out, filter each unique sync decision --- packages/core-data/src/entities.js | 9 ++-- packages/core-data/src/utils/crdt.ts | 76 +++++++++++++++++----------- 2 files changed, 49 insertions(+), 36 deletions(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 129315f81ac429..c3797f67e02f2f 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -16,7 +16,6 @@ import { parse } from '@wordpress/blocks'; import { applyPostChangesToCRDTDoc, getPostChangesFromCRDTDoc, - getSyncedMetaPropertiesForPostType, getSyncedPropertiesForPostType, } from './utils/crdt'; @@ -259,8 +258,6 @@ async function loadPostTypeEntities() { ); const namespace = postType?.rest_namespace ?? 'wp/v2'; const syncedProperties = getSyncedPropertiesForPostType( postType ); - const syncedMetaProperties = - getSyncedMetaPropertiesForPostType( postType ); return { kind: 'postType', @@ -308,8 +305,8 @@ async function loadPostTypeEntities() { crdtDoc, changes, record, + postType, syncedProperties, - syncedMetaProperties, origin ); }, @@ -326,8 +323,8 @@ async function loadPostTypeEntities() { getPostChangesFromCRDTDoc( crdtDoc, record, - syncedProperties, - syncedMetaProperties + postType, + syncedProperties ), /** diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index 17a41708daa34b..4ddeed7e048407 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -28,8 +28,8 @@ const DOCUMENT_MAP_KEY = 'document'; * @param {CRDTDoc} ydoc * @param {PostChanges} changes * @param {Post} record + * @param {Type} postType * @param {Set< string >} syncedProperties - * @param {Set< string >} syncedMetaProperties * @param {string} origin * @return {void} */ @@ -37,8 +37,8 @@ export function applyPostChangesToCRDTDoc( ydoc: CRDTDoc, changes: PostChanges, record: Post, + postType: Type, syncedProperties: Set< string >, - syncedMetaProperties: Set< string >, origin: string ): void { const ymap = ydoc.getMap( DOCUMENT_MAP_KEY ); @@ -101,7 +101,9 @@ export function applyPostChangesToCRDTDoc( // is a synced meta property). Object.entries( newValue ?? {} ).forEach( ( [ metaKey, metaValue ] ) => { - if ( ! syncedMetaProperties.has( metaKey ) ) { + if ( + ! shouldSyncMetaForPostType( metaKey, postType ) + ) { return; } @@ -174,15 +176,15 @@ export function applyPostChangesToCRDTDoc( * * @param {CRDTDoc} ydoc * @param {Post} record + * @param {Type} postType * @param {Set< string >} syncedProperties - * @param {Set< string >} syncedMetaProperties * @return {Partial} The changes that should be applied to the local record. */ export function getPostChangesFromCRDTDoc( ydoc: CRDTDoc, record: Post, - syncedProperties: Set< string >, - syncedMetaProperties: Set< string > + postType: Type, + syncedProperties: Set< string > ): PostChanges { const ymap = ydoc.getMap( DOCUMENT_MAP_KEY ); @@ -222,7 +224,7 @@ export function getPostChangesFromCRDTDoc( const allowedMeta = Object.fromEntries( Object.entries( newValue ?? {} ).filter( ( [ metaKey ] ) => - syncedMetaProperties.has( metaKey ) + shouldSyncMetaForPostType( metaKey, postType ) ) ); @@ -365,19 +367,25 @@ export function getSyncedPropertiesForPostType( return syncedProperties; } +const metaDecisionCache: Map< string, Map< string, boolean > > = new Map(); + /** - * Given a post type definition, return the set of meta properties that should - * be synced for that post type. + * Given a meta key and post type definition, return a decision on whether to + * sync the meta property. * - * @param {Type} postType The post type definition. - * @return {Set} The set of meta properties that should be synced. + * @param {string} metaKey The meta key. + * @param {Type} postType The post type definition. + * @return {boolean} Whether to sync the meta property. */ -export function getSyncedMetaPropertiesForPostType( - postType: Type -): Set< string > { - // Return empty set if the post type does not support custom fields. - if ( ! postType.supports?.[ 'custom-fields' ] ) { - return new Set(); +function shouldSyncMetaForPostType( metaKey: string, postType: Type ): boolean { + if ( ! metaDecisionCache.has( postType.slug ) ) { + metaDecisionCache.set( postType.slug, new Map() ); + } + + const decisionMap = metaDecisionCache.get( postType.slug )!; + + if ( decisionMap.has( metaKey ) ) { + return decisionMap.get( metaKey )!; } /** @@ -385,19 +393,27 @@ export function getSyncedMetaPropertiesForPostType( * registered against the post type and made available via the REST API * (`'show_in_rest' => true`). * - * Pass an Array instead of Set as a more familiar data structure. + * Of the registered meta properties, by default we do not sync "hidden" meta + * fields (leading underscore in the meta key). This filter allows third-party + * code to override that behavior. * - * @param {string[]} syncedMetaProperties List of meta properties to sync. - * @param {string} postTypeSlug The post type slug. - * @param {Type} postType The post type definition. - * @return {string[]} The filtered list of meta properties to sync. + * @param {boolean} shouldSync Whether to sync the meta property. + * @param {string} metaKey Meta key. + * @param {string} postTypeSlug The post type slug. + * @param {Type} postType The post type definition. + * @return {boolean} The filtered list of meta properties to sync. */ - const syncedMetaProperties: string[] = applyFilters( - 'sync.metaProperties', - [ 'footnotes' ], - postType.slug, - postType - ) as string[]; - - return new Set( syncedMetaProperties ); + const shouldSync = Boolean( + applyFilters( + 'sync.shouldSyncMeta', + ! metaKey.startsWith( '_' ), + metaKey, + postType.slug, + postType + ) + ); + + decisionMap.set( metaKey, shouldSync ); + + return shouldSync; } From b6ef1c4cd1fec83aa9e12705076f037bcab09a7f Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Mon, 15 Sep 2025 13:07:13 -0600 Subject: [PATCH 77/99] Return original blocks when controlledBlocks represent post content in a template for UUID-based awareness features to work --- .../src/components/provider/use-block-sync.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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 3cc2b21b141e67..4f01efa7e3bef2 100644 --- a/packages/block-editor/src/components/provider/use-block-sync.js +++ b/packages/block-editor/src/components/provider/use-block-sync.js @@ -104,15 +104,23 @@ export default function useBlockSync( { // and so it would already be persisted. __unstableMarkNextChangeAsNotPersistent(); if ( clientId ) { + const blockName = getBlockName( clientId ); + const isPostContentBlock = blockName === 'core/post-content'; + // It is important to batch here because otherwise, // as soon as `setHasControlledInnerBlocks` is called // the effect to restore might be triggered // before the actual blocks get set properly in state. registry.batch( () => { setHasControlledInnerBlocks( clientId, true ); - const storeBlocks = controlledBlocks.map( ( block ) => - cloneBlock( block ) - ); + + // For post-content block children, preserve the + // original blocks to maintain UUIDs used for + // multi-user collaboration + const storeBlocks = isPostContentBlock + ? controlledBlocks + : controlledBlocks.map( ( block ) => cloneBlock( block ) ); + if ( subscribedRef.current ) { pendingChangesRef.current.incoming = storeBlocks; } From 447b3519936c345a7b7e617467c71fad7e2f5743 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Tue, 16 Sep 2025 10:07:21 -0600 Subject: [PATCH 78/99] Expand comment about cloneBlock() workaround in useBlockSync() --- packages/block-editor/src/components/provider/use-block-sync.js | 2 ++ 1 file changed, 2 insertions(+) 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 4f01efa7e3bef2..04269735229e29 100644 --- a/packages/block-editor/src/components/provider/use-block-sync.js +++ b/packages/block-editor/src/components/provider/use-block-sync.js @@ -117,6 +117,8 @@ export default function useBlockSync( { // For post-content block children, preserve the // original blocks to maintain UUIDs used for // multi-user collaboration + // + // Unsure: Why are these blocks being cloned? Do they need to be? const storeBlocks = isPostContentBlock ? controlledBlocks : controlledBlocks.map( ( block ) => cloneBlock( block ) ); From 9ee8279d7ed404b3331620291183cabcd2df9a85 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 17 Sep 2025 17:51:40 -0600 Subject: [PATCH 79/99] Pass merged record to createEntityMeta --- packages/core-data/src/actions.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 8c6651978e5709..ce72fcff507aee 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -682,8 +682,7 @@ export const saveEntityRecord = ...edits.meta, ...( await getSyncProvider().createEntityMeta( entityConfig.syncConfig, - persistedRecord, - edits + { ...persistedRecord, ...edits } ) ), }; } From 9f843a6339cdb5c8ab67de2fcd1e5058fe0bb319 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 17 Sep 2025 17:55:27 -0600 Subject: [PATCH 80/99] Organize sync constants and move CRDT version to state map --- packages/core-data/src/utils/crdt-blocks.ts | 2 +- packages/sync/src/config.ts | 15 +++++++++++++ packages/sync/src/provider.ts | 25 ++++++++++++--------- packages/sync/src/utils.ts | 19 +++++++++------- 4 files changed, 41 insertions(+), 20 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index aaf8cb6ddf4711..5683ab38ebde09 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -184,7 +184,7 @@ function createNewYBlock( block: Block ): YBlock { * * @param yblocks The blocks in the local Y.Doc. * @param incomingBlocks Gutenberg blocks being synced. - * @param _origin The origin of the sync, either 'syncProvider.getInitialCRDTDoc' or 'gutenberg'. + * @param _origin The origin of the sync, either 'syncProvider' or 'gutenberg'. */ export function mergeCrdtBlocks( yblocks: Y.Array< YBlock >, // yblocks represent the blocks in the local Y.Doc diff --git a/packages/sync/src/config.ts b/packages/sync/src/config.ts index 2290c0756263fb..abf66bd9fa6903 100644 --- a/packages/sync/src/config.ts +++ b/packages/sync/src/config.ts @@ -2,3 +2,18 @@ // to Yjs doc schema or in how it is interpreted by code in the SyncConfig. This // allows implementors to invalidate persisted CRDT docs, if any. export const CRDT_DOC_VERSION = 1; + +// Map keys in the root Yjs document. +export const CRDT_RECORD_MAP_KEY = 'document'; +export const CRDT_STATE_MAP_KEY = 'state'; + +// Sub-keys. +export const CRDT_STATE_PERSISTED_AT_KEY = 'persistedAt'; + +// Origin strings. +export const LOCAL_EDITOR_ORIGIN = 'gutenberg'; +export const LOCAL_SYNC_PROVIDER_ORIGIN = 'syncProvider'; +export const LOCAL_ORIGINS = [ + LOCAL_EDITOR_ORIGIN, + LOCAL_SYNC_PROVIDER_ORIGIN, +]; diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index e05fa97f2127dc..dfec0fe9b4902b 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -6,7 +6,13 @@ import * as Y from 'yjs'; /** * Internal dependencies */ -import { CRDT_DOC_VERSION } from './config'; +import { + CRDT_DOC_VERSION, + CRDT_STATE_MAP_KEY, + CRDT_STATE_PERSISTED_AT_KEY, + LOCAL_ORIGINS, + LOCAL_SYNC_PROVIDER_ORIGIN, +} from './config'; import type { ConnectDoc, ConnectDocResult, @@ -30,11 +36,6 @@ interface EntityState { ydoc: CRDTDoc; } -const CRDT_STATE_MAP_KEY = 'state'; -const CRDT_STATE_PERSISTED_AT_KEY = 'persistedAt'; - -const LOCAL_ORIGINS = [ 'gutenberg', 'syncProvider' ]; - export class SyncProvider { private connectionCreators: ConnectDoc[]; @@ -78,7 +79,7 @@ export class SyncProvider { ): Promise< void > { const objectId = syncConfig.getObjectId( record ); const objectType = syncConfig.objectType; - const ydoc = createYjsDoc( objectType ); + const ydoc = createYjsDoc( { objectType } ); const connections = await this.connect( objectId, objectType, ydoc ); const entityId = this.getEntityId( objectType, objectId ); @@ -133,7 +134,7 @@ export class SyncProvider { () => { Y.applyUpdate( ydoc, Y.encodeStateAsUpdate( initialDoc ) ); }, - 'syncProvider', + LOCAL_SYNC_PROVIDER_ORIGIN, false ); } @@ -215,13 +216,15 @@ export class SyncProvider { // IMPORTANT: We use a new Yjs document so that the initial state can be // applied to the "real" Yjs document as a singular update. Therefore, we // don't need to wrap the changes in a transaction. - const initialStateDoc = createYjsDoc( syncConfig.objectType ); + const initialStateDoc = createYjsDoc( { + objectType: syncConfig.objectType, + } ); syncConfig.applyChangesToCRDTDoc( initialStateDoc, initialData, record, - 'syncProvider.getInitialCRDTDoc' + LOCAL_SYNC_PROVIDER_ORIGIN ); return initialStateDoc; @@ -382,7 +385,7 @@ export class SyncProvider { const stateMap = ydoc.getMap( 'state' ); stateMap.set( CRDT_STATE_PERSISTED_AT_KEY, lastPersistedAt ); }, - 'syncProvider', + LOCAL_SYNC_PROVIDER_ORIGIN, true ); } diff --git a/packages/sync/src/utils.ts b/packages/sync/src/utils.ts index 5c50fea8455fef..a68330699205c9 100644 --- a/packages/sync/src/utils.ts +++ b/packages/sync/src/utils.ts @@ -6,15 +6,18 @@ import * as Y from 'yjs'; /** * Internal dependencies */ -import { CRDT_DOC_VERSION } from './config'; -import { type ObjectType } from './types'; +import { CRDT_DOC_VERSION, CRDT_STATE_MAP_KEY } from './config'; -export function createYjsDoc( objectType: ObjectType ): Y.Doc { +export function createYjsDoc( documentMeta: Record< string, unknown > ): Y.Doc { // Meta is not synced and does not get persisted with the document. - const meta = new Map< string, unknown >( [ - [ 'objectType', objectType ], - [ 'version', CRDT_DOC_VERSION ], - ] ); + const metaMap = new Map< string, unknown >( + Object.entries( documentMeta ) + ); - return new Y.Doc( { meta } ); + const ydoc = new Y.Doc( { meta: metaMap } ); + const stateMap = ydoc.getMap( CRDT_STATE_MAP_KEY ); + + stateMap.set( 'version', CRDT_DOC_VERSION ); + + return ydoc; } From 85da341ab8e4450a402f504b39a2ebbcf30d0c11 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 17 Sep 2025 18:01:38 -0600 Subject: [PATCH 81/99] Update naming of record to rawRecord for clarity --- packages/core-data/src/utils/crdt.ts | 6 +-- packages/sync/src/provider.ts | 57 +++++++++++++++------------- packages/sync/src/types.ts | 2 +- 3 files changed, 34 insertions(+), 31 deletions(-) diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index 4ddeed7e048407..f1ea59277d5813 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -27,7 +27,7 @@ const DOCUMENT_MAP_KEY = 'document'; * * @param {CRDTDoc} ydoc * @param {PostChanges} changes - * @param {Post} record + * @param {Post} rawRecord * @param {Type} postType * @param {Set< string >} syncedProperties * @param {string} origin @@ -36,7 +36,7 @@ const DOCUMENT_MAP_KEY = 'document'; export function applyPostChangesToCRDTDoc( ydoc: CRDTDoc, changes: PostChanges, - record: Post, + rawRecord: Post, postType: Type, syncedProperties: Set< string >, origin: string @@ -138,7 +138,7 @@ export function applyPostChangesToCRDTDoc( // Undefined status indicates that we want to reset to the current // persisted value. if ( undefined === newStatus ) { - newStatus = record.status; + newStatus = rawRecord.status; } mergeValue( currentValue, newStatus, setValue ); diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index dfec0fe9b4902b..d603de638aa1da 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -69,15 +69,15 @@ export class SyncProvider { * Bootstrap an entity for syncing and manage its lifecycle. * * @param {SyncConfig} syncConfig Sync configuration for the object type. - * @param {ObjectData} record Record representing this object type. + * @param {ObjectData} rawRecord Raw entity record representing this object type. * @param {RecordHandlers} handlers Handlers for updating and fetching the record. */ public async bootstrap( syncConfig: SyncConfig, - record: ObjectData, + rawRecord: ObjectData, handlers: RecordHandlers ): Promise< void > { - const objectId = syncConfig.getObjectId( record ); + const objectId = syncConfig.getObjectId( rawRecord ); const objectType = syncConfig.objectType; const ydoc = createYjsDoc( { objectType } ); const connections = await this.connect( objectId, objectType, ydoc ); @@ -122,7 +122,10 @@ export class SyncProvider { ); // Get the initial document state. - const initialDoc = await this.getInitialCRDTDoc( syncConfig, record ); + const initialDoc = await this.getInitialCRDTDoc( + syncConfig, + rawRecord + ); // Attach the update listener before applying the initial state so that // we update the entity record in the local store. @@ -188,17 +191,16 @@ export class SyncProvider { * sync providers can override this method to provide a custom initial state. * * @param {SyncConfig} syncConfig Sync configuration for the object type. - * @param {ObjectData} record Initial data to apply to the document. + * @param {ObjectData} rawRecord Initial data to apply to the document. */ private async getInitialCRDTDoc( syncConfig: SyncConfig, - record: ObjectData + rawRecord: ObjectData ): Promise< CRDTDoc > { // Load the persisted document from previous sessions. const persistedDoc = await this.getPersistedCRDTDoc( syncConfig, - record, - CRDT_DOC_VERSION + rawRecord ); // If it exists and matches the current version, apply it as the base state @@ -211,7 +213,7 @@ export class SyncProvider { } // Otherwise, use the current record. - const initialData = syncConfig.getInitialObjectData( record ); + const initialData = syncConfig.getInitialObjectData( rawRecord ); // IMPORTANT: We use a new Yjs document so that the initial state can be // applied to the "real" Yjs document as a singular update. Therefore, we @@ -223,7 +225,7 @@ export class SyncProvider { syncConfig.applyChangesToCRDTDoc( initialStateDoc, initialData, - record, + rawRecord, LOCAL_SYNC_PROVIDER_ORIGIN ); @@ -237,15 +239,13 @@ export class SyncProvider { * entity. Custom sync providers can override this method to provide their * implementation. * - * @param {SyncConfig} _syncConfig Sync configuration for the object type. - * @param {ObjectData} _record Record representing this object type. - * @param {Partial< ObjectData >} _changes Updates to make. + * @param {SyncConfig} _syncConfig Sync configuration for the object type. + * @param {ObjectData} _rawRecord Raw record representing this object type. * @return {Promise< Record< string, any > >} Entity meta. */ public async createEntityMeta( _syncConfig: SyncConfig, - _record: ObjectData, - _changes: Partial< ObjectData > + _rawRecord: ObjectData ): Promise< Record< string, any > > { return Promise.resolve( {} ); } @@ -255,15 +255,13 @@ export class SyncProvider { * Custom sync providers can override this method to provide their * implementation. * - * @param {SyncConfig} _syncConfig Sync configuration for the object type. - * @param {ObjectData} _record Record representing this object type. - * @param {number} _expectedVersion Expected version of persisted CRDT document. + * @param {SyncConfig} _syncConfig Sync configuration for the object type. + * @param {ObjectData} _rawRecord Record representing this object type. * @return {Promise< CRDTDoc | null >} The persisted CRDT document, or null if none exists. */ protected async getPersistedCRDTDoc( _syncConfig: SyncConfig, - _record: ObjectData, - _expectedVersion: number + _rawRecord: ObjectData ): Promise< CRDTDoc | null > { return Promise.resolve( null ); } @@ -283,23 +281,28 @@ export class SyncProvider { * Update CRDT document with changes from the local store. * * @param {SyncConfig} syncConfig Sync configuration for the object type. - * @param {ObjectData} record Record to load. + * @param {ObjectData} rawRecord Raw record to load. * @param {Partial< ObjectData >} changes Updates to make. * @param {string} origin The source of change. */ public updateCRDTDoc( syncConfig: SyncConfig, - record: ObjectData, + rawRecord: ObjectData, changes: Partial< ObjectData >, origin: string ): void { const objectType = syncConfig.objectType; - const objectId = syncConfig.getObjectId( record ); + const objectId = syncConfig.getObjectId( rawRecord ); const entityId = this.getEntityId( objectType, objectId ); const ydoc = this.entityStates.get( entityId )?.ydoc; ydoc?.transact( () => { - syncConfig.applyChangesToCRDTDoc( ydoc, changes, record, origin ); + syncConfig.applyChangesToCRDTDoc( + ydoc, + changes, + rawRecord, + origin + ); }, origin ); } @@ -355,13 +358,13 @@ export class SyncProvider { * used by peers as a signal that they need to refetch the persisted entity. * * @param {SyncConfig} syncConfig Sync configuration for the object type. - * @param {ObjectData} record Record representing this object type. + * @param {ObjectData} rawRecord Raw record representing this object type. */ public updateLastPersistedDate( syncConfig: SyncConfig, - record: ObjectData + rawRecord: ObjectData ): void { - const objectId = syncConfig.getObjectId( record ); + const objectId = syncConfig.getObjectId( rawRecord ); const objectType = syncConfig.objectType; const entityId = this.getEntityId( objectType, objectId ); const entityState = this.entityStates.get( entityId ); diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index abd1e36f690054..7ca11039190a5b 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -34,7 +34,7 @@ export interface SyncConfig { applyChangesToCRDTDoc: ( ydoc: Y.Doc, changes: Partial< ObjectData >, - record: ObjectData, + rawRecord: ObjectData, origin: string ) => void; getChangesFromCRDTDoc: ( ydoc: Y.Doc, record: ObjectData ) => ObjectData; From 822c927d824bd93a905bae3fcfd56bd896f98a0d Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 17 Sep 2025 17:52:46 -0600 Subject: [PATCH 82/99] Create dedicated getInitialPostObjectData alongside similar functions --- packages/core-data/src/entities.js | 20 +++++-------- packages/core-data/src/utils/crdt.ts | 43 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index c3797f67e02f2f..4597527e7a4d2f 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -8,13 +8,13 @@ import { capitalCase, pascalCase } from 'change-case'; */ import apiFetch from '@wordpress/api-fetch'; import { __ } from '@wordpress/i18n'; -import { parse } from '@wordpress/blocks'; /** * Internal dependencies */ import { applyPostChangesToCRDTDoc, + getInitialPostObjectData, getPostChangesFromCRDTDoc, getSyncedPropertiesForPostType, } from './utils/crdt'; @@ -336,18 +336,12 @@ async function loadPostTypeEntities() { * @param {import('@wordpress/sync').ObjectData} record * @return {import('@wordpress/sync').ObjectData} The initial data */ - getInitialObjectData: ( record ) => { - // Mix in the parsed blocks into the record. Only allow properties in - // the synced properties set. - const content = record.content?.raw ?? record.content ?? ''; - const blocks = parse( content ); - - return Object.fromEntries( - Object.entries( { ...record, blocks } ).filter( - ( [ key ] ) => syncedProperties.has( key ) - ) - ); - }, + getInitialObjectData: ( record ) => + getInitialPostObjectData( + record, + postType, + syncedProperties + ), /** * Get the immutable identifier for an entity record. diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index f1ea59277d5813..dcc64c70425854 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -6,6 +6,8 @@ import * as fun from 'lib0/function'; /** * WordPress dependencies */ +// @ts-ignore No types available. +import { parse } from '@wordpress/blocks'; import { applyFilters } from '@wordpress/hooks'; import { type CRDTDoc, Y } from '@wordpress/sync'; @@ -265,6 +267,47 @@ export function getPostChangesFromCRDTDoc( ); } +export function getInitialPostObjectData( + record: Post, + postType: Type, + syncedProperties: Set< string > +): PostChanges { + // Mix in the parsed blocks. + const blocks = parse( getRawValue( record.content ) ); + + return Object.fromEntries( + Object.entries( { ...record, blocks } ) + // Only allow properties in the synced properties set. + .filter( ( [ key ] ) => syncedProperties.has( key ) ) + .map( ( [ key, value ] ) => { + switch ( key ) { + case 'content': + case 'excerpt': + case 'title': { + return [ key, getRawValue( value ) ]; + } + + case 'meta': { + return [ + key, + Object.fromEntries( + Object.entries( value ?? {} ).filter( + ( [ metaKey ] ) => + shouldSyncMetaForPostType( + metaKey, + postType + ) + ) + ), + ]; + } + } + + return [ key, value ]; + } ) + ); +} + /** * Extract the raw string value from a property that may be a string or an object * with a `raw` property (`RenderedText`). From e7b62cf3f99704bf3eb2cc3012800f605ba464be Mon Sep 17 00:00:00 2001 From: chriszarate Date: Wed, 17 Sep 2025 18:00:02 -0600 Subject: [PATCH 83/99] Update treatment of initial CRDT doc using invalidated meta --- packages/sync/src/provider.ts | 105 ++++++++++++++++++++++++---------- 1 file changed, 75 insertions(+), 30 deletions(-) diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index d603de638aa1da..76885903b2985b 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -132,14 +132,48 @@ export class SyncProvider { ydoc.on( 'update', onUpdate ); // Apply the initial document to the current document as a singular update. - Y.transact( - ydoc, - () => { - Y.applyUpdate( ydoc, Y.encodeStateAsUpdate( initialDoc ) ); - }, - LOCAL_SYNC_PROVIDER_ORIGIN, - false - ); + if ( initialDoc ) { + Y.transact( + ydoc, + () => { + Y.applyUpdate( ydoc, Y.encodeStateAsUpdate( initialDoc ) ); + }, + LOCAL_SYNC_PROVIDER_ORIGIN, + false + ); + } + + if ( ! initialDoc || true === initialDoc?.meta?.get( 'invalidated' ) ) { + Y.transact( + ydoc, + () => { + syncConfig.applyChangesToCRDTDoc( + ydoc, + syncConfig.getInitialObjectData( rawRecord ), + rawRecord, + LOCAL_SYNC_PROVIDER_ORIGIN + ); + }, + LOCAL_SYNC_PROVIDER_ORIGIN, + true + ); + + // TODO: This new state should be persisted to the entity record. This + // will result in a "dirty" record, but if the user does not save the + // record, then content can be duplicated as other users join the session + // or refresh. + // + // If we have high confidence in our hash validation / invalidation, then + // we could persist the updated record automatically. Or we could use + // awareness states to let the user know that the content has changed on + // the server and prompt them to save. + handlers.editRecord( { + meta: { + ...( rawRecord.meta as object ), + ...( await this.createEntityMeta( syncConfig, rawRecord ) ), + }, + } ); + } } /** @@ -196,7 +230,7 @@ export class SyncProvider { private async getInitialCRDTDoc( syncConfig: SyncConfig, rawRecord: ObjectData - ): Promise< CRDTDoc > { + ): Promise< CRDTDoc | null > { // Load the persisted document from previous sessions. const persistedDoc = await this.getPersistedCRDTDoc( syncConfig, @@ -205,31 +239,19 @@ export class SyncProvider { // If it exists and matches the current version, apply it as the base state // of the initial document. - if ( - persistedDoc && - CRDT_DOC_VERSION === persistedDoc.meta?.get( 'version' ) - ) { - return persistedDoc; + if ( ! persistedDoc ) { + return null; } - // Otherwise, use the current record. - const initialData = syncConfig.getInitialObjectData( rawRecord ); - - // IMPORTANT: We use a new Yjs document so that the initial state can be - // applied to the "real" Yjs document as a singular update. Therefore, we - // don't need to wrap the changes in a transaction. - const initialStateDoc = createYjsDoc( { - objectType: syncConfig.objectType, - } ); + const stateMap = persistedDoc.getMap( CRDT_STATE_MAP_KEY ); - syncConfig.applyChangesToCRDTDoc( - initialStateDoc, - initialData, - rawRecord, - LOCAL_SYNC_PROVIDER_ORIGIN - ); + if ( CRDT_DOC_VERSION !== stateMap.get( 'version' ) ) { + // TODO: Implement version migration. We have not yet incremented the + // version number, so there is nothing to implement yet. + persistedDoc.meta?.set( 'invalidated', true ); + } - return initialStateDoc; + return persistedDoc; } /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -255,6 +277,29 @@ export class SyncProvider { * Custom sync providers can override this method to provide their * implementation. * + * There are 5 possible states: + * + * 1. No persisted document exists: return null. A new document will be created + * from the current entity record. + * + * 2. A persisted document exists with a different version: return it. The + * version mismatch will be detected and the document will be migrated. + * + * 3. A persisted document exists, but its content no longer matches the + * current entity record (i.e., the entity record was updated outside of + * the block editor): return it, but mark it as invalidated. The document + * will be used as the base document and the current entity record will be + * applied as an update. + * + * - Mark it as invalidated by setting `invalidated=true` on its meta map. + * + * 4. A persisted document exists, but the entity record has been restored + * from a revision. This is a special case of #3, but is handled + * identically. + * + * 5. A persisted document exists: return it. It will be used as the initial + * document. + * * @param {SyncConfig} _syncConfig Sync configuration for the object type. * @param {ObjectData} _rawRecord Record representing this object type. * @return {Promise< CRDTDoc | null >} The persisted CRDT document, or null if none exists. From 6be5b15b215652b5339bbd4129c2e62507598b35 Mon Sep 17 00:00:00 2001 From: Paul Kevan Date: Fri, 19 Sep 2025 11:10:59 +0100 Subject: [PATCH 84/99] Allow post lock dialog to return and retain functionality --- packages/editor/src/components/post-locked-modal/index.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/editor/src/components/post-locked-modal/index.js b/packages/editor/src/components/post-locked-modal/index.js index 65225a96508ac1..9fe148befe0070 100644 --- a/packages/editor/src/components/post-locked-modal/index.js +++ b/packages/editor/src/components/post-locked-modal/index.js @@ -148,6 +148,10 @@ function PostLockedModal() { return null; } + if ( window.__experimentalEnableSync ) { + return null; + } + const userDisplayName = user.name; const userAvatar = user.avatar; From f91192c9389e109434f02bdd90f00dd6fc26765a Mon Sep 17 00:00:00 2001 From: chriszarate Date: Fri, 19 Sep 2025 10:24:18 -0600 Subject: [PATCH 85/99] Supply Awareness instance to connection and observe changes --- packages/sync/src/config.ts | 8 +-- packages/sync/src/index.ts | 2 +- packages/sync/src/provider.ts | 120 +++++++++++++++++++--------------- packages/sync/src/types.ts | 4 +- packages/sync/src/utils.ts | 12 +++- 5 files changed, 84 insertions(+), 62 deletions(-) diff --git a/packages/sync/src/config.ts b/packages/sync/src/config.ts index abf66bd9fa6903..1e0681d5e9ae9e 100644 --- a/packages/sync/src/config.ts +++ b/packages/sync/src/config.ts @@ -9,11 +9,11 @@ export const CRDT_STATE_MAP_KEY = 'state'; // Sub-keys. export const CRDT_STATE_PERSISTED_AT_KEY = 'persistedAt'; +export const CRDT_STATE_PERSISTED_BY_KEY = 'persistedBy'; +export const CRDT_STATE_RESTORED_AT_KEY = 'restoredAt'; +export const CRDT_STATE_RESTORED_BY_KEY = 'restoredBy'; +export const CRDT_STATE_VERSION_KEY = 'version'; // Origin strings. export const LOCAL_EDITOR_ORIGIN = 'gutenberg'; export const LOCAL_SYNC_PROVIDER_ORIGIN = 'syncProvider'; -export const LOCAL_ORIGINS = [ - LOCAL_EDITOR_ORIGIN, - LOCAL_SYNC_PROVIDER_ORIGIN, -]; diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index 3ecb0c2307a121..8a107686d51b56 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -10,7 +10,7 @@ import { createWebRTCConnection } from './create-webrtc-connection'; import { SyncProvider } from './provider'; export * as Y from 'yjs'; -export { CRDT_DOC_VERSION } from './config'; +export * from './config'; export { connectIndexDb } from './connect-indexdb'; export { createWebRTCConnection } from './create-webrtc-connection'; export { SyncProvider } from './provider'; diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index 76885903b2985b..6d413abb10e085 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -2,15 +2,19 @@ * External dependencies */ import * as Y from 'yjs'; +import { Awareness } from 'y-protocols/awareness'; /** * Internal dependencies */ import { CRDT_DOC_VERSION, - CRDT_STATE_MAP_KEY, - CRDT_STATE_PERSISTED_AT_KEY, - LOCAL_ORIGINS, + CRDT_RECORD_MAP_KEY as RECORD_KEY, + CRDT_STATE_MAP_KEY as STATE_KEY, + CRDT_STATE_PERSISTED_AT_KEY as PERSISTED_AT_KEY, + CRDT_STATE_PERSISTED_BY_KEY as PERSISTED_BY_KEY, + CRDT_STATE_RESTORED_AT_KEY as RESTORED_AT_KEY, + CRDT_STATE_RESTORED_BY_KEY as RESTORED_BY_KEY, LOCAL_SYNC_PROVIDER_ORIGIN, } from './config'; import type { @@ -28,9 +32,10 @@ import { UndoManager } from './undo-manager'; import { createYjsDoc } from './utils'; interface EntityState { + awareness?: Awareness; discard: () => void; handlers: RecordHandlers; - lastPersistedAt: number; + objectId: ObjectID; syncConfig: SyncConfig; undoManager?: UndoManager; ydoc: CRDTDoc; @@ -53,7 +58,6 @@ export class SyncProvider { */ private undoManager: UndoManager | undefined; - protected connections: Map< EntityID, ConnectDocResult[] > = new Map(); protected entityStates: Map< EntityID, EntityState > = new Map(); /** @@ -77,49 +81,79 @@ export class SyncProvider { rawRecord: ObjectData, handlers: RecordHandlers ): Promise< void > { + const now = Date.now(); const objectId = syncConfig.getObjectId( rawRecord ); const objectType = syncConfig.objectType; const ydoc = createYjsDoc( { objectType } ); - const connections = await this.connect( objectId, objectType, ydoc ); const entityId = this.getEntityId( objectType, objectId ); + const recordMap = ydoc.getMap( RECORD_KEY ); + const stateMap = ydoc.getMap( STATE_KEY ); + // Clean up connections and in-memory state when the entity is discarded. const onDiscard = (): void => { connections.forEach( ( result ) => result.destroy() ); - ydoc.off( 'update', onUpdate ); + recordMap.unobserveDeep( onRecordUpdate ); + stateMap.unobserve( onStateUpdate ); ydoc.destroy(); - this.connections.delete( entityId ); this.entityStates.delete( entityId ); }; // When the CRDT document is updated by a connection (not a local origin like // Gutenberg or this SyncProvider), update the local store. - const onUpdate = ( _update: Uint8Array, origin: string ): void => { - if ( LOCAL_ORIGINS.includes( origin ) ) { + const onRecordUpdate = ( + _events: Y.YEvent< any >[], + transaction: Y.Transaction + ): void => { + if ( transaction.local ) { return; } void this.updateEntityRecord( objectType, objectId ); }; + const onStateUpdate = ( + event: Y.YMapEvent< unknown >, + transaction: Y.Transaction + ) => { + if ( transaction.local ) { + return; + } + + if ( ! event.keysChanged.has( PERSISTED_AT_KEY ) ) { + return; + } + + const newValue = stateMap.get( PERSISTED_AT_KEY ); + if ( 'number' === typeof newValue && newValue > now ) { + handlers.refetchPersistedRecord(); + } + }; + const entityState: EntityState = { discard: onDiscard, handlers, - lastPersistedAt: Date.now(), + objectId, syncConfig, ydoc, }; + if ( syncConfig.supports?.awareness ) { + entityState.awareness = new Awareness( ydoc ); + } + if ( syncConfig.supports?.undo ) { entityState.undoManager = new UndoManager( ydoc ); this.undoManager = entityState.undoManager; } - this.connections.set( entityId, connections ); - this.entityStates.set( - this.getEntityId( objectType, objectId ), - entityState - ); + this.entityStates.set( entityId, entityState ); + + const connections = await this.connect( entityState ); + + // Attach observers. + recordMap.observeDeep( onRecordUpdate ); + stateMap.observe( onStateUpdate ); // Get the initial document state. const initialDoc = await this.getInitialCRDTDoc( @@ -127,10 +161,6 @@ export class SyncProvider { rawRecord ); - // Attach the update listener before applying the initial state so that - // we update the entity record in the local store. - ydoc.on( 'update', onUpdate ); - // Apply the initial document to the current document as a singular update. if ( initialDoc ) { Y.transact( @@ -139,7 +169,7 @@ export class SyncProvider { Y.applyUpdate( ydoc, Y.encodeStateAsUpdate( initialDoc ) ); }, LOCAL_SYNC_PROVIDER_ORIGIN, - false + true ); } @@ -153,6 +183,9 @@ export class SyncProvider { rawRecord, LOCAL_SYNC_PROVIDER_ORIGIN ); + + stateMap.set( RESTORED_AT_KEY, Date.now() ); + stateMap.set( RESTORED_BY_KEY, ydoc.clientID ); }, LOCAL_SYNC_PROVIDER_ORIGIN, true @@ -179,18 +212,19 @@ export class SyncProvider { /** * Establish connections for the given entity and its Yjs document. * - * @param {ObjectID} objectId Object ID to connect. - * @param {ObjectType} objectType Object type to connect. - * @param {CRDTDoc} ydoc Yjs document for the object. + * @param {EntityState} entityState State for the entity. */ private async connect( - objectId: ObjectID, - objectType: ObjectType, - ydoc: CRDTDoc + entityState: EntityState ): Promise< ConnectDocResult[] > { return await Promise.all( this.connectionCreators?.map( ( create ) => - create( objectId, objectType, ydoc ) + create( + entityState.objectId, + entityState.syncConfig.objectType, + entityState.ydoc, + entityState.awareness + ) ) ); } @@ -243,7 +277,7 @@ export class SyncProvider { return null; } - const stateMap = persistedDoc.getMap( CRDT_STATE_MAP_KEY ); + const stateMap = persistedDoc.getMap( STATE_KEY ); if ( CRDT_DOC_VERSION !== stateMap.get( 'version' ) ) { // TODO: Implement version migration. We have not yet incremented the @@ -369,7 +403,7 @@ export class SyncProvider { return; } - const { handlers, lastPersistedAt, syncConfig, ydoc } = entityState; + const { handlers, syncConfig, ydoc } = entityState; const currentRecord = await handlers.getEditedRecord(); @@ -382,20 +416,6 @@ export class SyncProvider { // in an update to the store if the blocks have changed. handlers.editRecord( changes ); - - // Determine if we should refetch the persisted entity record from the - // REST API because another client has persisted changes. - const ystateMap = ydoc.getMap( CRDT_STATE_MAP_KEY ); - const persistedAt = - ( ystateMap.get( CRDT_STATE_PERSISTED_AT_KEY ) as number ) ?? 0; - if ( persistedAt > lastPersistedAt ) { - this.entityStates.set( entityId, { - ...entityState, - lastPersistedAt: persistedAt, - } ); - - void handlers.refetchPersistedRecord(); - } } /** @@ -419,19 +439,13 @@ export class SyncProvider { } const ydoc = entityState.ydoc; - const lastPersistedAt = Date.now(); - - // Update in-memory state. - this.entityStates.set( entityId, { - ...entityState, - lastPersistedAt, - } ); Y.transact( ydoc, () => { - const stateMap = ydoc.getMap( 'state' ); - stateMap.set( CRDT_STATE_PERSISTED_AT_KEY, lastPersistedAt ); + const stateMap = ydoc.getMap( STATE_KEY ); + stateMap.set( PERSISTED_AT_KEY, Date.now() ); + stateMap.set( PERSISTED_BY_KEY, ydoc.clientID ); }, LOCAL_SYNC_PROVIDER_ORIGIN, true diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 7ca11039190a5b..2691d0fb87a4d5 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -14,14 +14,14 @@ export type ObjectType = string; export interface ObjectData extends Record< string, unknown > {} export interface ConnectDocResult { - awareness?: Awareness; destroy: () => void; } export type ConnectDoc = ( id: ObjectID, type: ObjectType, - ydoc: Y.Doc + ydoc: Y.Doc, + awareness?: Awareness ) => Promise< ConnectDocResult >; export interface RecordHandlers { diff --git a/packages/sync/src/utils.ts b/packages/sync/src/utils.ts index a68330699205c9..1ffe33bed894d5 100644 --- a/packages/sync/src/utils.ts +++ b/packages/sync/src/utils.ts @@ -6,7 +6,13 @@ import * as Y from 'yjs'; /** * Internal dependencies */ -import { CRDT_DOC_VERSION, CRDT_STATE_MAP_KEY } from './config'; +import { + CRDT_DOC_VERSION, + CRDT_STATE_MAP_KEY, + CRDT_STATE_PERSISTED_AT_KEY, + CRDT_STATE_RESTORED_AT_KEY, + CRDT_STATE_VERSION_KEY, +} from './config'; export function createYjsDoc( documentMeta: Record< string, unknown > ): Y.Doc { // Meta is not synced and does not get persisted with the document. @@ -17,7 +23,9 @@ export function createYjsDoc( documentMeta: Record< string, unknown > ): Y.Doc { const ydoc = new Y.Doc( { meta: metaMap } ); const stateMap = ydoc.getMap( CRDT_STATE_MAP_KEY ); - stateMap.set( 'version', CRDT_DOC_VERSION ); + stateMap.set( CRDT_STATE_PERSISTED_AT_KEY, 0 ); + stateMap.set( CRDT_STATE_RESTORED_AT_KEY, 0 ); + stateMap.set( CRDT_STATE_VERSION_KEY, CRDT_DOC_VERSION ); return ydoc; } From a7ae761313cec04ba2e22efe93842f554f989805 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 22 Sep 2025 09:04:15 -0600 Subject: [PATCH 86/99] Move from Y.transact to ydoc.transact --- packages/sync/src/provider.ts | 53 +++++++++++++---------------------- 1 file changed, 19 insertions(+), 34 deletions(-) diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index 6d413abb10e085..bce1964f6f5cdb 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -163,33 +163,23 @@ export class SyncProvider { // Apply the initial document to the current document as a singular update. if ( initialDoc ) { - Y.transact( - ydoc, - () => { - Y.applyUpdate( ydoc, Y.encodeStateAsUpdate( initialDoc ) ); - }, - LOCAL_SYNC_PROVIDER_ORIGIN, - true - ); + ydoc.transact( () => { + Y.applyUpdate( ydoc, Y.encodeStateAsUpdate( initialDoc ) ); + }, LOCAL_SYNC_PROVIDER_ORIGIN ); } if ( ! initialDoc || true === initialDoc?.meta?.get( 'invalidated' ) ) { - Y.transact( - ydoc, - () => { - syncConfig.applyChangesToCRDTDoc( - ydoc, - syncConfig.getInitialObjectData( rawRecord ), - rawRecord, - LOCAL_SYNC_PROVIDER_ORIGIN - ); - - stateMap.set( RESTORED_AT_KEY, Date.now() ); - stateMap.set( RESTORED_BY_KEY, ydoc.clientID ); - }, - LOCAL_SYNC_PROVIDER_ORIGIN, - true - ); + ydoc.transact( () => { + syncConfig.applyChangesToCRDTDoc( + ydoc, + syncConfig.getInitialObjectData( rawRecord ), + rawRecord, + LOCAL_SYNC_PROVIDER_ORIGIN + ); + + stateMap.set( RESTORED_AT_KEY, Date.now() ); + stateMap.set( RESTORED_BY_KEY, ydoc.clientID ); + }, LOCAL_SYNC_PROVIDER_ORIGIN ); // TODO: This new state should be persisted to the entity record. This // will result in a "dirty" record, but if the user does not save the @@ -440,15 +430,10 @@ export class SyncProvider { const ydoc = entityState.ydoc; - Y.transact( - ydoc, - () => { - const stateMap = ydoc.getMap( STATE_KEY ); - stateMap.set( PERSISTED_AT_KEY, Date.now() ); - stateMap.set( PERSISTED_BY_KEY, ydoc.clientID ); - }, - LOCAL_SYNC_PROVIDER_ORIGIN, - true - ); + ydoc.transact( () => { + const stateMap = ydoc.getMap( STATE_KEY ); + stateMap.set( PERSISTED_AT_KEY, Date.now() ); + stateMap.set( PERSISTED_BY_KEY, ydoc.clientID ); + }, LOCAL_SYNC_PROVIDER_ORIGIN ); } } From ee07ba8a642938d34624220c53e08d09b2656441 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 22 Sep 2025 09:04:41 -0600 Subject: [PATCH 87/99] Don't dispatch edits if they are empty --- packages/core-data/src/resolvers.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index c76f1b82b25c49..8e00ed3bc75343 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -160,6 +160,10 @@ export const getEntityRecord = { // Handle edits sourced from the sync provider. editRecord: ( edits ) => { + if ( ! Object.keys( edits ).length ) { + return; + } + dispatch( { type: 'EDIT_ENTITY_RECORD', kind, From d8dd989c38ced03a05f814fed2fedbf4fc34ba29 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 22 Sep 2025 09:05:22 -0600 Subject: [PATCH 88/99] Use map key constant --- packages/core-data/src/utils/crdt.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index dcc64c70425854..417e33d7c1c376 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -9,7 +9,7 @@ import * as fun from 'lib0/function'; // @ts-ignore No types available. import { parse } from '@wordpress/blocks'; import { applyFilters } from '@wordpress/hooks'; -import { type CRDTDoc, Y } from '@wordpress/sync'; +import { type CRDTDoc, CRDT_RECORD_MAP_KEY, Y } from '@wordpress/sync'; /** * Internal dependencies @@ -20,9 +20,6 @@ import { type Type } from '../entity-types'; type PostChanges = Partial< Post > & { blocks?: Block[] }; -// Key used to store the document map in the Y.Doc. -const DOCUMENT_MAP_KEY = 'document'; - /** * Given a set of local changes to a post record, apply those changes to the * local Y.Doc. @@ -43,7 +40,7 @@ export function applyPostChangesToCRDTDoc( syncedProperties: Set< string >, origin: string ): void { - const ymap = ydoc.getMap( DOCUMENT_MAP_KEY ); + const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); Object.entries( changes ).forEach( ( [ key, newValue ] ) => { if ( ! syncedProperties.has( key ) ) { @@ -188,7 +185,7 @@ export function getPostChangesFromCRDTDoc( postType: Type, syncedProperties: Set< string > ): PostChanges { - const ymap = ydoc.getMap( DOCUMENT_MAP_KEY ); + const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); return Object.fromEntries( Object.entries( ymap.toJSON() ).filter( ( [ key, newValue ] ) => { From d2fff347b32f9fd1aac16280e96961b87ea73d06 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 22 Sep 2025 09:52:30 -0600 Subject: [PATCH 89/99] Allow edits from local Y.UndoManager --- packages/sync/src/provider.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index bce1964f6f5cdb..84b55339c64e04 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -99,13 +99,16 @@ export class SyncProvider { this.entityStates.delete( entityId ); }; - // When the CRDT document is updated by a connection (not a local origin like - // Gutenberg or this SyncProvider), update the local store. + // When the CRDT document is updated by the UndoManager or a connection (not + // a local origin), update the local store. const onRecordUpdate = ( _events: Y.YEvent< any >[], transaction: Y.Transaction ): void => { - if ( transaction.local ) { + if ( + transaction.local && + ! ( transaction.origin instanceof Y.UndoManager ) + ) { return; } From 4b5536e131ec3d3ee4df68838a1154cd84d77e7b Mon Sep 17 00:00:00 2001 From: chriszarate Date: Mon, 22 Sep 2025 18:41:45 -0600 Subject: [PATCH 90/99] Only mark document as restored if there is an initial document --- packages/sync/src/provider.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts index 84b55339c64e04..01cdd9dac9ea65 100644 --- a/packages/sync/src/provider.ts +++ b/packages/sync/src/provider.ts @@ -180,8 +180,11 @@ export class SyncProvider { LOCAL_SYNC_PROVIDER_ORIGIN ); - stateMap.set( RESTORED_AT_KEY, Date.now() ); - stateMap.set( RESTORED_BY_KEY, ydoc.clientID ); + // Only mark as restored if we loaded an initial document. + if ( initialDoc ) { + stateMap.set( RESTORED_AT_KEY, Date.now() ); + stateMap.set( RESTORED_BY_KEY, ydoc.clientID ); + } }, LOCAL_SYNC_PROVIDER_ORIGIN ); // TODO: This new state should be persisted to the entity record. This From adbfa3f39fac2376bad13a19161619a14789f35b Mon Sep 17 00:00:00 2001 From: Paul Kevan <2290623+pkevan@users.noreply.github.com> Date: Tue, 23 Sep 2025 08:34:28 +0100 Subject: [PATCH 91/99] add todo --- packages/editor/src/components/post-locked-modal/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/editor/src/components/post-locked-modal/index.js b/packages/editor/src/components/post-locked-modal/index.js index 9fe148befe0070..6ae4d39a1a430c 100644 --- a/packages/editor/src/components/post-locked-modal/index.js +++ b/packages/editor/src/components/post-locked-modal/index.js @@ -148,6 +148,7 @@ function PostLockedModal() { return null; } + // Potentially refactor this into the above shortcircuit (!isLocked). if ( window.__experimentalEnableSync ) { return null; } From c4da4d72eb4a411a389e82abd8c5669580fffd82 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Wed, 24 Sep 2025 11:35:20 -0600 Subject: [PATCH 92/99] Add YTextAdapter with diff logging --- packages/rich-text/package.json | 4 +- .../event-listeners/input-and-selection.js | 1 + packages/rich-text/src/component/index.js | 25 ++++++ packages/rich-text/src/y-text-adapter.ts | 88 +++++++++++++++++++ packages/rich-text/tsconfig.json | 3 +- 5 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 packages/rich-text/src/y-text-adapter.ts diff --git a/packages/rich-text/package.json b/packages/rich-text/package.json index 5d8f87b6bdb3f7..0613cf6712eadf 100644 --- a/packages/rich-text/package.json +++ b/packages/rich-text/package.json @@ -41,8 +41,10 @@ "@wordpress/escape-html": "file:../escape-html", "@wordpress/i18n": "file:../i18n", "@wordpress/keycodes": "file:../keycodes", + "@wordpress/sync": "file:../sync", "colord": "2.9.3", - "memize": "^2.1.0" + "memize": "^2.1.0", + "quill-delta": "5.1.0" }, "peerDependencies": { "react": "^18.0.0" diff --git a/packages/rich-text/src/component/event-listeners/input-and-selection.js b/packages/rich-text/src/component/event-listeners/input-and-selection.js index 621f1c59fab04e..0b35fb6be95bb9 100644 --- a/packages/rich-text/src/component/event-listeners/input-and-selection.js +++ b/packages/rich-text/src/component/event-listeners/input-and-selection.js @@ -56,6 +56,7 @@ export default ( props ) => ( element ) => { let isComposing = false; + // alecg: onInput() is called when the user types into a block. function onInput( event ) { // Do not trigger a change if characters are being composed. Browsers // will usually emit a final `input` event when the characters are diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index e17a4704d8a370..f80f8d4dc05f8f 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -14,6 +14,10 @@ import { toHTMLString } from '../to-html-string'; import { useDefaultStyle } from './use-default-style'; import { useBoundaryStyle } from './use-boundary-style'; import { useEventListeners } from './event-listeners'; +import { YTextAdapter } from '../y-text-adapter'; + +// Local feature flag to enable Y.Text integration +const USE_YTEXT_ADAPTER = true; export function useRichText( { value = '', @@ -60,9 +64,21 @@ export function useRichText( { } // Internal values are updated synchronously, unlike props and state. + + // alecg: _valueRef is the external value for the prop. It is converted + // from the internal recordRef RichTextData format to the string or + // RichTextData object that props expect. const _valueRef = useRef( value ); + + // alecg: recordRef is a cached value for the RichTextData state. + // The reference is overwritten on load and anytime onInput() receives + // a new keystroke and recreates it from the DOM. const recordRef = useRef(); + // alecg: Where we store the Y.Text instance. + const yTextAdapterRef = useRef(); + + // alecg: setRecordFromProps() is called on post load, or when a new block is created. function setRecordFromProps() { _valueRef.current = value; recordRef.current = value; @@ -88,6 +104,8 @@ export function useRichText( { } recordRef.current.start = selectionStart; recordRef.current.end = selectionEnd; + + yTextAdapterRef.current = new YTextAdapter( recordRef.current ); } const hadSelectionUpdateRef = useRef( false ); @@ -116,6 +134,11 @@ export function useRichText( { */ function handleChange( newRecord ) { recordRef.current = newRecord; + yTextAdapterRef.current.handleChange( newRecord ); + + // alecg: This ensures that the DOM matches the newRecord value. + // newRecord is derived from the DOM state in onInput(), but formatting + // changes still need to be applied to the DOM. applyRecord( newRecord ); if ( disableFormats ) { @@ -126,6 +149,7 @@ export function useRichText( { : newRecord.formats; newRecord = { ...newRecord, formats: newFormats }; if ( typeof value === 'string' ) { + // alecg: Set the external facing valueRef to match the expected prop type. _valueRef.current = toHTMLString( { value: newRecord, preserveWhiteSpace, @@ -142,6 +166,7 @@ export function useRichText( { // We batch both calls to only attempt to rerender once. registry.batch( () => { onSelectionChange( start, end ); + // alecg: Pass the external valueRef to onChange listeners to update props. onChange( _valueRef.current, { __unstableFormats: formats, __unstableText: text, diff --git a/packages/rich-text/src/y-text-adapter.ts b/packages/rich-text/src/y-text-adapter.ts new file mode 100644 index 00000000000000..8e5106305c0b61 --- /dev/null +++ b/packages/rich-text/src/y-text-adapter.ts @@ -0,0 +1,88 @@ +/** + * External dependencies + */ +/** + * WordPress dependencies + */ +import { Y } from '@wordpress/sync'; + +/** + * Internal dependencies + */ +import type { RichTextValue } from './types'; +import { create } from './create'; +import { toHTMLString } from './to-html-string'; +import Delta from 'quill-delta'; + +const Y_MAP_KEY = 'text-map'; +const Y_TEXT_KEY = 'text'; +const TEMPORARY_Y_TEXT_KEY = 'text-update'; + +/** + * YTextAdapter class that encapsulates Y.Text operations and provides + * a bridge between Y.Text and RichTextValue formats. + */ +export class YTextAdapter { + private yDoc: Y.Doc; + private yTextMap: Y.Map< Y.Text >; + private yText: Y.Text; + + /** + * Create a new YTextAdapter instance. + * @param initialValue + */ + constructor( initialValue?: RichTextValue ) { + // Create a new Y.Doc for the adapter. A root Y.Doc is needed to + // use the Y.Text API. + this.yDoc = new Y.Doc(); + + // Create a map we can use to store Y.Text instances. + this.yTextMap = this.yDoc.getMap( Y_MAP_KEY ); + + if ( initialValue ) { + const richTextHtml = toHTMLString( { value: initialValue } ); + this.yText = new Y.Text( richTextHtml ); + } else { + this.yText = new Y.Text(); + } + + this.yTextMap.set( Y_TEXT_KEY, this.yText ); + } + + /** + * Get the current RichTextValue representation. + */ + getRichTextValue(): RichTextValue { + const yValue = this.yText.toJSON(); + const richTextValue = create( { html: yValue } ); + return richTextValue; + } + + /** + * Handle changes from the RichText component and apply them to Y.Text. + * @param newRecord The new RichTextValue to apply + */ + handleChange( newRecord: RichTextValue ): void { + // Y.Text must be attached to a Y.Doc to be able to do operations on it. + // Create a temporary Y.Text attached to the local Y.Doc for delta computation. + const newValue = toHTMLString( { value: newRecord } ); + const newYText = this.yTextMap.set( + TEMPORARY_Y_TEXT_KEY, + new Y.Text( newValue ) + ); + + const currentValueAsDelta = new Delta( this.yText.toDelta() ); + const updatedValueAsDelta = new Delta( newYText.toDelta() ); + + // TODO: We can pass in the pre-change cursor position as a hint to diff(), but + // newRecord's start and end are the after-change position. + // See if we can keep a copy of the prior selection position and use it here. + const deltaDiff = currentValueAsDelta.diff( updatedValueAsDelta ); + + this.yText.applyDelta( deltaDiff.ops ); + console.log( 'yText after change:', this.yText.toJSON() ); + + // Clean up the temporary Y.Text instance. + this.yTextMap.delete( TEMPORARY_Y_TEXT_KEY ); + } +} diff --git a/packages/rich-text/tsconfig.json b/packages/rich-text/tsconfig.json index 5dadcb0ed0045c..1e8c6ec0ef2228 100644 --- a/packages/rich-text/tsconfig.json +++ b/packages/rich-text/tsconfig.json @@ -13,6 +13,7 @@ { "path": "../element" }, { "path": "../escape-html" }, { "path": "../i18n" }, - { "path": "../keycodes" } + { "path": "../keycodes" }, + { "path": "../sync" } ] } From 7488ed4189b028b078a72e3fea1eb27b133e7286 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Mon, 29 Sep 2025 13:52:02 -0600 Subject: [PATCH 93/99] Manage text diffs in mergeCrdtBlocks(), remove YTextAdapter --- package-lock.json | 33 ++++++++++-- packages/core-data/package.json | 1 + packages/core-data/src/utils/crdt-blocks.ts | 56 ++++++++++++++++++--- packages/rich-text/package.json | 3 +- packages/rich-text/src/component/index.js | 11 ---- 5 files changed, 79 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3e4e2ab3e23fe0..e37e16d84deaae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24832,9 +24832,10 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-diff": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", - "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==" + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "license": "Apache-2.0" }, "node_modules/fast-fifo": { "version": "1.3.0", @@ -32230,8 +32231,7 @@ "node_modules/lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", - "dev": true + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" }, "node_modules/lodash.debounce": { "version": "4.0.8", @@ -32259,6 +32259,13 @@ "resolved": "https://registry.npmjs.org/lodash.invokemap/-/lodash.invokemap-4.6.0.tgz", "integrity": "sha512-CfkycNtMqgUlfjfdh2BhKO/ZXrP8ePOX5lEU/g0R3ItJcnuxWDwokMGKx1hWcfOikmyOVx6X9IwWnDGlgKl61w==" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.ismatch": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", @@ -39479,6 +39486,20 @@ "node": ">=8" } }, + "node_modules/quill-delta": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", + "license": "MIT", + "dependencies": { + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -50310,6 +50331,7 @@ "fast-deep-equal": "^3.1.3", "lib0": "^0.2.99", "memize": "^2.1.0", + "quill-delta": "5.1.0", "uuid": "^9.0.1" }, "engines": { @@ -51898,6 +51920,7 @@ "@wordpress/escape-html": "file:../escape-html", "@wordpress/i18n": "file:../i18n", "@wordpress/keycodes": "file:../keycodes", + "@wordpress/sync": "file:../sync", "colord": "2.9.3", "memize": "^2.1.0" }, diff --git a/packages/core-data/package.json b/packages/core-data/package.json index 078e66de8fc011..794db818a4bc0c 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -55,6 +55,7 @@ "fast-deep-equal": "^3.1.3", "lib0": "^0.2.99", "memize": "^2.1.0", + "quill-delta": "5.1.0", "uuid": "^9.0.1" }, "peerDependencies": { diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 5683ab38ebde09..d852c21d46ab2c 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -4,6 +4,7 @@ import { v4 as uuidv4 } from 'uuid'; import * as math from 'lib0/math'; import * as fun from 'lib0/function'; +import Delta from 'quill-delta'; /** * WordPress dependencies @@ -287,14 +288,31 @@ export function mergeCrdtBlocks( return; } - currentAttributes.set( - attributeName, - createNewYAttributeValue( - block.name, - attributeName, - attributeValue - ) + const isRichText = isRichTextAttribute( + block.name, + attributeName ); + + if ( isRichText ) { + // Rich text values are stored as persistent Y.Text instances. + // Update the value with a delta in place instead of overwriting it + // with createNewYAttributeValue(). + const blockYText = currentAttributes.get( + attributeName + ) as Y.Text; + + const updatedValue = attributeValue as string; + mergeRichTextUpdate( blockYText, updatedValue ); + } else { + currentAttributes.set( + attributeName, + createNewYAttributeValue( + block.name, + attributeName, + attributeValue + ) + ); + } } ); @@ -423,3 +441,27 @@ function isRichTextAttribute( cachedRichTextAttributes.get( blockName )?.has( attributeName ) ?? false ); } + +function mergeRichTextUpdate( blockYText: Y.Text, updatedValue: string ): void { + const doc = blockYText.doc; + + if ( ! doc ) { + throw new Error( 'mergeCrdtBlocks: Y.Text is not attached to a Y.Doc' ); + } + + // Y.Text must be attached to a Y.Doc to be able to do operations on it. + // Create a temporary Y.Text attached to a local Y.Doc for delta computation. + const updatedYText = doc.getText( 'temporary-text' ); + updatedYText.delete( 0, updatedYText.length ); + updatedYText.insert( 0, updatedValue ); + + const currentValueAsDelta = new Delta( blockYText.toDelta() ); + const updatedValueAsDelta = new Delta( updatedYText.toDelta() ); + + // TODO: We can pass in the pre-change cursor position as a hint to diff(), but + // newRecord's start and end are the after-change position. + // See if we can keep a copy of the prior selection position and use it here. + const deltaDiff = currentValueAsDelta.diff( updatedValueAsDelta ); + + blockYText.applyDelta( deltaDiff.ops ); +} diff --git a/packages/rich-text/package.json b/packages/rich-text/package.json index 0613cf6712eadf..77870766a50acc 100644 --- a/packages/rich-text/package.json +++ b/packages/rich-text/package.json @@ -43,8 +43,7 @@ "@wordpress/keycodes": "file:../keycodes", "@wordpress/sync": "file:../sync", "colord": "2.9.3", - "memize": "^2.1.0", - "quill-delta": "5.1.0" + "memize": "^2.1.0" }, "peerDependencies": { "react": "^18.0.0" diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index f80f8d4dc05f8f..e09c36150f5829 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -14,10 +14,6 @@ import { toHTMLString } from '../to-html-string'; import { useDefaultStyle } from './use-default-style'; import { useBoundaryStyle } from './use-boundary-style'; import { useEventListeners } from './event-listeners'; -import { YTextAdapter } from '../y-text-adapter'; - -// Local feature flag to enable Y.Text integration -const USE_YTEXT_ADAPTER = true; export function useRichText( { value = '', @@ -75,10 +71,6 @@ export function useRichText( { // a new keystroke and recreates it from the DOM. const recordRef = useRef(); - // alecg: Where we store the Y.Text instance. - const yTextAdapterRef = useRef(); - - // alecg: setRecordFromProps() is called on post load, or when a new block is created. function setRecordFromProps() { _valueRef.current = value; recordRef.current = value; @@ -104,8 +96,6 @@ export function useRichText( { } recordRef.current.start = selectionStart; recordRef.current.end = selectionEnd; - - yTextAdapterRef.current = new YTextAdapter( recordRef.current ); } const hadSelectionUpdateRef = useRef( false ); @@ -134,7 +124,6 @@ export function useRichText( { */ function handleChange( newRecord ) { recordRef.current = newRecord; - yTextAdapterRef.current.handleChange( newRecord ); // alecg: This ensures that the DOM matches the newRecord value. // newRecord is derived from the DOM state in onInput(), but formatting From 6993d5f799cb6e5a10819f7d4c42c4d4c45ce51c Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Mon, 29 Sep 2025 14:46:03 -0600 Subject: [PATCH 94/99] Remove YTextAdapter file and no longer relevant comments in useRichText --- packages/rich-text/package.json | 1 - .../event-listeners/input-and-selection.js | 1 - packages/rich-text/src/component/index.js | 13 --- packages/rich-text/src/y-text-adapter.ts | 88 ------------------- 4 files changed, 103 deletions(-) delete mode 100644 packages/rich-text/src/y-text-adapter.ts diff --git a/packages/rich-text/package.json b/packages/rich-text/package.json index 77870766a50acc..5d8f87b6bdb3f7 100644 --- a/packages/rich-text/package.json +++ b/packages/rich-text/package.json @@ -41,7 +41,6 @@ "@wordpress/escape-html": "file:../escape-html", "@wordpress/i18n": "file:../i18n", "@wordpress/keycodes": "file:../keycodes", - "@wordpress/sync": "file:../sync", "colord": "2.9.3", "memize": "^2.1.0" }, diff --git a/packages/rich-text/src/component/event-listeners/input-and-selection.js b/packages/rich-text/src/component/event-listeners/input-and-selection.js index 0b35fb6be95bb9..621f1c59fab04e 100644 --- a/packages/rich-text/src/component/event-listeners/input-and-selection.js +++ b/packages/rich-text/src/component/event-listeners/input-and-selection.js @@ -56,7 +56,6 @@ export default ( props ) => ( element ) => { let isComposing = false; - // alecg: onInput() is called when the user types into a block. function onInput( event ) { // Do not trigger a change if characters are being composed. Browsers // will usually emit a final `input` event when the characters are diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index e09c36150f5829..e281c197a60b24 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -60,15 +60,7 @@ export function useRichText( { } // Internal values are updated synchronously, unlike props and state. - - // alecg: _valueRef is the external value for the prop. It is converted - // from the internal recordRef RichTextData format to the string or - // RichTextData object that props expect. const _valueRef = useRef( value ); - - // alecg: recordRef is a cached value for the RichTextData state. - // The reference is overwritten on load and anytime onInput() receives - // a new keystroke and recreates it from the DOM. const recordRef = useRef(); function setRecordFromProps() { @@ -125,9 +117,6 @@ export function useRichText( { function handleChange( newRecord ) { recordRef.current = newRecord; - // alecg: This ensures that the DOM matches the newRecord value. - // newRecord is derived from the DOM state in onInput(), but formatting - // changes still need to be applied to the DOM. applyRecord( newRecord ); if ( disableFormats ) { @@ -138,7 +127,6 @@ export function useRichText( { : newRecord.formats; newRecord = { ...newRecord, formats: newFormats }; if ( typeof value === 'string' ) { - // alecg: Set the external facing valueRef to match the expected prop type. _valueRef.current = toHTMLString( { value: newRecord, preserveWhiteSpace, @@ -155,7 +143,6 @@ export function useRichText( { // We batch both calls to only attempt to rerender once. registry.batch( () => { onSelectionChange( start, end ); - // alecg: Pass the external valueRef to onChange listeners to update props. onChange( _valueRef.current, { __unstableFormats: formats, __unstableText: text, diff --git a/packages/rich-text/src/y-text-adapter.ts b/packages/rich-text/src/y-text-adapter.ts deleted file mode 100644 index 8e5106305c0b61..00000000000000 --- a/packages/rich-text/src/y-text-adapter.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * External dependencies - */ -/** - * WordPress dependencies - */ -import { Y } from '@wordpress/sync'; - -/** - * Internal dependencies - */ -import type { RichTextValue } from './types'; -import { create } from './create'; -import { toHTMLString } from './to-html-string'; -import Delta from 'quill-delta'; - -const Y_MAP_KEY = 'text-map'; -const Y_TEXT_KEY = 'text'; -const TEMPORARY_Y_TEXT_KEY = 'text-update'; - -/** - * YTextAdapter class that encapsulates Y.Text operations and provides - * a bridge between Y.Text and RichTextValue formats. - */ -export class YTextAdapter { - private yDoc: Y.Doc; - private yTextMap: Y.Map< Y.Text >; - private yText: Y.Text; - - /** - * Create a new YTextAdapter instance. - * @param initialValue - */ - constructor( initialValue?: RichTextValue ) { - // Create a new Y.Doc for the adapter. A root Y.Doc is needed to - // use the Y.Text API. - this.yDoc = new Y.Doc(); - - // Create a map we can use to store Y.Text instances. - this.yTextMap = this.yDoc.getMap( Y_MAP_KEY ); - - if ( initialValue ) { - const richTextHtml = toHTMLString( { value: initialValue } ); - this.yText = new Y.Text( richTextHtml ); - } else { - this.yText = new Y.Text(); - } - - this.yTextMap.set( Y_TEXT_KEY, this.yText ); - } - - /** - * Get the current RichTextValue representation. - */ - getRichTextValue(): RichTextValue { - const yValue = this.yText.toJSON(); - const richTextValue = create( { html: yValue } ); - return richTextValue; - } - - /** - * Handle changes from the RichText component and apply them to Y.Text. - * @param newRecord The new RichTextValue to apply - */ - handleChange( newRecord: RichTextValue ): void { - // Y.Text must be attached to a Y.Doc to be able to do operations on it. - // Create a temporary Y.Text attached to the local Y.Doc for delta computation. - const newValue = toHTMLString( { value: newRecord } ); - const newYText = this.yTextMap.set( - TEMPORARY_Y_TEXT_KEY, - new Y.Text( newValue ) - ); - - const currentValueAsDelta = new Delta( this.yText.toDelta() ); - const updatedValueAsDelta = new Delta( newYText.toDelta() ); - - // TODO: We can pass in the pre-change cursor position as a hint to diff(), but - // newRecord's start and end are the after-change position. - // See if we can keep a copy of the prior selection position and use it here. - const deltaDiff = currentValueAsDelta.diff( updatedValueAsDelta ); - - this.yText.applyDelta( deltaDiff.ops ); - console.log( 'yText after change:', this.yText.toJSON() ); - - // Clean up the temporary Y.Text instance. - this.yTextMap.delete( TEMPORARY_Y_TEXT_KEY ); - } -} From ddee7085c7a8f8c61b2e9ffab0af63cce2a6abd5 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Mon, 29 Sep 2025 14:46:56 -0600 Subject: [PATCH 95/99] Remove additional unused rich-text component changes --- packages/rich-text/src/component/index.js | 1 - packages/rich-text/tsconfig.json | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js index e281c197a60b24..e17a4704d8a370 100644 --- a/packages/rich-text/src/component/index.js +++ b/packages/rich-text/src/component/index.js @@ -116,7 +116,6 @@ export function useRichText( { */ function handleChange( newRecord ) { recordRef.current = newRecord; - applyRecord( newRecord ); if ( disableFormats ) { diff --git a/packages/rich-text/tsconfig.json b/packages/rich-text/tsconfig.json index 1e8c6ec0ef2228..5dadcb0ed0045c 100644 --- a/packages/rich-text/tsconfig.json +++ b/packages/rich-text/tsconfig.json @@ -13,7 +13,6 @@ { "path": "../element" }, { "path": "../escape-html" }, { "path": "../i18n" }, - { "path": "../keycodes" }, - { "path": "../sync" } + { "path": "../keycodes" } ] } From 65842cd1eb099f60e1b7d8ca603668f3fd4a9796 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Thu, 2 Oct 2025 13:32:20 -0600 Subject: [PATCH 96/99] Pass last selection as hint to mergeRichTextUpdate(), use local doc for delta computation --- packages/core-data/src/utils/crdt-blocks.ts | 63 ++++++++++++++++----- packages/core-data/src/utils/crdt.ts | 22 ++++++- 2 files changed, 69 insertions(+), 16 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index d852c21d46ab2c..9de1413e33ad4b 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -14,6 +14,10 @@ import { Y } from '@wordpress/sync'; // @ts-expect-error - This is a TypeScript file, and @wordpress/blocks doesn't have a tsconfig.json? import { getBlockTypes } from '@wordpress/blocks'; +/** + * Internal dependencies + */ +import type { WPBlockSelection } from '../../../editor/build-types/store/selectors'; interface BlockAttributes { [ key: string ]: unknown; @@ -185,11 +189,13 @@ function createNewYBlock( block: Block ): YBlock { * * @param yblocks The blocks in the local Y.Doc. * @param incomingBlocks Gutenberg blocks being synced. + * @param lastSelection * @param _origin The origin of the sync, either 'syncProvider' or 'gutenberg'. */ export function mergeCrdtBlocks( yblocks: Y.Array< YBlock >, // yblocks represent the blocks in the local Y.Doc incomingBlocks: Block[], // incomingBlocks represent JSON blocks being synced, either from a peer or from the local editor + lastSelection: WPBlockSelection | null, // Last cursor position, used for hinting the diff algorithm _origin: string // eslint-disable-line @typescript-eslint/no-unused-vars ): void { // Ensure we are working with serializable block data. @@ -295,14 +301,17 @@ export function mergeCrdtBlocks( if ( isRichText ) { // Rich text values are stored as persistent Y.Text instances. - // Update the value with a delta in place instead of overwriting it - // with createNewYAttributeValue(). + // Update the value with a delta in place. const blockYText = currentAttributes.get( attributeName ) as Y.Text; const updatedValue = attributeValue as string; - mergeRichTextUpdate( blockYText, updatedValue ); + mergeRichTextUpdate( + blockYText, + updatedValue, + lastSelection + ); } else { currentAttributes.set( attributeName, @@ -331,7 +340,12 @@ export function mergeCrdtBlocks( case 'innerBlocks': { // Recursively merge innerBlocks const yInnerBlocks = yblock.get( key ) as Y.Array< YBlock >; - mergeCrdtBlocks( yInnerBlocks, value ?? [], _origin ); + mergeCrdtBlocks( + yInnerBlocks, + value ?? [], + lastSelection, + _origin + ); break; } @@ -442,26 +456,45 @@ function isRichTextAttribute( ); } -function mergeRichTextUpdate( blockYText: Y.Text, updatedValue: string ): void { +let localDoc: Y.Doc | null = null; +let localYText: Y.Text | null = null; + +/** + * Given a Y.Text object and an updated string value, diff the new value and + * apply the delta to the Y.Text. + * + * @param blockYText The Y.Text to update. + * @param updatedValue The updated value. + * @param lastSelection The last cursor position before this update, used to hint the diff algorithm. + */ +function mergeRichTextUpdate( + blockYText: Y.Text, + updatedValue: string, + lastSelection: WPBlockSelection | null +): void { const doc = blockYText.doc; if ( ! doc ) { throw new Error( 'mergeCrdtBlocks: Y.Text is not attached to a Y.Doc' ); } - // Y.Text must be attached to a Y.Doc to be able to do operations on it. - // Create a temporary Y.Text attached to a local Y.Doc for delta computation. - const updatedYText = doc.getText( 'temporary-text' ); - updatedYText.delete( 0, updatedYText.length ); - updatedYText.insert( 0, updatedValue ); + if ( ! localDoc || ! localYText ) { + // Y.Text must be attached to a Y.Doc to be able to do operations on it. + // Create a temporary Y.Text attached to a local Y.Doc for delta computation. + localDoc = new Y.Doc(); + localYText = localDoc.getText( 'temporary-text' ); + } + + localYText.delete( 0, localYText.length ); + localYText.insert( 0, updatedValue ); const currentValueAsDelta = new Delta( blockYText.toDelta() ); - const updatedValueAsDelta = new Delta( updatedYText.toDelta() ); + const updatedValueAsDelta = new Delta( localYText.toDelta() ); - // TODO: We can pass in the pre-change cursor position as a hint to diff(), but - // newRecord's start and end are the after-change position. - // See if we can keep a copy of the prior selection position and use it here. - const deltaDiff = currentValueAsDelta.diff( updatedValueAsDelta ); + const deltaDiff = currentValueAsDelta.diff( + updatedValueAsDelta, + lastSelection?.offset + ); blockYText.applyDelta( deltaDiff.ops ); } diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index 417e33d7c1c376..1ce3dd071035c5 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -17,9 +17,12 @@ import { type CRDTDoc, CRDT_RECORD_MAP_KEY, Y } from '@wordpress/sync'; import { mergeCrdtBlocks, type Block, type YBlock } from './crdt-blocks'; import { type Post } from '../entity-types/post'; import { type Type } from '../entity-types'; +import type { WPBlockSelection } from '../../../editor/build-types/store/selectors'; type PostChanges = Partial< Post > & { blocks?: Block[] }; +let lastSelection: WPBlockSelection | null = null; + /** * Given a set of local changes to a post record, apply those changes to the * local Y.Doc. @@ -72,7 +75,12 @@ export function applyPostChangesToCRDTDoc( // Merge blocks does not need `setValue` because it is operating on a // Yjs type that is already in the Y.Doc. - mergeCrdtBlocks( currentBlocks, newBlocks, origin ); + mergeCrdtBlocks( + currentBlocks, + newBlocks, + lastSelection, + origin + ); break; } @@ -166,6 +174,18 @@ export function applyPostChangesToCRDTDoc( } } } ); + + // Update the lastSelection for CRDT use + if ( 'selection' in changes ) { + const selection = changes?.selection as + | { + selectionStart: WPBlockSelection; + selectionEnd: WPBlockSelection; + } + | undefined; + + lastSelection = selection?.selectionStart ?? null; + } } /** From bcb5105d877510ea6ff1e52a8292ed11f23f5638 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Tue, 7 Oct 2025 10:27:49 -0600 Subject: [PATCH 97/99] Use internal WPBlockSelection type declaration to avoid interpackage dependency --- packages/core-data/src/types.ts | 7 +++++++ packages/core-data/src/utils/crdt-blocks.ts | 2 +- packages/core-data/src/utils/crdt.ts | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/core-data/src/types.ts b/packages/core-data/src/types.ts index b1664d8ab8dbf7..3d31920aa3bee6 100644 --- a/packages/core-data/src/types.ts +++ b/packages/core-data/src/types.ts @@ -1,3 +1,10 @@ export interface AnyFunction { ( ...args: any[] ): any; } + +// Avoid a circular dependency with @wordpress/editor +export type WPBlockSelection = { + clientId: string; + attributeKey: string; + offset: number; +}; diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 3b9f48cf58e814..df917fe9df20f9 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -18,7 +18,7 @@ import { getBlockTypes } from '@wordpress/blocks'; /** * Internal dependencies */ -import type { WPBlockSelection } from '../../../editor/build-types/store/selectors'; +import type { WPBlockSelection } from '../types'; interface BlockAttributes { [ key: string ]: unknown; diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index 1ce3dd071035c5..0a5964e8247a2a 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -17,7 +17,7 @@ import { type CRDTDoc, CRDT_RECORD_MAP_KEY, Y } from '@wordpress/sync'; import { mergeCrdtBlocks, type Block, type YBlock } from './crdt-blocks'; import { type Post } from '../entity-types/post'; import { type Type } from '../entity-types'; -import type { WPBlockSelection } from '../../../editor/build-types/store/selectors'; +import type { WPBlockSelection } from '../types'; type PostChanges = Partial< Post > & { blocks?: Block[] }; From 3b6dbef4c490d897ff61122dc5a98dd88ba9c4b1 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 7 Oct 2025 11:18:07 -0600 Subject: [PATCH 98/99] Improve types --- packages/core-data/src/types.ts | 9 +++++++-- packages/core-data/src/utils/crdt-blocks.ts | 12 +++++++----- packages/core-data/src/utils/crdt.ts | 16 ++++++---------- 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/core-data/src/types.ts b/packages/core-data/src/types.ts index 3d31920aa3bee6..dac976505a8f2d 100644 --- a/packages/core-data/src/types.ts +++ b/packages/core-data/src/types.ts @@ -3,8 +3,13 @@ export interface AnyFunction { } // Avoid a circular dependency with @wordpress/editor -export type WPBlockSelection = { +export interface WPBlockSelection { clientId: string; attributeKey: string; offset: number; -}; +} + +export interface WPSelection { + selectionEnd: WPBlockSelection; + selectionStart: WPBlockSelection; +} diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index df917fe9df20f9..3e447facf2ce42 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -144,8 +144,8 @@ function createNewYAttributeValue( ): Y.Text | unknown { const isRichText = isRichTextAttribute( blockName, attributeName ); - if ( isRichText && 'string' === typeof attributeValue ) { - return new Y.Text( attributeValue ); + if ( isRichText ) { + return new Y.Text( attributeValue?.toString() ?? '' ); } return attributeValue; @@ -300,17 +300,19 @@ export function mergeCrdtBlocks( attributeName ); - if ( isRichText ) { + if ( + isRichText && + 'string' === typeof attributeValue + ) { // Rich text values are stored as persistent Y.Text instances. // Update the value with a delta in place. const blockYText = currentAttributes.get( attributeName ) as Y.Text; - const updatedValue = attributeValue as string; mergeRichTextUpdate( blockYText, - updatedValue, + attributeValue, lastSelection ); } else { diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index 0a5964e8247a2a..fc7b35b2082251 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -17,9 +17,12 @@ import { type CRDTDoc, CRDT_RECORD_MAP_KEY, Y } from '@wordpress/sync'; import { mergeCrdtBlocks, type Block, type YBlock } from './crdt-blocks'; import { type Post } from '../entity-types/post'; import { type Type } from '../entity-types'; -import type { WPBlockSelection } from '../types'; +import type { WPBlockSelection, WPSelection } from '../types'; -type PostChanges = Partial< Post > & { blocks?: Block[] }; +type PostChanges = Partial< Post > & { + blocks?: Block[]; + selection?: WPSelection; +}; let lastSelection: WPBlockSelection | null = null; @@ -177,14 +180,7 @@ export function applyPostChangesToCRDTDoc( // Update the lastSelection for CRDT use if ( 'selection' in changes ) { - const selection = changes?.selection as - | { - selectionStart: WPBlockSelection; - selectionEnd: WPBlockSelection; - } - | undefined; - - lastSelection = selection?.selectionStart ?? null; + lastSelection = changes.selection?.selectionStart ?? null; } } From dc8fc4a3a37825b06648bd3ef48f91c18fff310a Mon Sep 17 00:00:00 2001 From: chriszarate Date: Tue, 7 Oct 2025 11:19:32 -0600 Subject: [PATCH 99/99] Simplify local ydoc code --- packages/core-data/src/utils/crdt-blocks.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 3e447facf2ce42..5551d0454dd870 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -460,7 +460,6 @@ function isRichTextAttribute( } let localDoc: Y.Doc | null = null; -let localYText: Y.Text | null = null; /** * Given a Y.Text object and an updated string value, diff the new value and @@ -481,13 +480,13 @@ function mergeRichTextUpdate( throw new Error( 'mergeCrdtBlocks: Y.Text is not attached to a Y.Doc' ); } - if ( ! localDoc || ! localYText ) { + if ( ! localDoc ) { // Y.Text must be attached to a Y.Doc to be able to do operations on it. // Create a temporary Y.Text attached to a local Y.Doc for delta computation. localDoc = new Y.Doc(); - localYText = localDoc.getText( 'temporary-text' ); } + const localYText = localDoc.getText( 'temporary-text' ); localYText.delete( 0, localYText.length ); localYText.insert( 0, updatedValue );