diff --git a/backport-changelog/6.8/8063.md b/backport-changelog/6.8/8063.md deleted file mode 100644 index 10cc8c2a2f8c67..00000000000000 --- a/backport-changelog/6.8/8063.md +++ /dev/null @@ -1,3 +0,0 @@ -https://github.com/WordPress/wordpress-develop/pull/8063 - -* https://github.com/WordPress/gutenberg/pull/67125 diff --git a/changelog.txt b/changelog.txt index 0a8c65407beef3..bc30b4952af1b7 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,466 @@ == Changelog == += 21.7.0-rc.2 = + +## Changelog + +### Enhancements + +#### Block Commenting + +- Make buttons in dialogs translatable and clear. ([71639](https://github.com/WordPress/gutenberg/pull/71639)) +- Use rendered comment content. ([71638](https://github.com/WordPress/gutenberg/pull/71638)) +- Improve error handling for core data actions. ([71695](https://github.com/WordPress/gutenberg/pull/71695)) +- Use TextareaAutosize for comment field. ([71424](https://github.com/WordPress/gutenberg/pull/71424)) +- Add comment indicators in the block toolbar. ([71271](https://github.com/WordPress/gutenberg/pull/71271)) +- Add the ability to reopen resolved comments. ([71250](https://github.com/WordPress/gutenberg/pull/71250)) +- Enable inline comments on published post. ([71295](https://github.com/WordPress/gutenberg/pull/71295)) + +#### Command Palette + +- Move custom CSS command to `core-commands` from `edit-site` package. ([71565](https://github.com/WordPress/gutenberg/pull/71565)) +- Register menu navigation based on Core Menu API. ([71476](https://github.com/WordPress/gutenberg/pull/71476)) + +#### DataViews +- Add color field type and control to DataViews. ([71522](https://github.com/WordPress/gutenberg/pull/71522)) +- Add data picker functionality. ([70971](https://github.com/WordPress/gutenberg/pull/70971)) +- Add password field. ([71545](https://github.com/WordPress/gutenberg/pull/71545)) +- DataForm radio control: Support validation and description. ([71664](https://github.com/WordPress/gutenberg/pull/71664)) +- DataForm select control: Support validation. ([71665](https://github.com/WordPress/gutenberg/pull/71665)) +- DataForm toggleGroup control: Support validation. ([71666](https://github.com/WordPress/gutenberg/pull/71666)) +- DataForm: Add a textarea control available for use with the text field type. ([71495](https://github.com/WordPress/gutenberg/pull/71495)) +- DataForm: Add summary field support for composed fields. ([71614](https://github.com/WordPress/gutenberg/pull/71614)) +- DataForm: Switch boolean field types from toggle to checkbox UI. ([71505](https://github.com/WordPress/gutenberg/pull/71505)) +- Dataform: Add new URL field type and field control. ([71518](https://github.com/WordPress/gutenberg/pull/71518)) +- Dataform: Add new `telephone` field type and field control. ([71498](https://github.com/WordPress/gutenberg/pull/71498)) +- Dataforms: Add icons to email and telephone controls. ([71514](https://github.com/WordPress/gutenberg/pull/71514)) +- Dataforms: Add object configuration support for Edit property with prefix/suffix options. ([71582](https://github.com/WordPress/gutenberg/pull/71582)) +- Implement `groupBy` for list layout. ([71548](https://github.com/WordPress/gutenberg/pull/71548)) +- Update datetime control to use calendar UI. ([71509](https://github.com/WordPress/gutenberg/pull/71509)) +- DataForm: Row Layout. ([71124](https://github.com/WordPress/gutenberg/pull/71124)) + +#### Block Library +- Navigation Link: Change 'Add page' to 'Create page' button text. ([71487](https://github.com/WordPress/gutenberg/pull/71487)) +- Navigation Link: Show 'Create page' button in Write Mode while hiding 'Add block'. ([71489](https://github.com/WordPress/gutenberg/pull/71489)) +- Navigation Link: Surface page creation functionality clearly in the LinkUI. ([71188](https://github.com/WordPress/gutenberg/pull/71188)) +- Normalize the Navigation block appender behavior between canvas and list view contexts. ([71163](https://github.com/WordPress/gutenberg/pull/71163)) +- Table of Contents: Update toolbar controls. ([71587](https://github.com/WordPress/gutenberg/pull/71587)) +- Terms Query: Combine Order and Order By control into single dropdown + remove order by slug. ([71656](https://github.com/WordPress/gutenberg/pull/71656)) + +#### Write mode +- Add Navigation Add Page Button. ([71192](https://github.com/WordPress/gutenberg/pull/71192)) +- Hide Post Date toolbar when child of Query in contentOnly mode. ([71707](https://github.com/WordPress/gutenberg/pull/71707)) +- Try adding a `contentRole` block support to content container blocks. ([71539](https://github.com/WordPress/gutenberg/pull/71539)) + +#### Block bindings +- Add `block_bindings_supported_attributes` filter. ([71663](https://github.com/WordPress/gutenberg/pull/71663)) +- Add fallback for new features introduced in Core. ([71389](https://github.com/WordPress/gutenberg/pull/71389)) +- Image block: Save `
` if `caption` attr is bound. ([71483](https://github.com/WordPress/gutenberg/pull/71483)) + +#### Block Editor +- Localize labels in getVisualTypeName() for link control search items. ([71503](https://github.com/WordPress/gutenberg/pull/71503)) +- Update conditions for displaying the empty block inserter. ([70897](https://github.com/WordPress/gutenberg/pull/70897)) + +#### Global Styles +- Site Editor: Always show Additional CSS button. ([71537](https://github.com/WordPress/gutenberg/pull/71537)) + +#### Components +- Add support for ValidatedFormTokenField. ([71350](https://github.com/WordPress/gutenberg/pull/71350)) + +#### Command Palette +- Make navigation commands available on all screens. ([71335](https://github.com/WordPress/gutenberg/pull/71335)) + +#### Interactivity API +- Add fetchpriority=low support to script modules. ([70173](https://github.com/WordPress/gutenberg/pull/70173)) + +#### Block Variations +- Have `getActiveBlockVariation` fall back to default variation. ([63858](https://github.com/WordPress/gutenberg/pull/63858)) + +#### Core Data + +- Core Data: Remove conditions for selector resolutions. ([71575](https://github.com/WordPress/gutenberg/pull/71575)) + +### New APIs + +- Core Commands: Introduce new useCommands hook. ([71603](https://github.com/WordPress/gutenberg/pull/71603)) + + +### Bug Fixes + +- Block Comments: Prevent commenting on empty blocks. ([71497](https://github.com/WordPress/gutenberg/pull/71497)) +- Core Data: Don't return partial data when selecting a complete item. ([71474](https://github.com/WordPress/gutenberg/pull/71474)) +- Core Data: Fix early return check for the record field-level resolutions. ([71541](https://github.com/WordPress/gutenberg/pull/71541)) +- Core Data: Fix field conditions in 'getRevision' selector. ([71629](https://github.com/WordPress/gutenberg/pull/71629)) +- Fix PHP 8.5 array offset deprecation warning. ([71654](https://github.com/WordPress/gutenberg/pull/71654)) +- Fix block comment to display correct time. ([71652](https://github.com/WordPress/gutenberg/pull/71652)) +- Fix: Disable block commenting when postId is not number. ([71643](https://github.com/WordPress/gutenberg/pull/71643)) +- Fix: Hide resolved comments in unpinned mode. ([71409](https://github.com/WordPress/gutenberg/pull/71409)) +- Fix: Unable to pin comment sidebar after unpinning. ([71386](https://github.com/WordPress/gutenberg/pull/71386)) +- i18n: Show "1 more reply" instead of "1 more replies" in block comments. ([71685](https://github.com/WordPress/gutenberg/pull/71685)) + +#### DataViews +- DataForms regular layout: Remove label style overrides as they cause inconsistent results. ([71574](https://github.com/WordPress/gutenberg/pull/71574)) +- DataForms regular layout: Use BaseControl visual label for readonly fields when in top labelPosition. ([71597](https://github.com/WordPress/gutenberg/pull/71597)) +- DataViews grid layout: Rounded corners for media. ([71543](https://github.com/WordPress/gutenberg/pull/71543)) +- Field API: Add `setValue`. ([71604](https://github.com/WordPress/gutenberg/pull/71604)) + +#### Write mode +- Add contentRole to Query block and make sure Change design always works as expected. ([71686](https://github.com/WordPress/gutenberg/pull/71686)) +- Hide Spacer resizable box in write mode. ([71671](https://github.com/WordPress/gutenberg/pull/71671)) +- Make sure "add page" button label only appears once. ([71710](https://github.com/WordPress/gutenberg/pull/71710)) + +#### Block Editor +- Fix popover scroll hook’s simultaneous scrolling. ([71468](https://github.com/WordPress/gutenberg/pull/71468)) +- Fix stale insertion point reference. ([71559](https://github.com/WordPress/gutenberg/pull/71559)) +- useClipboardHandler: Prevent file paste for users without media upload permissions. ([71607](https://github.com/WordPress/gutenberg/pull/71607)) + +#### Components +- Fix popover not calling onClose on unmount. ([71252](https://github.com/WordPress/gutenberg/pull/71252)) +- Modal headings and labels missing editor text color. ([71311](https://github.com/WordPress/gutenberg/pull/71311)) +- Validated form controls: Fix Sass var handling for red color. ([71391](https://github.com/WordPress/gutenberg/pull/71391)) + +#### Global Styles +- BorderRadius Presets: Fix Generating wrong variable names in pattern code. ([71631](https://github.com/WordPress/gutenberg/pull/71631)) +- Search: Border radius presets work correctly. ([71481](https://github.com/WordPress/gutenberg/pull/71481)) + +#### Block Library +- Accordion: Add BlockGap support to content & panel. ([71461](https://github.com/WordPress/gutenberg/pull/71461)) +- Increase taxonomy limit for Terms List dropdown. ([71569](https://github.com/WordPress/gutenberg/pull/71569)) + +#### Block bindings +- Fix back-compat layer. ([71691](https://github.com/WordPress/gutenberg/pull/71691)) + +#### Block Transforms +- Block Editor: Fix BlockSwitcher to prevent transforms on reusable blocks. ([71661](https://github.com/WordPress/gutenberg/pull/71661)) + +#### Patterns +- Content Only Pattern experiment: Restore purple block icon color for synced patterns. ([71655](https://github.com/WordPress/gutenberg/pull/71655)) + +#### Post Editor +- Fix: Hide "View Post" link for non-viewable post types. ([71356](https://github.com/WordPress/gutenberg/pull/71356)) + +#### Block API +- Allow block attributes strings to terminate in \ character. ([71291](https://github.com/WordPress/gutenberg/pull/71291)) + +#### Block Bindings + +- Block Bindings: Fix pattern override bug, editing was allowed on non enabled overrides blocks. ([71813])(https://github.com/WordPress/gutenberg/pull/71813)) + +### Accessibility + +- Block Comments: Make "n more replies" text clickable for accessibility. ([71387](https://github.com/WordPress/gutenberg/pull/71387)) +- feat: Clarify label & add help text with link. ([70451](https://github.com/WordPress/gutenberg/pull/70451)) + +#### DataViews +- Custom `empty` elements are no longer wrapped in `

` tags to improve accessibility. ([71561](https://github.com/WordPress/gutenberg/pull/71561)) + + +### Performance + +- Core Data: Always try to resolve entity permissions. ([71532](https://github.com/WordPress/gutenberg/pull/71532)) + +#### Post Editor +- Editor: Try displaying intermediate results for hierarchical terms. ([71402](https://github.com/WordPress/gutenberg/pull/71402)) + + +### Experiments + +#### Patterns +- Make unsynced patterns content only by default. ([71512](https://github.com/WordPress/gutenberg/pull/71512)) +- contentOnly Patterns experiment: Add Edit Contents button to block inspector and show 'Detach' block action. ([71653](https://github.com/WordPress/gutenberg/pull/71653)) + +#### Block Library +- Add Terms Query block. ([70720](https://github.com/WordPress/gutenberg/pull/70720)) +- Pattern content only experiment: Make template parts section blocks. ([71627](https://github.com/WordPress/gutenberg/pull/71627)) + + +### Documentation + +- ControlWithError: Add documentation about cloned `children`. ([71392](https://github.com/WordPress/gutenberg/pull/71392)) +- DataForm: Remove default storybook example. ([71609](https://github.com/WordPress/gutenberg/pull/71609)) +- Docs: Fix broken links in Gutenberg release checklist. ([71611](https://github.com/WordPress/gutenberg/pull/71611)) +- Docs: Split "Gutenberg Release Process" page into two. ([71130](https://github.com/WordPress/gutenberg/pull/71130)) + + +### Code Quality + +- Block Commenting: Prevent unnecessary API requests when post ID is not integer. ([71713](https://github.com/WordPress/gutenberg/pull/71713)) +- CODEOWNERS: Remove some block code owners. ([71657](https://github.com/WordPress/gutenberg/pull/71657)) +- Export `UseEntityRecordsWithPermissionsType`. ([71003](https://github.com/WordPress/gutenberg/pull/71003)) +- Migrate element package to TS. ([70886](https://github.com/WordPress/gutenberg/pull/70886)) +- [core-data]: Fix TS types for user object. ([68045](https://github.com/WordPress/gutenberg/pull/68045)) +- i18n: Remove unnecessary JS file. ([71549](https://github.com/WordPress/gutenberg/pull/71549)) + +#### Block Library +- Clean up LinkUI duplication for Add block and Create page flows. ([71499](https://github.com/WordPress/gutenberg/pull/71499)) +- Gallery Block: Add end-to-end test for image randomization. ([71538](https://github.com/WordPress/gutenberg/pull/71538)) +- Refactor Link UI controls with better props. ([71515](https://github.com/WordPress/gutenberg/pull/71515)) +- Refactor: Content only template locking block editing modes to reducer. ([67606](https://github.com/WordPress/gutenberg/pull/67606)) + +#### Block bindings +- Date block: Move `block_bindings_supported_attributes` filter to compat layer. ([71662](https://github.com/WordPress/gutenberg/pull/71662)) + +#### DataViews +- Remove leftover code for Pages. ([71534](https://github.com/WordPress/gutenberg/pull/71534)) + + +### Tools + +- Update CODEOWNERS to remove ndiego. ([71591](https://github.com/WordPress/gutenberg/pull/71591)) + +#### Testing +- Automated testing: Add taxonomy pagination tests. ([71584](https://github.com/WordPress/gutenberg/pull/71584)) +- Fix comments in block editing mode tests. ([71690](https://github.com/WordPress/gutenberg/pull/71690)) +- contentOnly patterns experiment: Add reducer tests. ([71688](https://github.com/WordPress/gutenberg/pull/71688)) + + +## First-time contributors + +The following PRs were merged by first-time contributors: + +- @coderGtm: feat: Clarify label & add help text with link. ([70451](https://github.com/WordPress/gutenberg/pull/70451)) +- @straku: Field API: Add `setValue`. ([71604](https://github.com/WordPress/gutenberg/pull/71604)) +- @xavier-lc: Export `UseEntityRecordsWithPermissionsType`. ([71003](https://github.com/WordPress/gutenberg/pull/71003)) + + +## Contributors + +The following contributors merged PRs in this release: + +@adamsilverstein @Adi-ty @andrewserong @coderGtm @coreyworrell @dhruvikpatel18 @elazzabi @ellatrix @getdave @gigitux @heavyweight @jeryj @jorgefilipecosta @karthick-murugan @kmanijak @kushagra-goyal-14 @Mamaduka @manzoorwanijk @mikachan @mirka @ndiego @ntsekouras @oandregal @ockham @p-jackson @priethor @R1shabh-Gupta @shimotmk @shrivastavanolo @sirreal @stokesman @straku @swissspidy @t-hamano @talldan @tellthemachines @USERSATOSHI @westonruter @xavier-lc @yashjawale + + += 21.7.0-rc.1 = + + +## Changelog + +### Enhancements + +#### Block Commenting + +- Make buttons in dialogs translatable and clear. ([71639](https://github.com/WordPress/gutenberg/pull/71639)) +- Use rendered comment content. ([71638](https://github.com/WordPress/gutenberg/pull/71638)) +- Improve error handling for core data actions. ([71695](https://github.com/WordPress/gutenberg/pull/71695)) +- Use TextareaAutosize for comment field. ([71424](https://github.com/WordPress/gutenberg/pull/71424)) +- Add comment indicators in the block toolbar. ([71271](https://github.com/WordPress/gutenberg/pull/71271)) +- Add the ability to reopen resolved comments. ([71250](https://github.com/WordPress/gutenberg/pull/71250)) +- Enable inline comments on published post. ([71295](https://github.com/WordPress/gutenberg/pull/71295)) + +#### Command Palette + +- Move custom CSS command to `core-commands` from `edit-site` package. ([71565](https://github.com/WordPress/gutenberg/pull/71565)) +- Register menu navigation based on Core Menu API. ([71476](https://github.com/WordPress/gutenberg/pull/71476)) + +#### DataViews +- Add color field type and control to DataViews. ([71522](https://github.com/WordPress/gutenberg/pull/71522)) +- Add data picker functionality. ([70971](https://github.com/WordPress/gutenberg/pull/70971)) +- Add password field. ([71545](https://github.com/WordPress/gutenberg/pull/71545)) +- DataForm radio control: Support validation and description. ([71664](https://github.com/WordPress/gutenberg/pull/71664)) +- DataForm select control: Support validation. ([71665](https://github.com/WordPress/gutenberg/pull/71665)) +- DataForm toggleGroup control: Support validation. ([71666](https://github.com/WordPress/gutenberg/pull/71666)) +- DataForm: Add a textarea control available for use with the text field type. ([71495](https://github.com/WordPress/gutenberg/pull/71495)) +- DataForm: Add summary field support for composed fields. ([71614](https://github.com/WordPress/gutenberg/pull/71614)) +- DataForm: Switch boolean field types from toggle to checkbox UI. ([71505](https://github.com/WordPress/gutenberg/pull/71505)) +- Dataform: Add new URL field type and field control. ([71518](https://github.com/WordPress/gutenberg/pull/71518)) +- Dataform: Add new `telephone` field type and field control. ([71498](https://github.com/WordPress/gutenberg/pull/71498)) +- Dataforms: Add icons to email and telephone controls. ([71514](https://github.com/WordPress/gutenberg/pull/71514)) +- Dataforms: Add object configuration support for Edit property with prefix/suffix options. ([71582](https://github.com/WordPress/gutenberg/pull/71582)) +- Implement `groupBy` for list layout. ([71548](https://github.com/WordPress/gutenberg/pull/71548)) +- Update datetime control to use calendar UI. ([71509](https://github.com/WordPress/gutenberg/pull/71509)) +- DataForm: Row Layout. ([71124](https://github.com/WordPress/gutenberg/pull/71124)) + +#### Block Library +- Navigation Link: Change 'Add page' to 'Create page' button text. ([71487](https://github.com/WordPress/gutenberg/pull/71487)) +- Navigation Link: Show 'Create page' button in Write Mode while hiding 'Add block'. ([71489](https://github.com/WordPress/gutenberg/pull/71489)) +- Navigation Link: Surface page creation functionality clearly in the LinkUI. ([71188](https://github.com/WordPress/gutenberg/pull/71188)) +- Normalize the Navigation block appender behavior between canvas and list view contexts. ([71163](https://github.com/WordPress/gutenberg/pull/71163)) +- Table of Contents: Update toolbar controls. ([71587](https://github.com/WordPress/gutenberg/pull/71587)) +- Terms Query: Combine Order and Order By control into single dropdown + remove order by slug. ([71656](https://github.com/WordPress/gutenberg/pull/71656)) + +#### Write mode +- Add Navigation Add Page Button. ([71192](https://github.com/WordPress/gutenberg/pull/71192)) +- Hide Post Date toolbar when child of Query in contentOnly mode. ([71707](https://github.com/WordPress/gutenberg/pull/71707)) +- Try adding a `contentRole` block support to content container blocks. ([71539](https://github.com/WordPress/gutenberg/pull/71539)) + +#### Block bindings +- Add `block_bindings_supported_attributes` filter. ([71663](https://github.com/WordPress/gutenberg/pull/71663)) +- Add fallback for new features introduced in Core. ([71389](https://github.com/WordPress/gutenberg/pull/71389)) +- Image block: Save `

` if `caption` attr is bound. ([71483](https://github.com/WordPress/gutenberg/pull/71483)) + +#### Block Editor +- Localize labels in getVisualTypeName() for link control search items. ([71503](https://github.com/WordPress/gutenberg/pull/71503)) +- Update conditions for displaying the empty block inserter. ([70897](https://github.com/WordPress/gutenberg/pull/70897)) + +#### Global Styles +- Site Editor: Always show Additional CSS button. ([71537](https://github.com/WordPress/gutenberg/pull/71537)) + +#### Components +- Add support for ValidatedFormTokenField. ([71350](https://github.com/WordPress/gutenberg/pull/71350)) + +#### Command Palette +- Make navigation commands available on all screens. ([71335](https://github.com/WordPress/gutenberg/pull/71335)) + +#### Interactivity API +- Add fetchpriority=low support to script modules. ([70173](https://github.com/WordPress/gutenberg/pull/70173)) + +#### Templates API +- Allow template duplication + concept of active templates. ([67125](https://github.com/WordPress/gutenberg/pull/67125)) + +#### Block Variations +- Have `getActiveBlockVariation` fall back to default variation. ([63858](https://github.com/WordPress/gutenberg/pull/63858)) + +#### Core Data + +- Core Data: Remove conditions for selector resolutions. ([71575](https://github.com/WordPress/gutenberg/pull/71575)) + +### New APIs + +- Core Commands: Introduce new useCommands hook. ([71603](https://github.com/WordPress/gutenberg/pull/71603)) + + +### Bug Fixes + +- Block Comments: Prevent commenting on empty blocks. ([71497](https://github.com/WordPress/gutenberg/pull/71497)) +- Core Data: Don't return partial data when selecting a complete item. ([71474](https://github.com/WordPress/gutenberg/pull/71474)) +- Core Data: Fix early return check for the record field-level resolutions. ([71541](https://github.com/WordPress/gutenberg/pull/71541)) +- Core Data: Fix field conditions in 'getRevision' selector. ([71629](https://github.com/WordPress/gutenberg/pull/71629)) +- Fix PHP 8.5 array offset deprecation warning. ([71654](https://github.com/WordPress/gutenberg/pull/71654)) +- Fix block comment to display correct time. ([71652](https://github.com/WordPress/gutenberg/pull/71652)) +- Fix: Disable block commenting when postId is not number. ([71643](https://github.com/WordPress/gutenberg/pull/71643)) +- Fix: Hide resolved comments in unpinned mode. ([71409](https://github.com/WordPress/gutenberg/pull/71409)) +- Fix: Unable to pin comment sidebar after unpinning. ([71386](https://github.com/WordPress/gutenberg/pull/71386)) +- i18n: Show "1 more reply" instead of "1 more replies" in block comments. ([71685](https://github.com/WordPress/gutenberg/pull/71685)) + +#### DataViews +- DataForms regular layout: Remove label style overrides as they cause inconsistent results. ([71574](https://github.com/WordPress/gutenberg/pull/71574)) +- DataForms regular layout: Use BaseControl visual label for readonly fields when in top labelPosition. ([71597](https://github.com/WordPress/gutenberg/pull/71597)) +- DataViews grid layout: Rounded corners for media. ([71543](https://github.com/WordPress/gutenberg/pull/71543)) +- Field API: Add `setValue`. ([71604](https://github.com/WordPress/gutenberg/pull/71604)) + +#### Write mode +- Add contentRole to Query block and make sure Change design always works as expected. ([71686](https://github.com/WordPress/gutenberg/pull/71686)) +- Hide Spacer resizable box in write mode. ([71671](https://github.com/WordPress/gutenberg/pull/71671)) +- Make sure "add page" button label only appears once. ([71710](https://github.com/WordPress/gutenberg/pull/71710)) + +#### Block Editor +- Fix popover scroll hook’s simultaneous scrolling. ([71468](https://github.com/WordPress/gutenberg/pull/71468)) +- Fix stale insertion point reference. ([71559](https://github.com/WordPress/gutenberg/pull/71559)) +- useClipboardHandler: Prevent file paste for users without media upload permissions. ([71607](https://github.com/WordPress/gutenberg/pull/71607)) + +#### Components +- Fix popover not calling onClose on unmount. ([71252](https://github.com/WordPress/gutenberg/pull/71252)) +- Modal headings and labels missing editor text color. ([71311](https://github.com/WordPress/gutenberg/pull/71311)) +- Validated form controls: Fix Sass var handling for red color. ([71391](https://github.com/WordPress/gutenberg/pull/71391)) + +#### Global Styles +- BorderRadius Presets: Fix Generating wrong variable names in pattern code. ([71631](https://github.com/WordPress/gutenberg/pull/71631)) +- Search: Border radius presets work correctly. ([71481](https://github.com/WordPress/gutenberg/pull/71481)) + +#### Block Library +- Accordion: Add BlockGap support to content & panel. ([71461](https://github.com/WordPress/gutenberg/pull/71461)) +- Increase taxonomy limit for Terms List dropdown. ([71569](https://github.com/WordPress/gutenberg/pull/71569)) + +#### Block bindings +- Fix back-compat layer. ([71691](https://github.com/WordPress/gutenberg/pull/71691)) + +#### Block Transforms +- Block Editor: Fix BlockSwitcher to prevent transforms on reusable blocks. ([71661](https://github.com/WordPress/gutenberg/pull/71661)) + +#### Patterns +- Content Only Pattern experiment: Restore purple block icon color for synced patterns. ([71655](https://github.com/WordPress/gutenberg/pull/71655)) + +#### Post Editor +- Fix: Hide "View Post" link for non-viewable post types. ([71356](https://github.com/WordPress/gutenberg/pull/71356)) + +#### Block API +- Allow block attributes strings to terminate in \ character. ([71291](https://github.com/WordPress/gutenberg/pull/71291)) + + +### Accessibility + +- Block Comments: Make "n more replies" text clickable for accessibility. ([71387](https://github.com/WordPress/gutenberg/pull/71387)) +- feat: Clarify label & add help text with link. ([70451](https://github.com/WordPress/gutenberg/pull/70451)) + +#### DataViews +- Custom `empty` elements are no longer wrapped in `

` tags to improve accessibility. ([71561](https://github.com/WordPress/gutenberg/pull/71561)) + + +### Performance + +- Core Data: Always try to resolve entity permissions. ([71532](https://github.com/WordPress/gutenberg/pull/71532)) + +#### Post Editor +- Editor: Try displaying intermediate results for hierarchical terms. ([71402](https://github.com/WordPress/gutenberg/pull/71402)) + + +### Experiments + +#### Patterns +- Make unsynced patterns content only by default. ([71512](https://github.com/WordPress/gutenberg/pull/71512)) +- contentOnly Patterns experiment: Add Edit Contents button to block inspector and show 'Detach' block action. ([71653](https://github.com/WordPress/gutenberg/pull/71653)) + +#### Block Library +- Add Terms Query block. ([70720](https://github.com/WordPress/gutenberg/pull/70720)) +- Pattern content only experiment: Make template parts section blocks. ([71627](https://github.com/WordPress/gutenberg/pull/71627)) + + +### Documentation + +- ControlWithError: Add documentation about cloned `children`. ([71392](https://github.com/WordPress/gutenberg/pull/71392)) +- DataForm: Remove default storybook example. ([71609](https://github.com/WordPress/gutenberg/pull/71609)) +- Docs: Fix broken links in Gutenberg release checklist. ([71611](https://github.com/WordPress/gutenberg/pull/71611)) +- Docs: Split "Gutenberg Release Process" page into two. ([71130](https://github.com/WordPress/gutenberg/pull/71130)) + + +### Code Quality + +- Block Commenting: Prevent unnecessary API requests when post ID is not integer. ([71713](https://github.com/WordPress/gutenberg/pull/71713)) +- CODEOWNERS: Remove some block code owners. ([71657](https://github.com/WordPress/gutenberg/pull/71657)) +- Export `UseEntityRecordsWithPermissionsType`. ([71003](https://github.com/WordPress/gutenberg/pull/71003)) +- Migrate element package to TS. ([70886](https://github.com/WordPress/gutenberg/pull/70886)) +- [core-data]: Fix TS types for user object. ([68045](https://github.com/WordPress/gutenberg/pull/68045)) +- i18n: Remove unnecessary JS file. ([71549](https://github.com/WordPress/gutenberg/pull/71549)) + +#### Block Library +- Clean up LinkUI duplication for Add block and Create page flows. ([71499](https://github.com/WordPress/gutenberg/pull/71499)) +- Gallery Block: Add end-to-end test for image randomization. ([71538](https://github.com/WordPress/gutenberg/pull/71538)) +- Refactor Link UI controls with better props. ([71515](https://github.com/WordPress/gutenberg/pull/71515)) +- Refactor: Content only template locking block editing modes to reducer. ([67606](https://github.com/WordPress/gutenberg/pull/67606)) + +#### Block bindings +- Date block: Move `block_bindings_supported_attributes` filter to compat layer. ([71662](https://github.com/WordPress/gutenberg/pull/71662)) + +#### DataViews +- Remove leftover code for Pages. ([71534](https://github.com/WordPress/gutenberg/pull/71534)) + + +### Tools + +- Update CODEOWNERS to remove ndiego. ([71591](https://github.com/WordPress/gutenberg/pull/71591)) + +#### Testing +- Automated testing: Add taxonomy pagination tests. ([71584](https://github.com/WordPress/gutenberg/pull/71584)) +- Fix comments in block editing mode tests. ([71690](https://github.com/WordPress/gutenberg/pull/71690)) +- contentOnly patterns experiment: Add reducer tests. ([71688](https://github.com/WordPress/gutenberg/pull/71688)) + + +## First-time contributors + +The following PRs were merged by first-time contributors: + +- @coderGtm: feat: Clarify label & add help text with link. ([70451](https://github.com/WordPress/gutenberg/pull/70451)) +- @straku: Field API: Add `setValue`. ([71604](https://github.com/WordPress/gutenberg/pull/71604)) +- @xavier-lc: Export `UseEntityRecordsWithPermissionsType`. ([71003](https://github.com/WordPress/gutenberg/pull/71003)) + + +## Contributors + +The following contributors merged PRs in this release: + +@adamsilverstein @Adi-ty @andrewserong @coderGtm @coreyworrell @dhruvikpatel18 @elazzabi @ellatrix @getdave @gigitux @heavyweight @jeryj @jorgefilipecosta @karthick-murugan @kmanijak @kushagra-goyal-14 @Mamaduka @manzoorwanijk @mikachan @mirka @ndiego @ntsekouras @oandregal @ockham @p-jackson @priethor @R1shabh-Gupta @shimotmk @shrivastavanolo @sirreal @stokesman @straku @swissspidy @t-hamano @talldan @tellthemachines @USERSATOSHI @westonruter @xavier-lc @yashjawale + + = 21.6.0 = ## Changelog diff --git a/gutenberg.php b/gutenberg.php index cb0855f8650885..b4740e55c0cbc4 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.6.0 + * Version: 21.7.0 * Author: Gutenberg Team * Text Domain: gutenberg * diff --git a/lib/compat/wordpress-6.9/class-gutenberg-rest-static-templates-controller.php b/lib/compat/wordpress-6.9/class-gutenberg-rest-static-templates-controller.php deleted file mode 100644 index 5a12b5def492d4..00000000000000 --- a/lib/compat/wordpress-6.9/class-gutenberg-rest-static-templates-controller.php +++ /dev/null @@ -1,143 +0,0 @@ -namespace, - '/' . $this->rest_base, - array( - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_items' ), - 'permission_callback' => array( $this, 'get_items_permissions_check' ), - 'args' => $this->get_collection_params(), - ), - 'schema' => array( $this, 'get_public_item_schema' ), - ) - ); - - // Lists/updates a single template based on the given id. - register_rest_route( - $this->namespace, - // The route. - sprintf( - '/%s/(?P%s%s)', - $this->rest_base, - /* - * Matches theme's directory: `/themes///` or `/themes//`. - * Excludes invalid directory name characters: `/:<>*?"|`. - */ - '([^\/:<>\*\?"\|]+(?:\/[^\/:<>\*\?"\|]+)?)', - // Matches the template name. - '[\/\w%-]+' - ), - array( - 'args' => array( - 'id' => array( - 'description' => __( 'The id of a template' ), - 'type' => 'string', - 'sanitize_callback' => array( $this, '_sanitize_template_id' ), - ), - ), - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( $this, 'get_item' ), - 'permission_callback' => array( $this, 'get_item_permissions_check' ), - 'args' => array( - 'context' => $this->get_context_param( array( 'default' => 'view' ) ), - ), - ), - 'schema' => array( $this, 'get_public_item_schema' ), - ) - ); - } - - public function get_item_schema() { - $schema = parent::get_item_schema(); - $schema['properties']['is_custom'] = array( - 'description' => __( 'Whether a template is a custom template.' ), - 'type' => 'bool', - 'context' => array( 'embed', 'view', 'edit' ), - 'readonly' => true, - ); - $schema['properties']['plugin'] = array( - 'type' => 'string', - 'description' => __( 'Plugin that registered the template.' ), - 'readonly' => true, - 'context' => array( 'view', 'edit', 'embed' ), - ); - return $schema; - } - - public function get_items( $request ) { - $query = array(); - if ( isset( $request['area'] ) ) { - $query['area'] = $request['area']; - } - if ( isset( $request['post_type'] ) ) { - $query['post_type'] = $request['post_type']; - } - $template_files = _get_block_templates_files( 'wp_template', $query ); - $query_result = array(); - foreach ( $template_files as $template_file ) { - $query_result[] = _build_block_template_result_from_file( $template_file, 'wp_template' ); - } - - // Add templates registered in the template registry. Filtering out the ones which have a theme file. - $registered_templates = WP_Block_Templates_Registry::get_instance()->get_by_query( $query ); - $matching_registered_templates = array_filter( - $registered_templates, - function ( $registered_template ) use ( $template_files ) { - foreach ( $template_files as $template_file ) { - if ( $template_file['slug'] === $registered_template->slug ) { - return false; - } - } - return true; - } - ); - - $query_result = array_merge( $query_result, $matching_registered_templates ); - - /** - * Filters the array of queried block templates array after they've been fetched. - * - * @since 5.9.0 - * - * @param WP_Block_Template[] $query_result Array of found block templates. - * @param array $query { - * Arguments to retrieve templates. All arguments are optional. - * - * @type string[] $slug__in List of slugs to include. - * @type int $wp_id Post ID of customized template. - * @type string $area A 'wp_template_part_area' taxonomy value to filter by (for 'wp_template_part' template type only). - * @type string $post_type Post type to get the templates for. - * } - * @param string $template_type wp_template or wp_template_part. - */ - $query_result = apply_filters( 'get_block_templates', $query_result, $query, 'wp_template' ); - - $templates = array(); - foreach ( $query_result as $template ) { - $item = $this->prepare_item_for_response( $template, $request ); - $item->data['type'] = 'wp_registered_template'; - $templates[] = $this->prepare_response_for_collection( $item ); - } - - return rest_ensure_response( $templates ); - } - - public function get_item( $request ) { - $template = get_block_file_template( $request['id'], 'wp_template' ); - - if ( ! $template ) { - return new WP_Error( 'rest_template_not_found', __( 'No templates exist with that id.' ), array( 'status' => 404 ) ); - } - - $item = $this->prepare_item_for_response( $template, $request ); - // adjust the template type here instead - $item->data['type'] = 'wp_registered_template'; - return rest_ensure_response( $item ); - } -} diff --git a/lib/compat/wordpress-6.9/class-gutenberg-rest-templates-controller.php b/lib/compat/wordpress-6.9/class-gutenberg-rest-templates-controller.php deleted file mode 100644 index 53dbb6ca50c39c..00000000000000 --- a/lib/compat/wordpress-6.9/class-gutenberg-rest-templates-controller.php +++ /dev/null @@ -1,16 +0,0 @@ -name ) { - // Only prefetch for the root. If we preload it for all pages and it's not used - // it won't be possible to invalidate. - // To do: perhaps purge all preloaded paths when client side navigating. - if ( '/' !== $_GET['p'] ) { - $paths = array_filter( - $paths, - function ( $path ) { - return '/wp/v2/templates/lookup?slug=front-page' !== $path && '/wp/v2/templates/lookup?slug=home' !== $path; - } - ); - } - - $paths[] = '/wp/v2/wp_registered_template?context=edit'; - } - - return $paths; -} -add_filter( 'block_editor_rest_api_preload_paths', 'gutenberg_block_editor_preload_paths_6_9', 10, 2 ); diff --git a/lib/compat/wordpress-6.9/template-activate.php b/lib/compat/wordpress-6.9/template-activate.php deleted file mode 100644 index 936620b268e245..00000000000000 --- a/lib/compat/wordpress-6.9/template-activate.php +++ /dev/null @@ -1,169 +0,0 @@ -rest_base = 'templates'; - $controller = new WP_REST_Templates_Controller( 'wp_template' ); - $wp_post_types['wp_template']->rest_base = 'wp_template'; - $controller->register_routes(); -} - -// 3. We need a route to get that raw static templates from themes and plugins. -// I registered this as a post type route because right now the -// EditorProvider assumes templates are posts. -add_action( 'init', 'gutenberg_setup_static_template' ); -function gutenberg_setup_static_template() { - global $wp_post_types; - $wp_post_types['wp_registered_template'] = clone $wp_post_types['wp_template']; - $wp_post_types['wp_registered_template']->name = 'wp_registered_template'; - $wp_post_types['wp_registered_template']->rest_base = 'wp_registered_template'; - $wp_post_types['wp_registered_template']->rest_controller_class = 'Gutenberg_REST_Static_Templates_Controller'; - - register_setting( - 'reading', - 'active_templates', - array( - 'type' => 'object', - 'show_in_rest' => array( - 'schema' => array( - 'type' => 'object', - // properties can be integers or false (deactivated). - 'additionalProperties' => true, - ), - ), - 'default' => array(), - 'label' => 'Active Templates', - ) - ); -} - -add_filter( 'pre_wp_unique_post_slug', 'gutenberg_allow_template_slugs_to_be_duplicated', 10, 5 ); -function gutenberg_allow_template_slugs_to_be_duplicated( $override, $slug, $post_id, $post_status, $post_type ) { - return 'wp_template' === $post_type ? $slug : $override; -} - -add_filter( 'pre_get_block_templates', 'gutenberg_pre_get_block_templates', 10, 3 ); -function gutenberg_pre_get_block_templates( $output, $query, $template_type ) { - if ( 'wp_template' === $template_type && ! empty( $query['slug__in'] ) ) { - $active_templates = (array) get_option( 'active_templates', array() ); - $slugs = $query['slug__in']; - $output = array(); - foreach ( $slugs as $slug ) { - if ( isset( $active_templates[ $slug ] ) ) { - if ( false !== $active_templates[ $slug ] ) { - $post = get_post( $active_templates[ $slug ] ); - if ( $post && 'publish' === $post->post_status ) { - $output[] = _build_block_template_result_from_post( $post ); - } - } else { - // Deactivated template, fall back to next slug. - $output[] = array(); - } - } - } - if ( empty( $output ) ) { - $output = null; - } - } - return $output; -} - -// Whenever templates are queried by slug, never return any user templates. -// We are handling that in gutenberg_pre_get_block_templates. -function gutenberg_remove_tax_query_for_templates( $query ) { - if ( isset( $query->query['post_type'] ) && 'wp_template' === $query->query['post_type'] ) { - // We don't have templates with this status, that's the point. We want - // this query to not return any user templates. - $query->set( 'post_status', array( 'pending' ) ); - } -} - -add_filter( 'pre_get_block_templates', 'gutenberg_tax_pre_get_block_templates', 10, 3 ); -function gutenberg_tax_pre_get_block_templates( $output, $query, $template_type ) { - // Do not remove the tax query when querying for a specific slug. - if ( 'wp_template' === $template_type && ! empty( $query['slug__in'] ) ) { - add_action( 'pre_get_posts', 'gutenberg_remove_tax_query_for_templates' ); - } - return $output; -} - -add_filter( 'get_block_templates', 'gutenberg_tax_get_block_templates', 10, 3 ); -function gutenberg_tax_get_block_templates( $output, $query, $template_type ) { - if ( 'wp_template' === $template_type && ! empty( $query['slug__in'] ) ) { - remove_action( 'pre_get_posts', 'gutenberg_remove_tax_query_for_templates' ); - } - return $output; -} - -// We need to set the theme for the template when it's created. See: -// https://github.com/WordPress/wordpress-develop/blob/b2c8d8d2c8754cab5286b06efb4c11e2b6aa92d5/src/wp-includes/rest-api/endpoints/class-wp-rest-templates-controller.php#L571-L578 -// Priority 9 so it runs before default hooks like -// `inject_ignored_hooked_blocks_metadata_attributes`. -add_action( 'rest_pre_insert_wp_template', 'gutenberg_set_active_template_theme', 9, 2 ); -function gutenberg_set_active_template_theme( $changes, $request ) { - $template = $request['id'] ? get_block_template( $request['id'], 'wp_template' ) : null; - if ( $template ) { - return $changes; - } - $changes->tax_input = array( - 'wp_theme' => isset( $request['theme'] ) ? $request['theme'] : get_stylesheet(), - ); - return $changes; -} - -// Migrate existing "edited" templates. By existing, it means that the template -// is active. -add_action( 'init', 'gutenberg_migrate_existing_templates' ); -function gutenberg_migrate_existing_templates() { - $active_templates = get_option( 'active_templates' ); - - if ( $active_templates ) { - return; - } - - // Query all templates in the database. See `get_block_templates`. - $wp_query_args = array( - 'post_status' => 'publish', - 'post_type' => 'wp_template', - 'posts_per_page' => -1, - 'no_found_rows' => true, - 'lazy_load_term_meta' => false, - 'tax_query' => array( - array( - 'taxonomy' => 'wp_theme', - 'field' => 'name', - 'terms' => get_stylesheet(), - ), - ), - ); - - $template_query = new WP_Query( $wp_query_args ); - $active_templates = array(); - - foreach ( $template_query->posts as $post ) { - $active_templates[ $post->post_name ] = $post->ID; - } - - update_option( 'active_templates', $active_templates ); -} 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/lib/experiments-page.php b/lib/experiments-page.php index fe4ce4a2cc756f..6f5103a3d3cf49 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -117,12 +117,12 @@ 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.', 'gutenberg' ), 'id' => 'gutenberg-sync-collaboration', ) ); diff --git a/lib/load.php b/lib/load.php index 193259a9acbc80..1c77fc8c78600d 100644 --- a/lib/load.php +++ b/lib/load.php @@ -39,9 +39,6 @@ function gutenberg_is_experiment_enabled( $name ) { require __DIR__ . '/compat/wordpress-6.8/rest-api.php'; // WordPress 6.9 compat. - require __DIR__ . '/compat/wordpress-6.9/class-gutenberg-rest-static-templates-controller.php'; - require __DIR__ . '/compat/wordpress-6.9/class-gutenberg-rest-templates-controller.php'; - require __DIR__ . '/compat/wordpress-6.9/template-activate.php'; require __DIR__ . '/compat/wordpress-6.9/block-bindings.php'; require __DIR__ . '/compat/wordpress-6.9/post-data-block-bindings.php'; require __DIR__ . '/compat/wordpress-6.9/term-data-block-bindings.php'; @@ -87,7 +84,6 @@ function gutenberg_is_experiment_enabled( $name ) { // WordPress 6.9 compat. require __DIR__ . '/compat/wordpress-6.9/customizer-preview-custom-css.php'; require __DIR__ . '/compat/wordpress-6.9/command-palette.php'; -require __DIR__ . '/compat/wordpress-6.9/preload.php'; // Experimental features. require __DIR__ . '/experimental/block-editor-settings-mobile.php'; diff --git a/package-lock.json b/package-lock.json index 4af8ec42ac437d..8c12c77c0624eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gutenberg", - "version": "21.6.0", + "version": "21.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gutenberg", - "version": "21.6.0", + "version": "21.7.0", "hasInstallScript": true, "license": "GPL-2.0-or-later", "workspaces": [ @@ -27,6 +27,7 @@ "@emotion/native": "11.0.0", "@geometricpanda/storybook-addon-badges": "2.0.5", "@inquirer/prompts": "7.2.0", + "@jest/globals": "29.6.2", "@octokit/rest": "16.26.0", "@octokit/types": "6.34.0", "@octokit/webhooks-types": "5.8.0", @@ -1481,13 +1482,14 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.25.9.tgz", - "integrity": "sha512-z88xeGxnzehn2sqZ8UdGQEvYErF1odv2CftxInpSYJt6uHuPe9YjahKZITGs3l5LeI9d2ROG+obuDAoSlqbNfQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "license": "MIT", "dependencies": { - "@babel/highlight": "^7.25.9", - "picocolors": "^1.0.0" + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" @@ -2078,9 +2080,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2149,47 +2151,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/highlight": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz", - "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/parser": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.9.tgz", @@ -5831,6 +5792,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -5903,6 +5874,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/globals": { "version": "29.6.2", "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.6.2.tgz", @@ -5917,6 +5898,30 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern/node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/reporters": { "version": "29.6.2", "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.6.2.tgz", @@ -13790,9 +13795,10 @@ } }, "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", - "integrity": "sha512-hRJD2ahnnpLgsj6KWMYSrmXkM3rm2Dl1qkx6IOFD5FnuNPXJIG5L0dhgKXCYTRMGzU4n0wImQ/xfmRc4POUFlg==" + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT" }, "node_modules/@types/istanbul-lib-report": { "version": "3.0.0", @@ -13803,21 +13809,283 @@ } }, "node_modules/@types/istanbul-reports": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", - "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", "dependencies": { "@types/istanbul-lib-report": "*" } }, "node_modules/@types/jest": { - "version": "29.4.0", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.4.0.tgz", - "integrity": "sha512-VaywcGQ9tPorCX/Jkkni7RWGFfI11whqzs8dvxF41P17Z+z872thvEvlIbznjPJ02kl1HMX3LmLOonsj2n7HeQ==", + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/jest/node_modules/@jest/expect-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", "dev": true, + "license": "MIT", "dependencies": { - "expect": "^29.0.0", - "pretty-format": "^29.0.0" + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/@jest/types": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/@sinclair/typebox": { + "version": "0.34.41", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", + "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jest/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/ci-info": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", + "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@types/jest/node_modules/expect": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@types/jest/node_modules/jest-diff": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-matcher-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-message-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-mock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "jest-util": "30.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/jest-util": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.2.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jest/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" } }, "node_modules/@types/jsdom": { @@ -14169,9 +14437,10 @@ "dev": true }, "node_modules/@types/stack-utils": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", - "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "license": "MIT" }, "node_modules/@types/supports-color": { "version": "8.1.1", @@ -26660,6 +26929,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, "engines": { "node": ">=4" } @@ -27568,11 +27838,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", @@ -31126,13 +31391,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" }, @@ -43034,9 +43301,10 @@ } }, "node_modules/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-gL//fkxfWUsIlFL2Tl42Cl6+HFALEaB1FU76I/Fy+oZjRreP7OPMXFlGbxM7NQsI0ZpUfw76sHnv0WNYuTb7Iw==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", "dependencies": { "escape-string-regexp": "^2.0.0" }, @@ -43984,6 +44252,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -48482,56 +48751,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", @@ -48664,22 +48883,6 @@ "fd-slicer": "~1.1.0" } }, - "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==", - "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", @@ -48765,7 +48968,7 @@ }, "packages/a11y": { "name": "@wordpress/a11y", - "version": "4.30.0", + "version": "4.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -48779,7 +48982,7 @@ }, "packages/annotations": { "name": "@wordpress/annotations", - "version": "3.30.0", + "version": "3.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -48799,7 +49002,7 @@ }, "packages/api-fetch": { "name": "@wordpress/api-fetch", - "version": "7.30.0", + "version": "7.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -48813,7 +49016,7 @@ }, "packages/autop": { "name": "@wordpress/autop", - "version": "4.30.0", + "version": "4.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -48825,7 +49028,7 @@ }, "packages/babel-plugin-import-jsx-pragma": { "name": "@wordpress/babel-plugin-import-jsx-pragma", - "version": "5.30.0", + "version": "5.31.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -48837,7 +49040,7 @@ }, "packages/babel-plugin-makepot": { "name": "@wordpress/babel-plugin-makepot", - "version": "6.30.0", + "version": "6.31.0", "license": "GPL-2.0-or-later", "dependencies": { "deepmerge": "^4.3.0", @@ -48854,7 +49057,7 @@ }, "packages/babel-preset-default": { "name": "@wordpress/babel-preset-default", - "version": "8.30.0", + "version": "8.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/core": "7.25.7", @@ -49525,7 +49728,7 @@ }, "packages/base-styles": { "name": "@wordpress/base-styles", - "version": "6.6.0", + "version": "6.7.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -49534,7 +49737,7 @@ }, "packages/blob": { "name": "@wordpress/blob", - "version": "4.30.0", + "version": "4.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -49546,7 +49749,7 @@ }, "packages/block-directory": { "name": "@wordpress/block-directory", - "version": "5.30.0", + "version": "5.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -49582,7 +49785,7 @@ }, "packages/block-editor": { "name": "@wordpress/block-editor", - "version": "15.3.0", + "version": "15.4.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -49683,7 +49886,7 @@ }, "packages/block-library": { "name": "@wordpress/block-library", - "version": "9.30.0", + "version": "9.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -49741,7 +49944,7 @@ }, "packages/block-serialization-default-parser": { "name": "@wordpress/block-serialization-default-parser", - "version": "5.30.0", + "version": "5.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -49753,7 +49956,7 @@ }, "packages/block-serialization-spec-parser": { "name": "@wordpress/block-serialization-spec-parser", - "version": "5.30.0", + "version": "5.31.0", "license": "GPL-2.0-or-later", "dependencies": { "pegjs": "^0.10.0", @@ -49766,7 +49969,7 @@ }, "packages/blocks": { "name": "@wordpress/blocks", - "version": "15.3.0", + "version": "15.4.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -49812,7 +50015,7 @@ }, "packages/browserslist-config": { "name": "@wordpress/browserslist-config", - "version": "6.30.0", + "version": "6.31.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -49821,7 +50024,7 @@ }, "packages/commands": { "name": "@wordpress/commands", - "version": "1.30.0", + "version": "1.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50060,7 +50263,7 @@ }, "packages/components": { "name": "@wordpress/components", - "version": "30.3.0", + "version": "30.4.0", "license": "GPL-2.0-or-later", "dependencies": { "@ariakit/react": "^0.4.15", @@ -50153,7 +50356,7 @@ }, "packages/compose": { "name": "@wordpress/compose", - "version": "7.30.0", + "version": "7.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50190,7 +50393,7 @@ }, "packages/core-commands": { "name": "@wordpress/core-commands", - "version": "1.30.0", + "version": "1.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50219,7 +50422,7 @@ }, "packages/core-data": { "name": "@wordpress/core-data", - "version": "7.30.0", + "version": "7.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50230,6 +50433,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", @@ -50242,6 +50446,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" }, @@ -50256,7 +50461,7 @@ }, "packages/create-block": { "name": "@wordpress/create-block", - "version": "4.73.0", + "version": "4.74.0", "license": "GPL-2.0-or-later", "dependencies": { "@inquirer/prompts": "^7.2.0", @@ -50283,7 +50488,7 @@ }, "packages/create-block-interactive-template": { "name": "@wordpress/create-block-interactive-template", - "version": "2.30.0", + "version": "2.31.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -50292,7 +50497,7 @@ }, "packages/create-block-tutorial-template": { "name": "@wordpress/create-block-tutorial-template", - "version": "4.30.0", + "version": "4.31.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -50301,7 +50506,7 @@ }, "packages/customize-widgets": { "name": "@wordpress/customize-widgets", - "version": "5.30.0", + "version": "5.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50339,7 +50544,7 @@ }, "packages/data": { "name": "@wordpress/data", - "version": "10.30.0", + "version": "10.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50368,7 +50573,7 @@ }, "packages/data-controls": { "name": "@wordpress/data-controls", - "version": "4.30.0", + "version": "4.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50386,7 +50591,7 @@ }, "packages/dataviews": { "name": "@wordpress/dataviews", - "version": "8.0.0", + "version": "9.0.0", "license": "GPL-2.0-or-later", "dependencies": { "@ariakit/react": "^0.4.15", @@ -50432,7 +50637,7 @@ }, "packages/date": { "name": "@wordpress/date", - "version": "5.30.0", + "version": "5.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50447,7 +50652,7 @@ }, "packages/dependency-extraction-webpack-plugin": { "name": "@wordpress/dependency-extraction-webpack-plugin", - "version": "6.30.0", + "version": "6.31.0", "license": "GPL-2.0-or-later", "dependencies": { "json2php": "^0.0.7" @@ -50462,7 +50667,7 @@ }, "packages/deprecated": { "name": "@wordpress/deprecated", - "version": "4.30.0", + "version": "4.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50475,7 +50680,7 @@ }, "packages/docgen": { "name": "@wordpress/docgen", - "version": "2.30.0", + "version": "2.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/core": "7.25.7", @@ -50496,7 +50701,7 @@ }, "packages/dom": { "name": "@wordpress/dom", - "version": "4.30.0", + "version": "4.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50509,7 +50714,7 @@ }, "packages/dom-ready": { "name": "@wordpress/dom-ready", - "version": "4.30.0", + "version": "4.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -50521,7 +50726,7 @@ }, "packages/e2e-test-utils": { "name": "@wordpress/e2e-test-utils", - "version": "11.30.0", + "version": "11.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50543,7 +50748,7 @@ }, "packages/e2e-test-utils-playwright": { "name": "@wordpress/e2e-test-utils-playwright", - "version": "1.30.0", + "version": "1.31.0", "license": "GPL-2.0-or-later", "dependencies": { "change-case": "^4.1.2", @@ -50569,7 +50774,7 @@ }, "packages/e2e-tests": { "name": "@wordpress/e2e-tests", - "version": "8.30.0", + "version": "8.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@wordpress/e2e-test-utils": "file:../e2e-test-utils", @@ -50599,7 +50804,7 @@ }, "packages/edit-post": { "name": "@wordpress/edit-post", - "version": "8.30.0", + "version": "8.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50645,7 +50850,7 @@ }, "packages/edit-site": { "name": "@wordpress/edit-site", - "version": "6.30.0", + "version": "6.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50707,7 +50912,7 @@ }, "packages/edit-widgets": { "name": "@wordpress/edit-widgets", - "version": "6.30.0", + "version": "6.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50750,7 +50955,7 @@ }, "packages/editor": { "name": "@wordpress/editor", - "version": "14.30.0", + "version": "14.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50812,7 +51017,7 @@ }, "packages/element": { "name": "@wordpress/element", - "version": "6.30.0", + "version": "6.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50831,7 +51036,7 @@ }, "packages/env": { "name": "@wordpress/env", - "version": "10.30.0", + "version": "10.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@inquirer/prompts": "^7.2.0", @@ -50857,7 +51062,7 @@ }, "packages/escape-html": { "name": "@wordpress/escape-html", - "version": "3.30.0", + "version": "3.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -50869,7 +51074,7 @@ }, "packages/eslint-plugin": { "name": "@wordpress/eslint-plugin", - "version": "22.16.0", + "version": "22.17.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/eslint-parser": "7.25.7", @@ -50938,7 +51143,7 @@ }, "packages/fields": { "name": "@wordpress/fields", - "version": "0.22.0", + "version": "0.23.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -50980,7 +51185,7 @@ }, "packages/format-library": { "name": "@wordpress/format-library", - "version": "5.30.0", + "version": "5.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51008,7 +51213,7 @@ }, "packages/hooks": { "name": "@wordpress/hooks", - "version": "4.30.0", + "version": "4.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -51020,7 +51225,7 @@ }, "packages/html-entities": { "name": "@wordpress/html-entities", - "version": "4.30.0", + "version": "4.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -51032,7 +51237,7 @@ }, "packages/i18n": { "name": "@wordpress/i18n", - "version": "6.3.0", + "version": "6.4.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51052,7 +51257,7 @@ }, "packages/icons": { "name": "@wordpress/icons", - "version": "10.30.0", + "version": "10.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51066,7 +51271,7 @@ }, "packages/interactivity": { "name": "@wordpress/interactivity", - "version": "6.30.0", + "version": "6.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@preact/signals": "^1.3.0", @@ -51079,7 +51284,7 @@ }, "packages/interactivity-router": { "name": "@wordpress/interactivity-router", - "version": "2.30.0", + "version": "2.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@wordpress/a11y": "file:../a11y", @@ -51093,7 +51298,7 @@ }, "packages/interface": { "name": "@wordpress/interface", - "version": "9.15.0", + "version": "9.16.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51121,7 +51326,7 @@ }, "packages/is-shallow-equal": { "name": "@wordpress/is-shallow-equal", - "version": "5.30.0", + "version": "5.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -51133,7 +51338,7 @@ }, "packages/jest-console": { "name": "@wordpress/jest-console", - "version": "8.30.0", + "version": "8.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51149,7 +51354,7 @@ }, "packages/jest-preset-default": { "name": "@wordpress/jest-preset-default", - "version": "12.30.0", + "version": "12.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@wordpress/jest-console": "file:../jest-console", @@ -51166,7 +51371,7 @@ }, "packages/jest-puppeteer-axe": { "name": "@wordpress/jest-puppeteer-axe", - "version": "7.30.0", + "version": "7.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@axe-core/puppeteer": "^4.0.0", @@ -51188,7 +51393,7 @@ }, "packages/keyboard-shortcuts": { "name": "@wordpress/keyboard-shortcuts", - "version": "5.30.0", + "version": "5.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51206,7 +51411,7 @@ }, "packages/keycodes": { "name": "@wordpress/keycodes", - "version": "4.30.0", + "version": "4.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51219,7 +51424,7 @@ }, "packages/lazy-import": { "name": "@wordpress/lazy-import", - "version": "2.30.0", + "version": "2.31.0", "license": "GPL-2.0-or-later", "dependencies": { "execa": "^4.0.2", @@ -51233,7 +51438,7 @@ }, "packages/list-reusable-blocks": { "name": "@wordpress/list-reusable-blocks", - "version": "5.30.0", + "version": "5.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51256,7 +51461,7 @@ }, "packages/media-utils": { "name": "@wordpress/media-utils", - "version": "5.30.0", + "version": "5.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51273,7 +51478,7 @@ }, "packages/notices": { "name": "@wordpress/notices", - "version": "5.30.0", + "version": "5.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51290,7 +51495,7 @@ }, "packages/npm-package-json-lint-config": { "name": "@wordpress/npm-package-json-lint-config", - "version": "5.30.0", + "version": "5.31.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -51302,7 +51507,7 @@ }, "packages/nux": { "name": "@wordpress/nux", - "version": "9.30.0", + "version": "9.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51325,7 +51530,7 @@ }, "packages/patterns": { "name": "@wordpress/patterns", - "version": "2.30.0", + "version": "2.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51355,7 +51560,7 @@ }, "packages/plugins": { "name": "@wordpress/plugins", - "version": "7.30.0", + "version": "7.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51379,7 +51584,7 @@ }, "packages/postcss-plugins-preset": { "name": "@wordpress/postcss-plugins-preset", - "version": "5.30.0", + "version": "5.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@wordpress/base-styles": "file:../base-styles", @@ -51448,7 +51653,7 @@ }, "packages/postcss-themes": { "name": "@wordpress/postcss-themes", - "version": "6.30.0", + "version": "6.31.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -51460,7 +51665,7 @@ }, "packages/preferences": { "name": "@wordpress/preferences", - "version": "4.30.0", + "version": "4.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51486,7 +51691,7 @@ }, "packages/preferences-persistence": { "name": "@wordpress/preferences-persistence", - "version": "2.30.0", + "version": "2.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51499,7 +51704,7 @@ }, "packages/prettier-config": { "name": "@wordpress/prettier-config", - "version": "4.30.0", + "version": "4.31.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -51511,7 +51716,7 @@ }, "packages/primitives": { "name": "@wordpress/primitives", - "version": "4.30.0", + "version": "4.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51528,7 +51733,7 @@ }, "packages/priority-queue": { "name": "@wordpress/priority-queue", - "version": "3.30.0", + "version": "3.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51541,7 +51746,7 @@ }, "packages/private-apis": { "name": "@wordpress/private-apis", - "version": "1.30.0", + "version": "1.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -51553,7 +51758,7 @@ }, "packages/project-management-automation": { "name": "@wordpress/project-management-automation", - "version": "2.30.0", + "version": "2.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@actions/core": "1.9.1", @@ -51581,7 +51786,7 @@ }, "packages/react-i18n": { "name": "@wordpress/react-i18n", - "version": "4.30.0", + "version": "4.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51737,7 +51942,7 @@ }, "packages/readable-js-assets-webpack-plugin": { "name": "@wordpress/readable-js-assets-webpack-plugin", - "version": "3.30.0", + "version": "3.31.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -51749,7 +51954,7 @@ }, "packages/redux-routine": { "name": "@wordpress/redux-routine", - "version": "5.30.0", + "version": "5.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51792,7 +51997,7 @@ }, "packages/reusable-blocks": { "name": "@wordpress/reusable-blocks", - "version": "5.30.0", + "version": "5.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51819,7 +52024,7 @@ }, "packages/rich-text": { "name": "@wordpress/rich-text", - "version": "7.30.0", + "version": "7.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51844,7 +52049,7 @@ }, "packages/router": { "name": "@wordpress/router", - "version": "1.30.0", + "version": "1.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -51865,7 +52070,7 @@ }, "packages/scripts": { "name": "@wordpress/scripts", - "version": "30.23.0", + "version": "30.24.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/core": "7.25.7", @@ -52008,7 +52213,7 @@ }, "packages/server-side-render": { "name": "@wordpress/server-side-render", - "version": "6.6.0", + "version": "6.7.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -52033,7 +52238,7 @@ }, "packages/shortcode": { "name": "@wordpress/shortcode", - "version": "4.30.0", + "version": "4.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -52046,7 +52251,7 @@ }, "packages/style-engine": { "name": "@wordpress/style-engine", - "version": "2.30.0", + "version": "2.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -52059,7 +52264,7 @@ }, "packages/stylelint-config": { "name": "@wordpress/stylelint-config", - "version": "23.22.0", + "version": "23.23.0", "license": "MIT", "dependencies": { "@stylistic/stylelint-plugin": "^3.0.1", @@ -52171,28 +52376,83 @@ }, "packages/sync": { "name": "@wordpress/sync", - "version": "1.30.0", + "version": "1.31.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-protocols": "^1.0.5", - "y-webrtc": "~10.2.5", - "yjs": "~13.6.6" + "y-indexeddb": "^9.0.12", + "y-protocols": "^1.0.6", + "yjs": "13.6.27" }, "engines": { "node": ">=18.12.0", "npm": ">=8.19.2" } }, + "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/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.30.0", + "version": "3.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" @@ -52204,7 +52464,7 @@ }, "packages/undo-manager": { "name": "@wordpress/undo-manager", - "version": "1.30.0", + "version": "1.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -52217,7 +52477,7 @@ }, "packages/upload-media": { "name": "@wordpress/upload-media", - "version": "0.15.0", + "version": "0.16.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -52243,7 +52503,7 @@ }, "packages/url": { "name": "@wordpress/url", - "version": "4.30.0", + "version": "4.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -52256,7 +52516,7 @@ }, "packages/viewport": { "name": "@wordpress/viewport", - "version": "6.30.0", + "version": "6.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -52286,7 +52546,7 @@ }, "packages/warning": { "name": "@wordpress/warning", - "version": "3.30.0", + "version": "3.31.0", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -52295,7 +52555,7 @@ }, "packages/widgets": { "name": "@wordpress/widgets", - "version": "4.30.0", + "version": "4.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", @@ -52323,7 +52583,7 @@ }, "packages/wordcount": { "name": "@wordpress/wordcount", - "version": "4.30.0", + "version": "4.31.0", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7" diff --git a/package.json b/package.json index fc68cf949027cf..09312bcd7502ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gutenberg", - "version": "21.6.0", + "version": "21.7.0", "private": true, "description": "A new WordPress editor experience.", "author": "The WordPress Contributors", @@ -36,6 +36,7 @@ "@emotion/native": "11.0.0", "@geometricpanda/storybook-addon-badges": "2.0.5", "@inquirer/prompts": "7.2.0", + "@jest/globals": "29.6.2", "@octokit/rest": "16.26.0", "@octokit/types": "6.34.0", "@octokit/webhooks-types": "5.8.0", diff --git a/packages/a11y/CHANGELOG.md b/packages/a11y/CHANGELOG.md index 12c24abe06232d..827078edfb82a6 100644 --- a/packages/a11y/CHANGELOG.md +++ b/packages/a11y/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.31.0 (2025-09-17) + ## 4.30.0 (2025-09-03) ## 4.29.0 (2025-08-20) diff --git a/packages/a11y/package.json b/packages/a11y/package.json index 4f81e08b6c1784..8848724fa65bd6 100644 --- a/packages/a11y/package.json +++ b/packages/a11y/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/a11y", - "version": "4.30.0", + "version": "4.31.0", "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 5cac668b6b3edd..175c100309debc 100644 --- a/packages/annotations/CHANGELOG.md +++ b/packages/annotations/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 3.31.0 (2025-09-17) + ## 3.30.0 (2025-09-03) ## 3.29.0 (2025-08-20) diff --git a/packages/annotations/package.json b/packages/annotations/package.json index 7f7f1dd182695e..3a19288ee0c0f4 100644 --- a/packages/annotations/package.json +++ b/packages/annotations/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/annotations", - "version": "3.30.0", + "version": "3.31.0", "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 915b1f1279ad59..5985a771741d99 100644 --- a/packages/api-fetch/CHANGELOG.md +++ b/packages/api-fetch/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 7.31.0 (2025-09-17) + ## 7.30.0 (2025-09-03) ## 7.29.0 (2025-08-20) diff --git a/packages/api-fetch/package.json b/packages/api-fetch/package.json index 3b6b8e9ee8c4a8..74140e6c94949b 100644 --- a/packages/api-fetch/package.json +++ b/packages/api-fetch/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/api-fetch", - "version": "7.30.0", + "version": "7.31.0", "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 552a919415631b..fc88bffbd01f56 100644 --- a/packages/autop/CHANGELOG.md +++ b/packages/autop/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.31.0 (2025-09-17) + ## 4.30.0 (2025-09-03) ## 4.29.0 (2025-08-20) diff --git a/packages/autop/package.json b/packages/autop/package.json index e0f4509242b528..4f6e22b759789e 100644 --- a/packages/autop/package.json +++ b/packages/autop/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/autop", - "version": "4.30.0", + "version": "4.31.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/CHANGELOG.md b/packages/babel-plugin-import-jsx-pragma/CHANGELOG.md index 2c8cae7903f06c..ad5959aa2f9cec 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.31.0 (2025-09-17) + ## 5.30.0 (2025-09-03) ## 5.29.0 (2025-08-20) diff --git a/packages/babel-plugin-import-jsx-pragma/package.json b/packages/babel-plugin-import-jsx-pragma/package.json index 681226ffa300ea..895dcffe540403 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.30.0", + "version": "5.31.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/CHANGELOG.md b/packages/babel-plugin-makepot/CHANGELOG.md index d1458109e861e4..ffb86fd9d3d3df 100644 --- a/packages/babel-plugin-makepot/CHANGELOG.md +++ b/packages/babel-plugin-makepot/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.31.0 (2025-09-17) + ## 6.30.0 (2025-09-03) ## 6.29.0 (2025-08-20) diff --git a/packages/babel-plugin-makepot/package.json b/packages/babel-plugin-makepot/package.json index 1681c8d69f721b..62ef459cdbb6c3 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.30.0", + "version": "6.31.0", "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 ee0ad60febe3dd..9fcb0ec20b2257 100644 --- a/packages/babel-preset-default/CHANGELOG.md +++ b/packages/babel-preset-default/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 8.31.0 (2025-09-17) + ## 8.30.0 (2025-09-03) ## 8.29.0 (2025-08-20) diff --git a/packages/babel-preset-default/package.json b/packages/babel-preset-default/package.json index f1b4233cddebeb..632d0ce02bb81f 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.30.0", + "version": "8.31.0", "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 0530d9748ab3f0..1504c7fc269ea3 100644 --- a/packages/base-styles/CHANGELOG.md +++ b/packages/base-styles/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.7.0 (2025-09-17) + ## 6.6.0 (2025-09-03) ## 6.5.0 (2025-08-20) diff --git a/packages/base-styles/package.json b/packages/base-styles/package.json index e75645a7aab3b9..c8be74009b4590 100644 --- a/packages/base-styles/package.json +++ b/packages/base-styles/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/base-styles", - "version": "6.6.0", + "version": "6.7.0", "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 8234694b6ddc21..0672d54ca53c8a 100644 --- a/packages/blob/CHANGELOG.md +++ b/packages/blob/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.31.0 (2025-09-17) + ## 4.30.0 (2025-09-03) ## 4.29.0 (2025-08-20) diff --git a/packages/blob/package.json b/packages/blob/package.json index c700074e1d3e6a..6746e5bce95612 100644 --- a/packages/blob/package.json +++ b/packages/blob/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/blob", - "version": "4.30.0", + "version": "4.31.0", "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 72df7b18cab289..bbef234d3956d5 100644 --- a/packages/block-directory/CHANGELOG.md +++ b/packages/block-directory/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.31.0 (2025-09-17) + ## 5.30.0 (2025-09-03) ## 5.29.0 (2025-08-20) diff --git a/packages/block-directory/package.json b/packages/block-directory/package.json index 012d257e980168..03a9f2afb74ad1 100644 --- a/packages/block-directory/package.json +++ b/packages/block-directory/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-directory", - "version": "5.30.0", + "version": "5.31.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/CHANGELOG.md b/packages/block-editor/CHANGELOG.md index 57a6102fc809f6..4a6373fd85bd9f 100644 --- a/packages/block-editor/CHANGELOG.md +++ b/packages/block-editor/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 15.4.0 (2025-09-17) + ## 15.3.0 (2025-09-03) ## 15.2.0 (2025-08-20) diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index 77d9d085db3010..a5c1bec42f8132 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-editor", - "version": "15.3.0", + "version": "15.4.0", "description": "Generic block editor.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/block-editor/src/components/block-canvas/index.js b/packages/block-editor/src/components/block-canvas/index.js index c399f38054ed4d..20968b4a3c02a9 100644 --- a/packages/block-editor/src/components/block-canvas/index.js +++ b/packages/block-editor/src/components/block-canvas/index.js @@ -18,6 +18,7 @@ import { useBlockSelectionClearer } from '../block-selection-clearer'; import { useBlockCommands } from '../use-block-commands'; import { store as blockEditorStore } from '../../store'; import { unlock } from '../../lock-unlock'; +import { BlockCanvasCover } from '../../index'; // EditorStyles is a memoized component, so avoid passing a new // object reference on each render. @@ -44,6 +45,7 @@ export function ExperimentalBlockCanvas( { ( select ) => unlock( select( blockEditorStore ) ).getZoomLevel(), [] ); + const zoomOutIframeProps = zoomLevel !== 100 && ! isTabletViewport ? { @@ -74,6 +76,27 @@ export function ExperimentalBlockCanvas( { > { children } + + + { ( covers ) => + covers.map( ( cover, index ) => ( +

+ { cover } +
+ ) ) + } + ); } @@ -95,6 +118,26 @@ export function ExperimentalBlockCanvas( { > { children } + + { ( covers ) => + covers.map( ( cover, index ) => ( +
+ { cover } +
+ ) ) + } +
); 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..04269735229e29 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,25 @@ 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 + // + // Unsure: Why are these blocks being cloned? Do they need to be? + const storeBlocks = isPostContentBlock + ? controlledBlocks + : controlledBlocks.map( ( block ) => cloneBlock( block ) ); + if ( subscribedRef.current ) { pendingChangesRef.current.incoming = storeBlocks; } diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index ac33f7f98d2968..4fbdc49c7bd0f3 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -253,8 +253,15 @@ export function RichTextWrapper( blockContext, ] ); + const isInsidePatternOverrides = !! blockContext?.[ 'pattern/overrides' ]; + const hasOverrideEnabled = + blockBindings?.__default?.source === 'core/pattern-overrides'; - const shouldDisableEditing = readOnly || disableBoundBlock; + const shouldDisableForPattern = + isInsidePatternOverrides && ! hasOverrideEnabled; + + const shouldDisableEditing = + readOnly || disableBoundBlock || shouldDisableForPattern; const { getSelectionStart, getSelectionEnd, getBlockRootClientId } = useSelect( blockEditorStore ); diff --git a/packages/block-editor/src/index.js b/packages/block-editor/src/index.js index d81f23a702b04e..1a2037c983256c 100644 --- a/packages/block-editor/src/index.js +++ b/packages/block-editor/src/index.js @@ -1,3 +1,8 @@ +/** + * WordPress dependencies + */ +import { createSlotFill } from '@wordpress/components'; + /** * Internal dependencies */ @@ -21,3 +26,5 @@ export * from './utils'; export { storeConfig, store } from './store'; export { SETTINGS_DEFAULTS } from './store/defaults'; export { privateApis } from './private-apis'; + +export const BlockCanvasCover = createSlotFill( 'BlockCanvasCover' ); diff --git a/packages/block-library/CHANGELOG.md b/packages/block-library/CHANGELOG.md index 0631bc43549273..a0af049b2ed507 100644 --- a/packages/block-library/CHANGELOG.md +++ b/packages/block-library/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 9.31.0 (2025-09-17) + ## 9.30.0 (2025-09-03) ## 9.29.0 (2025-08-20) diff --git a/packages/block-library/package.json b/packages/block-library/package.json index b916c0433957ea..1e2c9f1ff66362 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/block-library", - "version": "9.30.0", + "version": "9.31.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/CHANGELOG.md b/packages/block-serialization-default-parser/CHANGELOG.md index 44db8427d4c76e..54037f086729a2 100644 --- a/packages/block-serialization-default-parser/CHANGELOG.md +++ b/packages/block-serialization-default-parser/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.31.0 (2025-09-17) + ## 5.30.0 (2025-09-03) ## 5.29.0 (2025-08-20) diff --git a/packages/block-serialization-default-parser/package.json b/packages/block-serialization-default-parser/package.json index a70f3e63026f3a..d7b55cebe60c50 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.30.0", + "version": "5.31.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/CHANGELOG.md b/packages/block-serialization-spec-parser/CHANGELOG.md index baa920505b2d74..e6831add454567 100644 --- a/packages/block-serialization-spec-parser/CHANGELOG.md +++ b/packages/block-serialization-spec-parser/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.31.0 (2025-09-17) + ## 5.30.0 (2025-09-03) ## 5.29.0 (2025-08-20) diff --git a/packages/block-serialization-spec-parser/package.json b/packages/block-serialization-spec-parser/package.json index 1ac31916d49b7d..adf341862db0a8 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.30.0", + "version": "5.31.0", "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 8274ae0792559f..80c63d33ec9421 100644 --- a/packages/blocks/CHANGELOG.md +++ b/packages/blocks/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 15.4.0 (2025-09-17) + ## 15.3.0 (2025-09-03) ## 15.2.0 (2025-08-20) diff --git a/packages/blocks/package.json b/packages/blocks/package.json index cd39ec7797a95d..0a716832b6ec58 100644 --- a/packages/blocks/package.json +++ b/packages/blocks/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/blocks", - "version": "15.3.0", + "version": "15.4.0", "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 b72c7392edbaf4..86c34dba2f2d55 100644 --- a/packages/browserslist-config/CHANGELOG.md +++ b/packages/browserslist-config/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.31.0 (2025-09-17) + ## 6.30.0 (2025-09-03) ## 6.29.0 (2025-08-20) diff --git a/packages/browserslist-config/package.json b/packages/browserslist-config/package.json index 18132c93484704..c4e9a616cc4368 100644 --- a/packages/browserslist-config/package.json +++ b/packages/browserslist-config/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/browserslist-config", - "version": "6.30.0", + "version": "6.31.0", "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 7b1b988a3bde7f..3305bc23b76e7a 100644 --- a/packages/commands/CHANGELOG.md +++ b/packages/commands/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 1.31.0 (2025-09-17) + ## 1.30.0 (2025-09-03) ## 1.29.0 (2025-08-20) diff --git a/packages/commands/package.json b/packages/commands/package.json index 6fc608271d4b34..4b7b706a0b9165 100644 --- a/packages/commands/package.json +++ b/packages/commands/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/commands", - "version": "1.30.0", + "version": "1.31.0", "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 12fe5e8199a83e..cb11af1e9209b6 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 30.4.0 (2025-09-17) + ### Bug Fixes - `Modal`: Fix modal headings and labels to use proper editor text color instead of wp-admin colors ([#71311](https://github.com/WordPress/gutenberg/pull/71311)). diff --git a/packages/components/package.json b/packages/components/package.json index 4dea847d90b168..aadf89f17012f5 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/components", - "version": "30.3.0", + "version": "30.4.0", "description": "UI components for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/components/tsconfig.json b/packages/components/tsconfig.json index 09bfef2c53b076..1b5fd7d03571b3 100644 --- a/packages/components/tsconfig.json +++ b/packages/components/tsconfig.json @@ -6,7 +6,8 @@ "gutenberg-env", "gutenberg-test-env", "jest", - "@testing-library/jest-dom" + "@testing-library/jest-dom", + "node" ] }, "references": [ diff --git a/packages/compose/CHANGELOG.md b/packages/compose/CHANGELOG.md index 29b8f60a24b3d6..6f515a5dd7e725 100644 --- a/packages/compose/CHANGELOG.md +++ b/packages/compose/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 7.31.0 (2025-09-17) + ## 7.30.0 (2025-09-03) ## 7.29.0 (2025-08-20) diff --git a/packages/compose/package.json b/packages/compose/package.json index 93e2fae550e581..9f0bc878734e1d 100644 --- a/packages/compose/package.json +++ b/packages/compose/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/compose", - "version": "7.30.0", + "version": "7.31.0", "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 1a8e96f57bbd20..7147368e979c3f 100644 --- a/packages/core-commands/CHANGELOG.md +++ b/packages/core-commands/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 1.31.0 (2025-09-17) + ## 1.30.0 (2025-09-03) ## 1.29.0 (2025-08-20) diff --git a/packages/core-commands/package.json b/packages/core-commands/package.json index f4fef9fcfe8150..7bef2236f36976 100644 --- a/packages/core-commands/package.json +++ b/packages/core-commands/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/core-commands", - "version": "1.30.0", + "version": "1.31.0", "description": "WordPress core reusable commands.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/core-commands/src/site-editor-navigation-commands.js b/packages/core-commands/src/site-editor-navigation-commands.js index 8439b3cb1ced56..3bfd4b6209afff 100644 --- a/packages/core-commands/src/site-editor-navigation-commands.js +++ b/packages/core-commands/src/site-editor-navigation-commands.js @@ -33,7 +33,6 @@ const icons = { post, page, wp_template: layout, - wp_registered_template: layout, wp_template_part: symbolFilled, }; @@ -171,7 +170,7 @@ const getNavigationCommandLoaderPerTemplate = ( templateType ) => return { isBlockBasedTheme: select( coreStore ).getCurrentTheme()?.is_block_theme, - canCreateTemplate: select( coreStore ).canUser( 'read', { + canCreateTemplate: select( coreStore ).canUser( 'create', { kind: 'postType', name: templateType, } ), @@ -470,10 +469,6 @@ export function useSiteEditorNavigationCommands() { name: 'core/edit-site/navigate-templates', hook: getNavigationCommandLoaderPerTemplate( 'wp_template' ), } ); - useCommandLoader( { - name: 'core/edit-site/navigate-templates', - hook: getNavigationCommandLoaderPerTemplate( 'wp_registered_template' ), - } ); useCommandLoader( { name: 'core/edit-site/navigate-template-parts', hook: getNavigationCommandLoaderPerTemplate( 'wp_template_part' ), diff --git a/packages/core-data/CHANGELOG.md b/packages/core-data/CHANGELOG.md index dcca05b6601bfe..fedb261ad7677b 100644 --- a/packages/core-data/CHANGELOG.md +++ b/packages/core-data/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 7.31.0 (2025-09-17) + ## 7.30.0 (2025-09-03) ## 7.29.0 (2025-08-20) diff --git a/packages/core-data/package.json b/packages/core-data/package.json index 1113af1a8291c1..6057777c80d5c6 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/core-data", - "version": "7.30.0", + "version": "7.31.0", "description": "Access to and manipulation of core WordPress entities.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -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", @@ -52,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 07cd87a89cd183..ce72fcff507aee 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -93,16 +93,6 @@ export function receiveEntityRecords( edits, meta ) { - // If we receive an auto-draft template, pretend it's already published. - if ( kind === 'postType' && name === 'wp_template' ) { - records = ( Array.isArray( records ) ? records : [ records ] ).map( - ( record ) => - record.status === 'auto-draft' - ? { ...record, status: 'publish' } - : record - ); - } - // Auto drafts should not have titles, but some plugins rely on them so we can't filter this // on the server. if ( kind === 'postType' ) { @@ -374,7 +364,7 @@ export const deleteEntityRecord = */ export const editEntityRecord = ( kind, name, recordId, edits, options = {} ) => - async ( { select, dispatch, resolveSelect } ) => { + ( { select, dispatch } ) => { logEntityDeprecation( kind, name, 'editEntityRecord' ); const entityConfig = select.getEntityConfig( kind, name ); if ( ! entityConfig ) { @@ -410,66 +400,35 @@ export const editEntityRecord = }; if ( window.__experimentalEnableSync && entityConfig.syncConfig ) { if ( globalThis.IS_GUTENBERG_PLUGIN ) { - const objectId = entityConfig.getSyncObjectId( recordId ); - getSyncProvider().update( - entityConfig.syncObjectType + '--edit', - objectId, - edit.edits - ); - } - } 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 + getSyncProvider().updateCRDTDoc( + entityConfig.syncConfig, + record, + edit.edits, + 'gutenberg' ); - // Temporary solution until we find the right UX: when the user - // modifies a template, we automatically set it active. - // It can be unchecked in multi-entity saving. - // This is to keep the current behaviour where templates are - // immediately active. - if ( - ! options.isCached && - kind === 'postType' && - name === 'wp_template' - ) { - const site = await resolveSelect.getEntityRecord( - 'root', - 'site' - ); - await dispatch.editEntityRecord( - 'root', - 'site', - undefined, - { - active_templates: { - ...site.active_templates, - [ record.slug ]: record.id, - }, - }, - { isCached: true } - ); - } } - 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, + } ); }; /** @@ -714,11 +673,20 @@ export const saveEntityRecord = ), }; } - // Unless there is no persisted record, set the status to - // publish. - if ( name === 'wp_template' && persistedRecord ) { - edits.status = 'publish'; + 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', @@ -732,6 +700,15 @@ export const saveEntityRecord = true, edits ); + if ( + window.__experimentalEnableSync && + entityConfig.syncConfig?.enabled + ) { + getSyncProvider().updateLastPersistedDate( + entityConfig.syncConfig, + persistedRecord + ); + } } } catch ( _error ) { hasError = true; diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 14ec31b3a86240..28e1a50d723f35 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -8,7 +8,16 @@ import { capitalCase, pascalCase } from 'change-case'; */ import apiFetch from '@wordpress/api-fetch'; import { __ } from '@wordpress/i18n'; -import { RichTextData } from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ +import { + applyPostChangesToCRDTDoc, + getInitialPostObjectData, + getPostChangesFromCRDTDoc, + getSyncedPropertiesForPostType, +} from './utils/crdt'; export const DEFAULT_ENTITY_KEY = 'id'; const POST_RAW_ATTRIBUTES = [ 'title', 'excerpt', 'content' ]; @@ -40,24 +49,6 @@ export const rootEntitiesConfig = [ // The entity doesn't support selecting multiple records. // 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(); - }, - }, - syncObjectType: 'root/base', - getSyncObjectId: () => 'index', }, { label: __( 'Post Type' ), @@ -67,26 +58,6 @@ export const rootEntitiesConfig = [ baseURL: '/wp/v2/types', 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(); - }, - }, - syncObjectType: 'root/postType', - getSyncObjectId: ( id ) => id, }, { name: 'media', @@ -276,29 +247,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; - return { - ...rest, - attributes: makeBlockAttributesSerializable( attributes ), - innerBlocks: makeBlocksSerializable( innerBlocks ), - }; - } ); -} - /** * Returns the list of post type entities. * @@ -306,13 +254,15 @@ function makeBlocksSerializable( blocks ) { */ 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( name ); const namespace = postType?.rest_namespace ?? 'wp/v2'; + const syncedProperties = getSyncedPropertiesForPostType( postType ); + return { kind: 'postType', baseURL: `/${ namespace }/${ postType.rest_base }`, @@ -334,39 +284,106 @@ 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`, - } ); + /** + * Is syncing enabled for this entity? + * + * @type {boolean} + */ + enabled: Boolean( + postType.supports?.[ 'collaborative-editing' ] && + postType.supports?.editor + ), + + /** + * Apply changes from the local editor to the local CRDT document so + * that those changes can be synced to other peers (via the provider). + * + * @param {import('@wordpress/sync').CRDTDoc} crdtDoc + * @param {Partial< import('@wordpress/sync').ObjectData >} changes + * @param {import('@wordpress/sync').ObjectData} record + * @param {string} origin + * @return {void} + */ + applyChangesToCRDTDoc: ( crdtDoc, changes, record, origin ) => { + applyPostChangesToCRDTDoc( + crdtDoc, + changes, + record, + postType, + syncedProperties, + origin + ); }, - 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 ) - ); - } + /** + * 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 + * @return {Partial< import('@wordpress/sync').ObjectData >} Changes to record + */ + getChangesFromCRDTDoc: ( crdtDoc, record ) => + getPostChangesFromCRDTDoc( + crdtDoc, + record, + postType, + syncedProperties + ), - value = serialisableBlocksCache.get( value ); - } + /** + * 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 ) => + getInitialPostObjectData( + record, + postType, + syncedProperties + ), - if ( document.get( key ) !== value ) { - document.set( key, value ); - } - } - } ); - }, - fromCRDTDoc: ( doc ) => { - return doc.getMap( 'document' ).toJSON(); + /** + * 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, + + /** + * The object type for the entity, used to scope CRDT documents. + * + * @type {import('@wordpress/sync').ObjectType} + */ + objectType: `postType/${ postType.slug }`, + + /** + * 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, }, + + /** + * The properties that should be synced via the CRDT document. + * + * @type {Set< string >} + */ + syncedProperties, }, - syncObjectType: 'postType/' + postType.name, - getSyncObjectId: ( id ) => id, supportsPagination: true, getRevisionsUrl: ( parentId, revisionId ) => `/${ namespace }/${ @@ -374,7 +391,7 @@ async function loadPostTypeEntities() { }/${ parentId }/revisions${ revisionId ? '/' + revisionId : '' }`, - revisionKey: DEFAULT_ENTITY_KEY, + revisionKey: isTemplate ? 'wp_id' : DEFAULT_ENTITY_KEY, }; } ); } @@ -413,24 +430,6 @@ async function loadSiteEntity() { name: 'site', 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(); - }, - }, - syncObjectType: 'root/site', - getSyncObjectId: () => 'index', meta: {}, }; diff --git a/packages/core-data/src/private-actions.js b/packages/core-data/src/private-actions.js index 0ed6e9748b552a..29af65ba137bf6 100644 --- a/packages/core-data/src/private-actions.js +++ b/packages/core-data/src/private-actions.js @@ -132,7 +132,3 @@ export const editMediaEntity = dispatch.__unstableReleaseStoreLock( lock ); } }; - -export function receiveTemplateAutoDraftId( target, id ) { - return { type: 'RECEIVE_TEMPLATE_AUTO_DRAFT_ID', target, id }; -} diff --git a/packages/core-data/src/private-selectors.ts b/packages/core-data/src/private-selectors.ts index 0b3e67e7ce3eed..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; } /** @@ -163,9 +164,6 @@ export const getHomePage = createRegistrySelector( ( select ) => return { postType: 'wp_template', postId: frontPageTemplateId }; }, ( state ) => [ - // Even though getDefaultTemplateId.shouldInvalidate returns true when root/site changes, - // it doesn't seem to invalidate this cache, I'm not sure why. - getEntityRecord( state, 'root', 'site' ), getEntityRecord( state, 'root', '__unstableBase' ), getDefaultTemplateId( state, { slug: 'front-page', @@ -268,10 +266,3 @@ export const getTemplateId = createRegistrySelector( } ); } ); - -export function getTemplateAutoDraftId( - state: State, - staticTemplateId: string -) { - return state.templateAutoDraftId[ staticTemplateId ]; -} diff --git a/packages/core-data/src/reducer.js b/packages/core-data/src/reducer.js index 051dc884a3f079..2d53bf5762702b 100644 --- a/packages/core-data/src/reducer.js +++ b/packages/core-data/src/reducer.js @@ -628,12 +628,6 @@ export function registeredPostMeta( state = {}, action ) { return state; } -export function templateAutoDraftId( state = {}, action ) { - return action.type === 'RECEIVE_TEMPLATE_AUTO_DRAFT_ID' - ? { ...state, [ action.target ]: action.id } - : state; -} - export default combineReducers( { users, currentTheme, @@ -654,5 +648,4 @@ export default combineReducers( { navigationFallbackId, defaultTemplates, registeredPostMeta, - templateAutoDraftId, } ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index d9518c3782bd44..6e9368b4eb55df 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -66,18 +66,6 @@ export const getCurrentUser = export const getEntityRecord = ( kind, name, key = '', query ) => async ( { select, dispatch, registry, resolveSelect } ) => { - // For back-compat, we allow querying for static templates through - // wp_template. - if ( - kind === 'postType' && - name === 'wp_template' && - typeof key === 'string' && - // __experimentalGetDirtyEntityRecords always calls getEntityRecord - // with a string key, so we need that it's not a numeric ID. - ! /^\d+$/.test( key ) - ) { - name = 'wp_registered_template'; - } const configs = await resolveSelect.getEntitiesConfig( kind ); const entityConfig = configs.find( ( config ) => config.name === name && config.kind === kind @@ -85,159 +73,136 @@ 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 ( - 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 - ); - } - ); + 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(), + }; + } - // Bootstraps the edited document as well (and load from peers). - await getSyncProvider().bootstrap( - entityConfig.syncObjectType + '--edit', - objectId, - ( record ) => { - dispatch( { - type: 'EDIT_ENTITY_RECORD', - kind, - name, - recordId: key, - edits: record, - meta: { - undo: undefined, - }, - } ); - } - ); - } - } 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(), - }; + if ( query !== undefined && query._fields ) { + // 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 `getEntityRecord` resolution to occur. + const hasRecord = select.hasEntityRecord( + kind, + name, + key, + query + ); + if ( hasRecord ) { + return; } + } - if ( query !== undefined && query._fields ) { - // 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 `getEntityRecord` resolution to occur. - const hasRecord = select.hasEntityRecord( - kind, - name, - key, - query - ); - if ( hasRecord ) { - return; - } + const path = addQueryArgs( + entityConfig.baseURL + ( key ? '/' + key : '' ), + { + ...entityConfig.baseURLParams, + ...query, } + ); - const path = addQueryArgs( - entityConfig.baseURL + ( key ? '/' + key : '' ), - { - ...entityConfig.baseURLParams, - ...query, - } - ); - const response = await apiFetch( { path, parse: false } ); - const record = await response.json(); - const permissions = getUserPermissionsFromAllowHeader( - response.headers?.get( 'allow' ) - ); + 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 ]; + 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 }, - ] ); - } + canUserResolutionsArgs.push( [ + action, + { kind, name, id: key }, + ] ); + } - registry.batch( () => { - dispatch.receiveEntityRecords( kind, name, record, query ); - dispatch.receiveUserPermissions( - receiveUserPermissionArgs - ); - dispatch.finishResolutions( - 'canUser', - canUserResolutionsArgs + if ( + window.__experimentalEnableSync && + entityConfig.syncConfig?.enabled && + ! query + ) { + if ( globalThis.IS_GUTENBERG_PLUGIN ) { + await getSyncProvider().bootstrap( + // Bootstrap syncing for the entity. + entityConfig.syncConfig, + record, + { + // Handle edits sourced from the sync provider. + editRecord: ( edits ) => { + if ( ! Object.keys( edits ).length ) { + return; + } + + 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 + ), + // Refetch the persisted entity record. + refetchPersistedRecord: () => { + void ( async () => { + dispatch.receiveEntityRecords( + kind, + name, + await apiFetch( { path, parse: true } ), + query + ); + } )(); + }, + } ); - } ); + } } + + registry.batch( () => { + dispatch.receiveEntityRecords( kind, name, record, query ); + dispatch.receiveUserPermissions( receiveUserPermissionArgs ); + dispatch.finishResolutions( 'canUser', canUserResolutionsArgs ); + } ); } finally { dispatch.__unstableReleaseStoreLock( lock ); } }; -export const getTemplateAutoDraftId = - ( staticTemplateId ) => - async ( { resolveSelect, dispatch } ) => { - const record = await resolveSelect.getEntityRecord( - 'postType', - 'wp_registered_template', - staticTemplateId - ); - const autoDraft = await dispatch.saveEntityRecord( - 'postType', - 'wp_template', - { - ...record, - id: undefined, - type: 'wp_template', - status: 'auto-draft', - } - ); - await dispatch.receiveTemplateAutoDraftId( - staticTemplateId, - autoDraft.id - ); - }; - /** * Requests an entity's record from the REST API. */ @@ -866,37 +831,23 @@ export const getDefaultTemplateId = // Wait for the the entities config to be loaded, otherwise receiving // the template as an entity will not work. await resolveSelect.getEntitiesConfig( 'postType' ); - const id = template?.wp_id || template?.id; // Endpoint may return an empty object if no template is found. - if ( id ) { - template.id = id; - template.type = - typeof id === 'string' - ? 'wp_registered_template' - : 'wp_template'; + if ( template?.id ) { registry.batch( () => { - dispatch.receiveDefaultTemplateId( query, id ); - dispatch.receiveEntityRecords( 'postType', template.type, [ + dispatch.receiveDefaultTemplateId( query, template.id ); + dispatch.receiveEntityRecords( 'postType', 'wp_template', [ template, ] ); // Avoid further network requests. dispatch.finishResolution( 'getEntityRecord', [ 'postType', - template.type, - id, + 'wp_template', + template.id, ] ); } ); } }; -getDefaultTemplateId.shouldInvalidate = ( action ) => { - return ( - action.type === 'EDIT_ENTITY_RECORD' && - action.kind === 'root' && - action.name === 'site' - ); -}; - /** * Requests an entity's revisions from the REST API. * diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 4f5504b50ae0ec..85f5ea88ab37c1 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, @@ -49,7 +50,6 @@ export interface State { userPatternCategories: Array< UserPatternCategory >; defaultTemplates: Record< string, string >; registeredPostMeta: Record< string, Object >; - templateAutoDraftId: Record< string, number | null >; } type EntityRecordKey = string | number; @@ -359,18 +359,6 @@ export const getEntityRecord = createSelector( ): EntityRecord | undefined => { logEntityDeprecation( kind, name, 'getEntityRecord' ); - // For back-compat, we allow querying for static templates through - // wp_template. - if ( - kind === 'postType' && - name === 'wp_template' && - typeof key === 'string' && - // __experimentalGetDirtyEntityRecords always calls getEntityRecord - // with a string key, so we need that it's not a numeric ID. - ! /^\d+$/.test( key ) - ) { - name = 'wp_registered_template'; - } const queriedState = state.entities.records?.[ kind ]?.[ name ]?.queriedData; if ( ! queriedState ) { @@ -1139,7 +1127,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(); } /** @@ -1151,7 +1139,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/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..722b620d66eb7c --- /dev/null +++ b/packages/core-data/src/sync.ts @@ -0,0 +1,47 @@ +/** + * WordPress dependencies + */ +import { applyFilters } from '@wordpress/hooks'; +import { getWebRTCSyncProvider, SyncProvider } from '@wordpress/sync'; + +declare global { + interface Window { + __experimentalEnableSync?: boolean; + } +} + +let syncProvider: SyncProvider | null = null; + +/** + * 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 = new SyncProvider(); + + 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. + if ( ! syncProvider && window.__experimentalEnableSync ) { + syncProvider = getWebRTCSyncProvider(); + } + + // If no sync provider is set, use the fallback no-op sync provider. + if ( ! syncProvider ) { + syncProvider = fallbackNoOpSyncProvider; + } + + return syncProvider; +} diff --git a/packages/core-data/src/test/actions.js b/packages/core-data/src/test/actions.js index 4e35a6dcd8ecab..dd9c344f4be7e4 100644 --- a/packages/core-data/src/test/actions.js +++ b/packages/core-data/src/test/actions.js @@ -5,6 +5,16 @@ import apiFetch from '@wordpress/api-fetch'; jest.mock( '@wordpress/api-fetch' ); +// Mock the sync provider +jest.mock( '../sync', () => ( { + getSyncProvider: jest.fn( () => ( { + updateCRDTDoc: jest.fn(), + } ) ), +} ) ); + +// Mock logEntityDeprecation +jest.mock( '../utils/log-entity-deprecation', () => jest.fn() ); + /** * Internal dependencies */ @@ -19,6 +29,8 @@ import { __experimentalBatch, } from '../actions'; +import { getSyncProvider } from '../sync'; + jest.mock( '../batch', () => { const { createBatch } = jest.requireActual( '../batch' ); return { @@ -29,27 +41,422 @@ jest.mock( '../batch', () => { } ); describe( 'editEntityRecord', () => { + let select, dispatch, mockUndoManager; + + beforeEach( () => { + jest.clearAllMocks(); + + mockUndoManager = { + addRecord: jest.fn(), + }; + + select = { + getEntityConfig: jest.fn(), + getRawEntityRecord: jest.fn(), + getEditedEntityRecord: jest.fn(), + getUndoManager: jest.fn( () => mockUndoManager ), + }; + + dispatch = jest.fn(); + + getSyncProvider.mockReturnValue( { + updateCRDTDoc: jest.fn(), + } ); + } ); + it( 'throws when the edited entity does not have a loaded config.', async () => { const entityConfig = { kind: 'someKind', name: 'someName', id: 'someId', }; - const select = { - getEntityConfig: jest.fn(), - }; - const fulfillment = async () => + select.getEntityConfig.mockReturnValue( null ); + + const fulfillment = () => editEntityRecord( entityConfig.kind, entityConfig.name, entityConfig.id, {} )( { select } ); - await expect( fulfillment ).rejects.toThrow( + expect( fulfillment ).toThrow( `The entity being edited (${ entityConfig.kind }, ${ entityConfig.name }) does not have a loaded config.` ); expect( select.getEntityConfig ).toHaveBeenCalledTimes( 1 ); } ); + + it( 'dispatches EDIT_ENTITY_RECORD action with basic edits and no sync', () => { + const entityConfig = { + kind: 'postType', + name: 'post', + mergedEdits: {}, + }; + const record = { + id: 1, + title: 'Original Title', + content: 'Original Content', + }; + const editedRecord = { + id: 1, + title: 'Original Title', + content: 'Original Content', + }; + const edits = { title: 'New Title' }; + + select.getEntityConfig.mockReturnValue( entityConfig ); + select.getRawEntityRecord.mockReturnValue( record ); + select.getEditedEntityRecord.mockReturnValue( editedRecord ); + + editEntityRecord( + 'postType', + 'post', + 1, + edits + )( { select, dispatch } ); + + expect( dispatch ).toHaveBeenCalledWith( { + type: 'EDIT_ENTITY_RECORD', + kind: 'postType', + name: 'post', + recordId: 1, + edits, + } ); + expect( mockUndoManager.addRecord ).toHaveBeenCalledWith( + [ + { + id: { kind: 'postType', name: 'post', recordId: 1 }, + changes: { + title: { + from: 'Original Title', + to: 'New Title', + }, + }, + }, + ], + undefined + ); + } ); + + it( 'clears edits when they are equal to persisted values', () => { + const entityConfig = { + kind: 'postType', + name: 'post', + mergedEdits: {}, + }; + const record = { + id: 1, + title: 'Same Title', + content: 'Original Content', + }; + const editedRecord = { + id: 1, + title: 'Same Title', + content: 'Original Content', + }; + const edits = { title: undefined }; + + select.getEntityConfig.mockReturnValue( entityConfig ); + select.getRawEntityRecord.mockReturnValue( record ); + select.getEditedEntityRecord.mockReturnValue( editedRecord ); + + editEntityRecord( + 'postType', + 'post', + 1, + edits + )( { select, dispatch } ); + + expect( dispatch ).toHaveBeenCalledWith( { + type: 'EDIT_ENTITY_RECORD', + kind: 'postType', + name: 'post', + recordId: 1, + edits, + } ); + expect( mockUndoManager.addRecord ).toHaveBeenCalledWith( + [ + { + id: { kind: 'postType', name: 'post', recordId: 1 }, + changes: { + title: { + from: 'Same Title', + to: undefined, + }, + }, + }, + ], + undefined + ); + } ); + + it( 'handles merged edits configuration', () => { + const entityConfig = { + kind: 'postType', + name: 'post', + mergedEdits: { meta: true }, + }; + const record = { + id: 1, + title: 'Original Title', + meta: { key1: 'value1' }, + }; + const editedRecord = { + id: 1, + title: 'Original Title', + meta: { key1: 'value1', key2: 'value2' }, + }; + const edits = { meta: { key3: 'value3' } }; + + select.getEntityConfig.mockReturnValue( entityConfig ); + select.getRawEntityRecord.mockReturnValue( record ); + select.getEditedEntityRecord.mockReturnValue( editedRecord ); + + editEntityRecord( + 'postType', + 'post', + 1, + edits + )( { select, dispatch } ); + + expect( dispatch ).toHaveBeenCalledWith( { + type: 'EDIT_ENTITY_RECORD', + kind: 'postType', + name: 'post', + recordId: 1, + edits: { + meta: { key1: 'value1', key2: 'value2', key3: 'value3' }, + }, + } ); + expect( mockUndoManager.addRecord ).toHaveBeenCalledWith( + [ + { + id: { kind: 'postType', name: 'post', recordId: 1 }, + changes: { + meta: { + from: { + key1: 'value1', + key2: 'value2', + }, + to: { + key3: 'value3', + }, + }, + }, + }, + ], + undefined + ); + } ); + + it( 'skips undo manager when undoIgnore option is true', () => { + const entityConfig = { + kind: 'postType', + name: 'post', + mergedEdits: {}, + }; + const record = { id: 1, title: 'Original Title' }; + const editedRecord = { id: 1, title: 'Original Title' }; + const edits = { title: 'New Title' }; + const options = { undoIgnore: true }; + + select.getEntityConfig.mockReturnValue( entityConfig ); + select.getRawEntityRecord.mockReturnValue( record ); + select.getEditedEntityRecord.mockReturnValue( editedRecord ); + + editEntityRecord( + 'postType', + 'post', + 1, + edits, + options + )( { select, dispatch } ); + + expect( mockUndoManager.addRecord ).not.toHaveBeenCalled(); + expect( dispatch ).toHaveBeenCalledWith( { + type: 'EDIT_ENTITY_RECORD', + kind: 'postType', + name: 'post', + recordId: 1, + edits: { + title: 'New Title', + }, + } ); + } ); + + it( 'passes isCached option to undo manager', () => { + const entityConfig = { + kind: 'postType', + name: 'post', + mergedEdits: {}, + }; + const record = { id: 1, title: 'Original Title' }; + const editedRecord = { id: 1, title: 'Original Title' }; + const edits = { title: 'New Title' }; + const options = { isCached: true }; + + select.getEntityConfig.mockReturnValue( entityConfig ); + select.getRawEntityRecord.mockReturnValue( record ); + select.getEditedEntityRecord.mockReturnValue( editedRecord ); + + editEntityRecord( + 'postType', + 'post', + 1, + edits, + options + )( { select, dispatch } ); + + expect( mockUndoManager.addRecord ).toHaveBeenCalledWith( + [ + { + id: { kind: 'postType', name: 'post', recordId: 1 }, + changes: { + title: { + from: 'Original Title', + to: 'New Title', + }, + }, + }, + ], + true + ); + expect( dispatch ).toHaveBeenCalledWith( { + type: 'EDIT_ENTITY_RECORD', + kind: 'postType', + name: 'post', + recordId: 1, + edits: { + title: 'New Title', + }, + } ); + } ); + + it( 'handles multiple simultaneous edits', () => { + const entityConfig = { + kind: 'postType', + name: 'post', + mergedEdits: {}, + }; + const record = { + id: 1, + title: 'Original Title', + content: 'Original Content', + status: 'draft', + }; + const editedRecord = { + id: 1, + title: 'Original Title', + content: 'Original Content', + status: 'draft', + }; + const edits = { + title: 'New Title', + content: 'New Content', + status: 'publish', + }; + + select.getEntityConfig.mockReturnValue( entityConfig ); + select.getRawEntityRecord.mockReturnValue( record ); + select.getEditedEntityRecord.mockReturnValue( editedRecord ); + + editEntityRecord( + 'postType', + 'post', + 1, + edits + )( { select, dispatch } ); + + expect( dispatch ).toHaveBeenCalledWith( { + type: 'EDIT_ENTITY_RECORD', + kind: 'postType', + name: 'post', + recordId: 1, + edits: { + title: 'New Title', + content: 'New Content', + status: 'publish', + }, + } ); + + expect( mockUndoManager.addRecord ).toHaveBeenCalledWith( + [ + { + id: { kind: 'postType', name: 'post', recordId: 1 }, + changes: { + title: { from: 'Original Title', to: 'New Title' }, + content: { + from: 'Original Content', + to: 'New Content', + }, + status: { from: 'draft', to: 'publish' }, + }, + }, + ], + undefined + ); + } ); + + it( 'works correctly with sync-enabled entity config', () => { + globalThis.window.__experimentalEnableSync = true; + const syncConfig = { enabled: true }; + // Test that sync-related config doesn't break normal functionality + + const entityConfig = { + kind: 'postType', + name: 'post', + mergedEdits: {}, + syncConfig, + }; + const record = { id: 1, title: 'Original Title' }; + const editedRecord = { id: 1, title: 'Original Title' }; + const edits = { title: 'New Title' }; + + select.getEntityConfig.mockReturnValue( entityConfig ); + select.getRawEntityRecord.mockReturnValue( record ); + select.getEditedEntityRecord.mockReturnValue( editedRecord ); + + editEntityRecord( + 'postType', + 'post', + 1, + edits + )( { select, dispatch } ); + + // Normal functionality should work regardless of sync config + expect( dispatch ).toHaveBeenCalledWith( { + type: 'EDIT_ENTITY_RECORD', + kind: 'postType', + name: 'post', + recordId: 1, + edits: { title: 'New Title' }, + } ); + + expect( mockUndoManager.addRecord ).toHaveBeenCalledWith( + [ + { + id: { kind: 'postType', name: 'post', recordId: 1 }, + changes: { + title: { + from: 'Original Title', + to: 'New Title', + }, + }, + }, + ], + undefined + ); + + expect( getSyncProvider ).toBeDefined(); + const mockUpdateCRDTDoc = getSyncProvider().updateCRDTDoc; + expect( mockUpdateCRDTDoc ).toBeDefined(); + expect( mockUpdateCRDTDoc ).toHaveBeenCalledWith( + syncConfig, + record, + edits, + 'gutenberg' + ); + delete globalThis.window.__experimentalEnableSync; + } ); } ); describe( 'deleteEntityRecord', () => { @@ -507,6 +914,169 @@ describe( 'saveEntityRecord', () => { expect( result ).toBe( postType ); } ); + + it( 'handles sync functionality when experimental sync is enabled', async () => { + globalThis.window.__experimentalEnableSync = true; + + const mockCreateEntityMeta = jest + .fn() + .mockResolvedValue( { syncMeta: 'test' } ); + const mockUpdateLastPersistedDate = jest.fn(); + getSyncProvider.mockReturnValue( { + createEntityMeta: mockCreateEntityMeta, + updateLastPersistedDate: mockUpdateLastPersistedDate, + } ); + + const post = { id: 10, title: 'test post', meta: { existing: 'data' } }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + syncConfig: { enabled: true }, + }, + ]; + const select = { + getRawEntityRecord: () => post, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + const updatedRecord = { ...post, id: 10 }; + apiFetch.mockImplementation( () => updatedRecord ); + + const result = await saveEntityRecord( + 'postType', + 'post', + post + )( { select, dispatch, resolveSelect } ); + + // Verify createEntityMeta was called before persisting + expect( mockCreateEntityMeta ).toHaveBeenCalledWith( + { enabled: true }, + { + id: 10, + title: 'test post', + meta: { existing: 'data' }, + } + ); + + // Verify the request was made with merged meta + expect( apiFetch ).toHaveBeenCalledWith( { + path: '/wp/v2/posts/10', + method: 'PUT', + data: { + ...post, + meta: { + existing: 'data', + syncMeta: 'test', + }, + }, + } ); + + // Verify updateLastPersistedDate was called after successful save + expect( mockUpdateLastPersistedDate ).toHaveBeenCalledWith( + { enabled: true }, + post + ); + + expect( result ).toBe( updatedRecord ); + + delete globalThis.window.__experimentalEnableSync; + } ); + + it( 'skips sync when experimental sync is disabled', async () => { + const mockCreateEntityMeta = jest.fn(); + const mockUpdateLastPersistedDate = jest.fn(); + getSyncProvider.mockReturnValue( { + createEntityMeta: mockCreateEntityMeta, + updateLastPersistedDate: mockUpdateLastPersistedDate, + } ); + + const post = { id: 10, title: 'test post', meta: { existing: 'data' } }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + syncConfig: { enabled: true }, + }, + ]; + const select = { + getRawEntityRecord: () => post, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + const updatedRecord = { ...post, id: 10 }; + apiFetch.mockImplementation( () => updatedRecord ); + + const result = await saveEntityRecord( + 'postType', + 'post', + post + )( { select, dispatch, resolveSelect } ); + + // Verify sync functions were not called + expect( mockCreateEntityMeta ).not.toHaveBeenCalled(); + expect( mockUpdateLastPersistedDate ).not.toHaveBeenCalled(); + + // Verify normal save still works + expect( apiFetch ).toHaveBeenCalledWith( { + path: '/wp/v2/posts/10', + method: 'PUT', + data: post, + } ); + + expect( result ).toBe( updatedRecord ); + } ); + + it( 'skips sync when entity has no syncConfig', async () => { + globalThis.window.__experimentalEnableSync = true; + + const mockCreateEntityMeta = jest.fn(); + const mockUpdateLastPersistedDate = jest.fn(); + getSyncProvider.mockReturnValue( { + createEntityMeta: mockCreateEntityMeta, + updateLastPersistedDate: mockUpdateLastPersistedDate, + } ); + + const post = { id: 10, title: 'test post', meta: { existing: 'data' } }; + const configs = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + // No syncConfig property + }, + ]; + const select = { + getRawEntityRecord: () => post, + }; + const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) }; + + const updatedRecord = { ...post, id: 10, updated: true }; + apiFetch.mockImplementation( () => updatedRecord ); + + const result = await saveEntityRecord( + 'postType', + 'post', + post + )( { select, dispatch, resolveSelect } ); + + // Verify sync functions were not called + expect( mockCreateEntityMeta ).not.toHaveBeenCalled(); + expect( mockUpdateLastPersistedDate ).not.toHaveBeenCalled(); + + // Verify normal save still works + expect( apiFetch ).toHaveBeenCalledWith( { + path: '/wp/v2/posts/10', + method: 'PUT', + data: post, + } ); + + expect( result ).toBe( updatedRecord ); + + delete globalThis.window.__experimentalEnableSync; + } ); } ); describe( 'receiveUserPermission', () => { diff --git a/packages/core-data/src/test/entities.js b/packages/core-data/src/test/entities.js index 142debcd61c67f..303967268cc6cd 100644 --- a/packages/core-data/src/test/entities.js +++ b/packages/core-data/src/test/entities.js @@ -100,3 +100,64 @@ describe( 'loadTaxonomyEntities', () => { expect( entities[ 0 ].supportsPagination ).toBe( true ); } ); } ); + +describe( 'loadPostTypeEntities', () => { + beforeEach( () => { + apiFetch.mockReset(); + } ); + + it( 'should add required properties to post type entities', async () => { + const mockPostTypes = { + post: { + name: 'Posts', + rest_base: 'posts', + rest_namespace: 'wp/v2', + slug: 'post', + supports: { + 'custom-fields': true, + editor: true, + 'collaborative-editing': true, + }, + }, + }; + + apiFetch.mockResolvedValueOnce( mockPostTypes ); + + const postTypeLoader = additionalEntityConfigLoaders.find( + ( loader ) => loader.kind === 'postType' + ); + const entities = await postTypeLoader.loadEntities(); + + expect( entities[ 0 ].supportsPagination ).toBe( true ); + expect( entities[ 0 ].transientEdits ).toEqual( { + blocks: true, + selection: true, + } ); + expect( entities[ 0 ].mergedEdits ).toEqual( { meta: true } ); + expect( entities[ 0 ].rawAttributes ).toBeDefined(); + expect( entities[ 0 ].__unstable_rest_base ).toBe( 'posts' ); + expect( entities[ 0 ].syncConfig ).toBeDefined(); + expect( entities[ 0 ].syncConfig.enabled ).toBe( true ); + expect( typeof entities[ 0 ].syncConfig.applyChangesToCRDTDoc ).toBe( + 'function' + ); + expect( typeof entities[ 0 ].syncConfig.getChangesFromCRDTDoc ).toBe( + 'function' + ); + expect( typeof entities[ 0 ].syncConfig.getInitialObjectData ).toBe( + 'function' + ); + expect( typeof entities[ 0 ].syncConfig.getObjectId ).toBe( + 'function' + ); + expect( entities[ 0 ].syncConfig.objectType ).toBe( 'postType/post' ); + expect( entities[ 0 ].syncConfig.supports ).toEqual( { + awareness: true, + crdtPersistence: true, + undo: true, + } ); + expect( entities[ 0 ].syncConfig.syncedProperties ).toBeDefined(); + expect( typeof entities[ 0 ].getRevisionsUrl ).toBe( 'function' ); + expect( entities[ 0 ].revisionKey ).toBe( 'id' ); + } ); +} ); diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index b37c1e891403b4..079e8a9396957c 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -5,6 +5,13 @@ import triggerFetch from '@wordpress/api-fetch'; jest.mock( '@wordpress/api-fetch' ); +// Mock the sync provider +jest.mock( '../sync', () => ( { + getSyncProvider: jest.fn( () => ( { + bootstrap: jest.fn(), + } ) ), +} ) ); + /** * Internal dependencies */ @@ -16,6 +23,7 @@ import { getAutosaves, getCurrentUser, } from '../resolvers'; +import { getSyncProvider } from '../sync'; describe( 'getEntityRecord', () => { const POST_TYPE = { slug: 'post' }; @@ -41,6 +49,7 @@ describe( 'getEntityRecord', () => { finishResolutions: jest.fn(), } ); triggerFetch.mockReset(); + getSyncProvider.mockClear(); } ); it( 'yields with requested post type', async () => { @@ -111,6 +120,158 @@ describe( 'getEntityRecord', () => { 1 ); } ); + + it( 'bootstraps sync provider when experimentalEnableSync is enabled', async () => { + const POST_RECORD = { id: 1, title: 'Test Post' }; + const POST_RESPONSE = { + json: () => Promise.resolve( POST_RECORD ), + headers: { + get: () => null, + }, + }; + const ENTITIES_WITH_SYNC = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + baseURLParams: { context: 'edit' }, + syncConfig: { + enabled: true, + objectType: 'postType/post', + }, + }, + ]; + + window.__experimentalEnableSync = true; + const mockBootstrap = jest.fn(); + getSyncProvider.mockReturnValue( { + bootstrap: mockBootstrap, + } ); + + const resolveSelectWithSync = { + getEntitiesConfig: jest.fn( () => ENTITIES_WITH_SYNC ), + getEditedEntityRecord: jest.fn(), + }; + + triggerFetch.mockImplementation( () => POST_RESPONSE ); + + await getEntityRecord( + 'postType', + 'post', + 1 + )( { + dispatch, + registry, + resolveSelect: resolveSelectWithSync, + } ); + + // Verify bootstrap was called with correct arguments + expect( mockBootstrap ).toHaveBeenCalledTimes( 1 ); + expect( mockBootstrap ).toHaveBeenCalledWith( + ENTITIES_WITH_SYNC[ 0 ].syncConfig, + POST_RECORD, + expect.objectContaining( { + editRecord: expect.any( Function ), + getEditedRecord: expect.any( Function ), + refetchPersistedRecord: expect.any( Function ), + } ) + ); + + delete window.__experimentalEnableSync; + } ); + + it( 'does not bootstrap sync when query is present', async () => { + const POST_RECORD = { id: 1, title: 'Test Post' }; + const POST_RESPONSE = { + json: () => Promise.resolve( POST_RECORD ), + headers: { + get: () => null, + }, + }; + const ENTITIES_WITH_SYNC = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + baseURLParams: { context: 'edit' }, + syncConfig: { + enabled: true, + objectType: 'postType/post', + }, + }, + ]; + + window.__experimentalEnableSync = true; + + const mockBootstrap = jest.fn(); + getSyncProvider.mockReturnValue( { + bootstrap: mockBootstrap, + } ); + + const resolveSelectWithSync = { + getEntitiesConfig: jest.fn( () => ENTITIES_WITH_SYNC ), + }; + + triggerFetch.mockImplementation( () => POST_RESPONSE ); + + // Call with a query parameter + await getEntityRecord( 'postType', 'post', 1, { context: 'view' } )( { + dispatch, + registry, + resolveSelect: resolveSelectWithSync, + } ); + + // Verify bootstrap was NOT called + expect( mockBootstrap ).not.toHaveBeenCalled(); + + delete window.__experimentalEnableSync; + } ); + + it( 'does not bootstrap sync when experimentalEnableSync is disabled', async () => { + const POST_RECORD = { id: 1, title: 'Test Post' }; + const POST_RESPONSE = { + json: () => Promise.resolve( POST_RECORD ), + headers: { + get: () => null, + }, + }; + const ENTITIES_WITH_SYNC = [ + { + name: 'post', + kind: 'postType', + baseURL: '/wp/v2/posts', + baseURLParams: { context: 'edit' }, + syncConfig: { + enabled: true, + objectType: 'postType/post', + }, + }, + ]; + + const mockBootstrap = jest.fn(); + getSyncProvider.mockReturnValue( { + bootstrap: mockBootstrap, + } ); + + const resolveSelectWithSync = { + getEntitiesConfig: jest.fn( () => ENTITIES_WITH_SYNC ), + }; + + triggerFetch.mockImplementation( () => POST_RESPONSE ); + + await getEntityRecord( + 'postType', + 'post', + 1 + )( { + dispatch, + registry, + resolveSelect: resolveSelectWithSync, + } ); + + // Verify bootstrap was NOT called + expect( mockBootstrap ).not.toHaveBeenCalled(); + } ); } ); describe( 'getEntityRecords', () => { 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..5683ab38ebde09 --- /dev/null +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -0,0 +1,425 @@ +/** + * 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'; + +// @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; +} + +interface BlockType { + name: string; + attributes?: Record< string, { type?: string } >; +} + +export interface Block { + attributes: BlockAttributes; + clientId?: string; + innerBlocks: Block[]; + originalContent?: string; // unserializable + validationIssues?: string[]; // unserializable + 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 >. */ + | 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 +// accessed -- or type casting with `as`. +// export type YBlock = 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< YBlock > +): Block[] { + return blocks.map( ( block: Block | YBlock ) => { + const blockAsJson = block instanceof Y.Map ? block.toJSON() : block; + const { name, innerBlocks, attributes, ...rest } = blockAsJson; + delete rest.validationIssues; + delete rest.originalContent; + // delete rest.isValid + return { + ...rest, + name, + attributes: makeBlockAttributesSerializable( attributes ), + innerBlocks: makeBlocksSerializable( innerBlocks ), + }; + } ); +} + +/** + * @param {any} gblock + * @param {Y.Map} yblock + */ +function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean { + const yblockAsJson = yblock.toJSON(); + + // we must not sync clientId, as this can't be generated consistently and + // hence will lead to merge conflicts. + const overwrites = { + innerBlocks: null, + clientId: null, + }; + const res = fun.equalityDeep( + Object.assign( {}, gblock, overwrites ), + Object.assign( {}, yblockAsJson, overwrites ) + ); + const inners = gblock.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.get( i ) ) + ) + ); +} + +function createNewYAttributeMap( + blockName: string, + attributes: BlockAttributes +): YBlockAttributes { + return new Y.Map( + Object.entries( attributes ).map( + ( [ 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 ] ) => { + switch ( key ) { + case 'attributes': { + return [ key, createNewYAttributeMap( block.name, value ) ]; + } + + case 'innerBlocks': { + const innerBlocks = new Y.Array(); + + // If not an array, set to empty Y.Array. + if ( ! Array.isArray( value ) ) { + return [ key, innerBlocks ]; + } + + innerBlocks.insert( + 0, + value.map( ( innerBlock: Block ) => + createNewYBlock( innerBlock ) + ) + ); + + return [ key, innerBlocks ]; + } + + 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. + * + * @param yblocks The blocks in the local Y.Doc. + * @param incomingBlocks Gutenberg blocks being synced. + * @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 + _origin: string // eslint-disable-line @typescript-eslint/no-unused-vars +): void { + // Ensure we are working with serializable block data. + if ( ! serializableBlocksCache.has( incomingBlocks ) ) { + serializableBlocksCache.set( + incomingBlocks, + makeBlocksSerializable( incomingBlocks ) + ); + } + const allBlocks = serializableBlocksCache.get( incomingBlocks ) ?? []; + + // Ensure we skip blocks that we don't want to sync at the moment + const blocksToSync = allBlocks.filter( ( block ) => + shouldBlockBeSynced( block ) + ); + + // 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( + blocksToSync.length ?? 0, + yblocks.length + ); + + let left = 0; + let right = 0; + + // skip equal blocks from left + for ( + ; + left < numOfCommonEntries && + areBlocksEqual( blocksToSync[ left ], yblocks.get( left ) ); + left++ + ) { + /* nop */ + } + + // skip equal blocks from right + for ( + ; + right < numOfCommonEntries - left && + areBlocksEqual( + blocksToSync[ blocksToSync.length - right - 1 ], + yblocks.get( yblocks.length - right - 1 ) + ); + right++ + ) { + /* nop */ + } + + const numOfUpdatesNeeded = numOfCommonEntries - left - right; + const numOfInsertionsNeeded = math.max( + 0, + blocksToSync.length - yblocks.length + ); + const numOfDeletionsNeeded = math.max( + 0, + yblocks.length - blocksToSync.length + ); + + // updates + for ( let i = 0; i < numOfUpdatesNeeded; i++, left++ ) { + const block = blocksToSync[ left ]; + const yblock = yblocks.get( left ); + Object.entries( block ).forEach( ( [ key, value ] ) => { + switch ( key ) { + case 'attributes': { + 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 ), + 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; + } + + case 'innerBlocks': { + // Recursively merge innerBlocks + const yInnerBlocks = yblock.get( key ) as Y.Array< YBlock >; + mergeCrdtBlocks( yInnerBlocks, value ?? [], _origin ); + break; + } + + default: + if ( + ! fun.equalityDeep( block[ key ], yblock.get( key ) ) + ) { + yblock.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++ ) { + const newBlock = [ createNewYBlock( blocksToSync[ left ] ) ]; + + yblocks.insert( left, newBlock ); + } + + // remove duplicate clientids + const knownClientIds = new Set< string >(); + for ( let j = 0; j < yblocks.length; j++ ) { + const yblock: YBlock = yblocks.get( j ); + + let clientId: string = yblock.get( 'clientId' ) as string; + + if ( knownClientIds.has( clientId ) ) { + clientId = uuidv4(); + yblock.set( 'clientId', clientId ); + } + 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 { + // 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 + ); + } + + // Allow all other blocks to be synced. + return 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 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, + attributeName: string +): boolean { + 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 + ); + } + } + + return ( + cachedRichTextAttributes.get( blockName )?.has( attributeName ) ?? false + ); +} diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts new file mode 100644 index 00000000000000..417e33d7c1c376 --- /dev/null +++ b/packages/core-data/src/utils/crdt.ts @@ -0,0 +1,459 @@ +/** + * External dependencies + */ +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, CRDT_RECORD_MAP_KEY, 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 PostChanges = Partial< Post > & { blocks?: Block[] }; + +/** + * 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} rawRecord + * @param {Type} postType + * @param {Set< string >} syncedProperties + * @param {string} origin + * @return {void} + */ +export function applyPostChangesToCRDTDoc( + ydoc: CRDTDoc, + changes: PostChanges, + rawRecord: Post, + postType: Type, + syncedProperties: Set< string >, + origin: string +): void { + const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); + + Object.entries( changes ).forEach( ( [ key, newValue ] ) => { + if ( ! syncedProperties.has( key ) ) { + return; + } + + // Cannot serialize function values, so cannot sync them. + if ( 'function' === typeof newValue ) { + return; + } + + // Set the value in the root document. + function setValue< T = unknown >( updatedValue: T ): void { + ymap.set( key, updatedValue ); + } + + switch ( key ) { + case 'blocks': { + let currentBlocks = ymap.get( 'blocks' ) as Y.Array< YBlock >; + + // Initialize. + if ( ! ( currentBlocks instanceof Y.Array ) ) { + currentBlocks = new Y.Array(); + setValue( currentBlocks ); + } + + // Block[] from local changes. + 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. + mergeCrdtBlocks( currentBlocks, newBlocks, origin ); + break; + } + + case 'excerpt': { + const currentValue = ymap.get( 'excerpt' ) as + | string + | undefined; + const rawNewValue = getRawValue( newValue ); + + mergeValue( currentValue, rawNewValue, setValue ); + 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 ( + ! shouldSyncMetaForPostType( metaKey, postType ) + ) { + 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. + 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 = rawRecord.status; + } + + mergeValue( currentValue, newStatus, setValue ); + break; + } + + case '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 = getRawValue( newValue ); + if ( ! currentValue && 'Auto Draft' === rawNewValue ) { + rawNewValue = ''; + } + + mergeValue( currentValue, rawNewValue, setValue ); + break; + } + + // Add support for additional data types here. + + default: { + const currentValue = ymap.get( key ); + mergeValue( currentValue, newValue, setValue ); + } + } + } ); +} + +/** + * 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 {Post} record + * @param {Type} postType + * @param {Set< string >} syncedProperties + * @return {Partial} The changes that should be applied to the local record. + */ +export function getPostChangesFromCRDTDoc( + ydoc: CRDTDoc, + record: Post, + postType: Type, + syncedProperties: Set< string > +): PostChanges { + const ymap = ydoc.getMap( CRDT_RECORD_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 'meta': { + const allowedMeta = Object.fromEntries( + Object.entries( newValue ?? {} ).filter( + ( [ metaKey ] ) => + shouldSyncMetaForPostType( metaKey, postType ) + ) + ); + + // 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 ) { + return false; + } + + return haveValuesChanged( currentValue, newValue ); + } + + case 'excerpt': + case 'title': { + return haveValuesChanged( + getRawValue( currentValue ), + newValue + ); + } + + // Add support for additional data types here. + + default: { + return haveValuesChanged( currentValue, newValue ); + } + } + } ) + ); +} + +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`). + * + * @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; + } + + if ( + value && + 'object' === typeof value && + 'raw' in value && + 'string' === typeof value.raw + ) { + return value.raw; + } + + return undefined; +} + +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 ) => 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; +} + +const metaDecisionCache: Map< string, Map< string, boolean > > = new Map(); + +/** + * Given a meta key and post type definition, return a decision on whether to + * sync the meta property. + * + * @param {string} metaKey The meta key. + * @param {Type} postType The post type definition. + * @return {boolean} Whether to sync the meta property. + */ +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 )!; + } + + /** + * 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`). + * + * 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 {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 shouldSync = Boolean( + applyFilters( + 'sync.shouldSyncMeta', + ! metaKey.startsWith( '_' ), + metaKey, + postType.slug, + postType + ) + ); + + decisionMap.set( metaKey, shouldSync ); + + return shouldSync; +} diff --git a/packages/core-data/tsconfig.json b/packages/core-data/tsconfig.json index 57c9d208e4c689..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" }, @@ -13,6 +14,7 @@ { "path": "../deprecated" }, { "path": "../element" }, { "path": "../html-entities" }, + { "path": "../hooks" }, { "path": "../i18n" }, { "path": "../is-shallow-equal" }, { "path": "../private-apis" }, diff --git a/packages/create-block-interactive-template/package.json b/packages/create-block-interactive-template/package.json index fdd8113609b5b4..e39a4a0fcad11a 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.30.0", + "version": "2.31.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/CHANGELOG.md b/packages/create-block-tutorial-template/CHANGELOG.md index 16e63ec0e6a212..3463bf113a500e 100644 --- a/packages/create-block-tutorial-template/CHANGELOG.md +++ b/packages/create-block-tutorial-template/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.31.0 (2025-09-17) + ## 4.30.0 (2025-09-03) ## 4.29.0 (2025-08-20) diff --git a/packages/create-block-tutorial-template/package.json b/packages/create-block-tutorial-template/package.json index 77d821522bda25..e95efa679ea5a0 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.30.0", + "version": "4.31.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/CHANGELOG.md b/packages/create-block/CHANGELOG.md index 39ed53feed8d76..fb610bc6cd2e60 100644 --- a/packages/create-block/CHANGELOG.md +++ b/packages/create-block/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.74.0 (2025-09-17) + ## 4.73.0 (2025-09-03) ### Enhancement diff --git a/packages/create-block/package.json b/packages/create-block/package.json index 65d739fd9b3338..4cecf2f65fc4bd 100644 --- a/packages/create-block/package.json +++ b/packages/create-block/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/create-block", - "version": "4.73.0", + "version": "4.74.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/CHANGELOG.md b/packages/customize-widgets/CHANGELOG.md index 98de7b27fc2497..0f0587af06f5bb 100644 --- a/packages/customize-widgets/CHANGELOG.md +++ b/packages/customize-widgets/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.31.0 (2025-09-17) + ## 5.30.0 (2025-09-03) ## 5.29.0 (2025-08-20) diff --git a/packages/customize-widgets/package.json b/packages/customize-widgets/package.json index bb14105a7b3cf2..d047317011dbfa 100644 --- a/packages/customize-widgets/package.json +++ b/packages/customize-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/customize-widgets", - "version": "5.30.0", + "version": "5.31.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/CHANGELOG.md b/packages/data-controls/CHANGELOG.md index 3f329be41d8056..8dc4ac78997a52 100644 --- a/packages/data-controls/CHANGELOG.md +++ b/packages/data-controls/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.31.0 (2025-09-17) + ## 4.30.0 (2025-09-03) ## 4.29.0 (2025-08-20) diff --git a/packages/data-controls/package.json b/packages/data-controls/package.json index c927a0b73d2ab9..1247c38aeed020 100644 --- a/packages/data-controls/package.json +++ b/packages/data-controls/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/data-controls", - "version": "4.30.0", + "version": "4.31.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/CHANGELOG.md b/packages/data/CHANGELOG.md index 43b159f228fe60..6b6dcafdbb4d28 100644 --- a/packages/data/CHANGELOG.md +++ b/packages/data/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 10.31.0 (2025-09-17) + ## 10.30.0 (2025-09-03) ## 10.29.0 (2025-08-20) diff --git a/packages/data/package.json b/packages/data/package.json index 05bcadf4f75cfc..b4e15d5b09ab85 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/data", - "version": "10.30.0", + "version": "10.31.0", "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 f8ce898994dfac..7c1e732881ed19 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 9.0.0 (2025-09-17) + ### Breaking changes - Remove `boolean` form control. Fields using `Edit: 'boolean'` must now use `Edit: 'checkbox'` or `Edit: 'toggle'` instead. Boolean field types now use checkboxes by default. [#71505](https://github.com/WordPress/gutenberg/pull/71505) diff --git a/packages/dataviews/package.json b/packages/dataviews/package.json index 2bd132569701fb..f9c1891727ab5b 100644 --- a/packages/dataviews/package.json +++ b/packages/dataviews/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/dataviews", - "version": "8.0.0", + "version": "9.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/dataviews/tsconfig.json b/packages/dataviews/tsconfig.json index 8b3d5ad18ddacd..1869b49db039e7 100644 --- a/packages/dataviews/tsconfig.json +++ b/packages/dataviews/tsconfig.json @@ -6,7 +6,8 @@ "gutenberg-env", "gutenberg-test-env", "jest", - "@testing-library/jest-dom" + "@testing-library/jest-dom", + "node" ] }, "references": [ diff --git a/packages/date/CHANGELOG.md b/packages/date/CHANGELOG.md index 3cb5c4759d83f9..95caac35b6dfc9 100644 --- a/packages/date/CHANGELOG.md +++ b/packages/date/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.31.0 (2025-09-17) + ## 5.30.0 (2025-09-03) ### Enhancements diff --git a/packages/date/package.json b/packages/date/package.json index 27e39b752a3df6..9af1f194e59293 100644 --- a/packages/date/package.json +++ b/packages/date/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/date", - "version": "5.30.0", + "version": "5.31.0", "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 0e73f863afdd98..0269bd856e97a5 100644 --- a/packages/dependency-extraction-webpack-plugin/CHANGELOG.md +++ b/packages/dependency-extraction-webpack-plugin/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.31.0 (2025-09-17) + ## 6.30.0 (2025-09-03) ## 6.29.0 (2025-08-20) 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/dependency-extraction-webpack-plugin/package.json b/packages/dependency-extraction-webpack-plugin/package.json index 0b95df69238d33..2918e0260b4398 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.30.0", + "version": "6.31.0", "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 008f5b29f70c5a..362d213091bd2a 100644 --- a/packages/deprecated/CHANGELOG.md +++ b/packages/deprecated/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.31.0 (2025-09-17) + ## 4.30.0 (2025-09-03) ## 4.29.0 (2025-08-20) diff --git a/packages/deprecated/package.json b/packages/deprecated/package.json index 761b54bf4672f7..c722eefc44a868 100644 --- a/packages/deprecated/package.json +++ b/packages/deprecated/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/deprecated", - "version": "4.30.0", + "version": "4.31.0", "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 106678ede7a0e1..48e94204f110fc 100644 --- a/packages/docgen/CHANGELOG.md +++ b/packages/docgen/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 2.31.0 (2025-09-17) + ## 2.30.0 (2025-09-03) ## 2.29.0 (2025-08-20) diff --git a/packages/docgen/lib/get-export-entries.js b/packages/docgen/lib/get-export-entries.js index dc4046b72fcc77..9785f40808b94d 100644 --- a/packages/docgen/lib/get-export-entries.js +++ b/packages/docgen/lib/get-export-entries.js @@ -54,10 +54,10 @@ module.exports = ( token ) => { } const name = []; - if ( token.declaration === null ) { + if ( ! token.declaration ) { token.specifiers.forEach( ( specifier ) => name.push( { - localName: specifier.local.name, + localName: specifier.local?.name, exportName: specifier.exported.name, module: token.source?.value ?? null, lineStart: specifier.loc.start.line, diff --git a/packages/docgen/package.json b/packages/docgen/package.json index 8577fc0ac5b775..326436adccbb13 100644 --- a/packages/docgen/package.json +++ b/packages/docgen/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/docgen", - "version": "2.30.0", + "version": "2.31.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/CHANGELOG.md b/packages/dom-ready/CHANGELOG.md index 25c668abc93c01..22aba788ab79a2 100644 --- a/packages/dom-ready/CHANGELOG.md +++ b/packages/dom-ready/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.31.0 (2025-09-17) + ## 4.30.0 (2025-09-03) ## 4.29.0 (2025-08-20) diff --git a/packages/dom-ready/package.json b/packages/dom-ready/package.json index f9187636442f82..960781611d1b15 100644 --- a/packages/dom-ready/package.json +++ b/packages/dom-ready/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/dom-ready", - "version": "4.30.0", + "version": "4.31.0", "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 0b1f440464ee56..6287fa6498aa4f 100644 --- a/packages/dom/CHANGELOG.md +++ b/packages/dom/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.31.0 (2025-09-17) + ## 4.30.0 (2025-09-03) ## 4.29.0 (2025-08-20) diff --git a/packages/dom/package.json b/packages/dom/package.json index e004cc359084c6..d46a728f90e257 100644 --- a/packages/dom/package.json +++ b/packages/dom/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/dom", - "version": "4.30.0", + "version": "4.31.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/CHANGELOG.md b/packages/e2e-test-utils-playwright/CHANGELOG.md index ba50295361ffc5..1cb4a3a1d5a37b 100644 --- a/packages/e2e-test-utils-playwright/CHANGELOG.md +++ b/packages/e2e-test-utils-playwright/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 1.31.0 (2025-09-17) + ## 1.30.0 (2025-09-03) ## 1.29.0 (2025-08-20) diff --git a/packages/e2e-test-utils-playwright/package.json b/packages/e2e-test-utils-playwright/package.json index 5ea0080f3b90cc..fd6a13d1ce7af1 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.30.0", + "version": "1.31.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-playwright/src/admin/visit-site-editor.ts b/packages/e2e-test-utils-playwright/src/admin/visit-site-editor.ts index 670792a8f95bea..bc6e3e23d078b5 100644 --- a/packages/e2e-test-utils-playwright/src/admin/visit-site-editor.ts +++ b/packages/e2e-test-utils-playwright/src/admin/visit-site-editor.ts @@ -8,7 +8,6 @@ interface SiteEditorOptions { postType?: string; path?: string; canvas?: string; - activeView?: string; showWelcomeGuide?: boolean; } @@ -22,7 +21,7 @@ export async function visitSiteEditor( this: Admin, options: SiteEditorOptions = {} ) { - const { postId, postType, path, canvas, activeView } = options; + const { postId, postType, path, canvas } = options; const query = new URLSearchParams(); if ( postId ) { @@ -37,9 +36,6 @@ export async function visitSiteEditor( if ( canvas ) { query.set( 'canvas', canvas ); } - if ( activeView ) { - query.set( 'activeView', activeView ); - } await this.visitAdminPage( 'site-editor.php', query.toString() ); diff --git a/packages/e2e-test-utils/CHANGELOG.md b/packages/e2e-test-utils/CHANGELOG.md index 0429c0ed028602..368004f3e17291 100644 --- a/packages/e2e-test-utils/CHANGELOG.md +++ b/packages/e2e-test-utils/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 11.31.0 (2025-09-17) + ## 11.30.0 (2025-09-03) ## 11.29.0 (2025-08-20) diff --git a/packages/e2e-test-utils/package.json b/packages/e2e-test-utils/package.json index 4108e7376a080c..cd56ed2aec0dcf 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.30.0", + "version": "11.31.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/CHANGELOG.md b/packages/e2e-tests/CHANGELOG.md index 9b53fd2e2d56a3..ca032c39c70ffb 100644 --- a/packages/e2e-tests/CHANGELOG.md +++ b/packages/e2e-tests/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 8.31.0 (2025-09-17) + ## 8.30.0 (2025-09-03) ## 8.29.0 (2025-08-20) diff --git a/packages/e2e-tests/package.json b/packages/e2e-tests/package.json index 428749ddc3797c..4fc4caf769b45c 100644 --- a/packages/e2e-tests/package.json +++ b/packages/e2e-tests/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/e2e-tests", - "version": "8.30.0", + "version": "8.31.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/CHANGELOG.md b/packages/edit-post/CHANGELOG.md index 4ef3723096063f..5548e84501b9ad 100644 --- a/packages/edit-post/CHANGELOG.md +++ b/packages/edit-post/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 8.31.0 (2025-09-17) + ## 8.30.0 (2025-09-03) ## 8.29.0 (2025-08-20) diff --git a/packages/edit-post/package.json b/packages/edit-post/package.json index 1767202a8eb3c6..fee44955d49c92 100644 --- a/packages/edit-post/package.json +++ b/packages/edit-post/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/edit-post", - "version": "8.30.0", + "version": "8.31.0", "description": "Edit Post module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/edit-post/src/components/layout/index.js b/packages/edit-post/src/components/layout/index.js index 50866435b3c93b..c4ab06d2d1d306 100644 --- a/packages/edit-post/src/components/layout/index.js +++ b/packages/edit-post/src/components/layout/index.js @@ -81,7 +81,6 @@ const DESIGN_POST_TYPES = [ 'wp_template_part', 'wp_block', 'wp_navigation', - 'wp_registered_template', ]; function useEditorStyles( ...additionalStyles ) { @@ -469,17 +468,6 @@ function Layout( { useMetaBoxInitialization( hasActiveMetaboxes && hasResolvedMode ); - const editableResolvedTemplateId = useSelect( - ( select ) => { - if ( typeof templateId !== 'string' ) { - return templateId; - } - return unlock( select( coreStore ) ).getTemplateAutoDraftId( - templateId - ); - }, - [ templateId ] - ); const [ paddingAppenderRef, paddingStyle ] = usePaddingAppender( enablePaddingAppender ); @@ -603,7 +591,7 @@ function Layout( { initialEdits={ initialEdits } postType={ currentPostType } postId={ currentPostId } - templateId={ editableResolvedTemplateId } + templateId={ templateId } className={ className } styles={ styles } forceIsDirty={ hasActiveMetaboxes } diff --git a/packages/edit-site/CHANGELOG.md b/packages/edit-site/CHANGELOG.md index a37e5fa56486dc..0fd2b2c78927c5 100644 --- a/packages/edit-site/CHANGELOG.md +++ b/packages/edit-site/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.31.0 (2025-09-17) + ## 6.30.0 (2025-09-03) ## 6.29.0 (2025-08-20) diff --git a/packages/edit-site/package.json b/packages/edit-site/package.json index 2152ff505400db..3c36c711407f58 100644 --- a/packages/edit-site/package.json +++ b/packages/edit-site/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/edit-site", - "version": "6.30.0", + "version": "6.31.0", "description": "Edit Site Page module for WordPress.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js b/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js index 4929ace507af8e..7a846985c8c6a6 100644 --- a/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js +++ b/packages/edit-site/src/components/add-new-template/add-custom-template-modal-content.js @@ -172,7 +172,9 @@ function AddCustomTemplateModalContent( { onBack, containerRef, } ) { - const [ showSearchEntities, setShowSearchEntities ] = useState(); + const [ showSearchEntities, setShowSearchEntities ] = useState( + entityForSuggestions.hasGeneralTemplate + ); // Focus on the first focusable element when the modal opens. // We handle focus management in the parent modal, just need to focus on the first focusable element. diff --git a/packages/edit-site/src/components/add-new-template/index.js b/packages/edit-site/src/components/add-new-template/index.js index 754cce67898400..aa36fa2e59540f 100644 --- a/packages/edit-site/src/components/add-new-template/index.js +++ b/packages/edit-site/src/components/add-new-template/index.js @@ -53,6 +53,7 @@ import { TEMPLATE_POST_TYPE } from '../../utils/constants'; */ import AddCustomTemplateModalContent from './add-custom-template-modal-content'; import { + useExistingTemplates, useDefaultTemplateTypes, useTaxonomiesMenuItems, usePostTypeMenuItems, @@ -386,9 +387,15 @@ function NewTemplate() { } function useMissingTemplates( setEntityForSuggestions, onClick ) { + const existingTemplates = useExistingTemplates(); const defaultTemplateTypes = useDefaultTemplateTypes(); + const existingTemplateSlugs = ( existingTemplates || [] ).map( + ( { slug } ) => slug + ); const missingDefaultTemplates = ( defaultTemplateTypes || [] ).filter( - ( template ) => DEFAULT_TEMPLATE_SLUGS.includes( template.slug ) + ( template ) => + DEFAULT_TEMPLATE_SLUGS.includes( template.slug ) && + ! existingTemplateSlugs.includes( template.slug ) ); const onClickMenuItem = ( _entityForSuggestions ) => { onClick?.(); diff --git a/packages/edit-site/src/components/add-new-template/utils.js b/packages/edit-site/src/components/add-new-template/utils.js index c7bd9ecb11729b..e9f3961bee5a39 100644 --- a/packages/edit-site/src/components/add-new-template/utils.js +++ b/packages/edit-site/src/components/add-new-template/utils.js @@ -189,6 +189,7 @@ export function usePostTypeArchiveMenuItems() { export const usePostTypeMenuItems = ( onClickMenuItem ) => { const publicPostTypes = usePublicPostTypes(); + const existingTemplates = useExistingTemplates(); const defaultTemplateTypes = useDefaultTemplateTypes(); // We need to keep track of naming conflicts. If a conflict // occurs, we need to add slug. @@ -228,6 +229,9 @@ export const usePostTypeMenuItems = ( onClickMenuItem ) => { [ publicPostTypes ] ); const postTypesInfo = useEntitiesInfo( 'postType', templatePrefixes ); + const existingTemplateSlugs = ( existingTemplates || [] ).map( + ( { slug } ) => slug + ); const menuItems = ( publicPostTypes || [] ).reduce( ( accumulator, postType ) => { const { slug, labels, icon } = postType; @@ -238,6 +242,8 @@ export const usePostTypeMenuItems = ( onClickMenuItem ) => { const defaultTemplateType = defaultTemplateTypes?.find( ( { slug: _slug } ) => _slug === generalTemplateSlug ); + const hasGeneralTemplate = + existingTemplateSlugs?.includes( generalTemplateSlug ); const _needsUniqueIdentifier = needsUniqueIdentifier( postType ); let menuItemTitle = labels.template_name || @@ -315,12 +321,14 @@ export const usePostTypeMenuItems = ( onClickMenuItem ) => { }, }, labels, + hasGeneralTemplate, template, } ); }; } - // We don't need to add the menu item if there are no entities. - if ( hasEntities ) { + // We don't need to add the menu item if there are no + // entities and the general template exists. + if ( ! hasGeneralTemplate || hasEntities ) { accumulator.push( menuItem ); } return accumulator; @@ -547,11 +555,7 @@ export function useAuthorMenuItem( onClickMenuItem ) { getSpecificTemplate: ( suggestion ) => { const templateSlug = `author-${ suggestion.slug }`; return { - title: sprintf( - // translators: %s: Name of the author e.g: "Admin". - __( 'Author: %s' ), - suggestion.name - ), + title: templateSlug, slug: templateSlug, templatePrefix: 'author', }; @@ -573,6 +577,91 @@ export function useAuthorMenuItem( onClickMenuItem ) { } } +/** + * Helper hook that filters all the existing templates by the given + * object with the entity's slug as key and the template prefix as value. + * + * Example: + * `existingTemplates` is: [ { slug: 'tag-apple' }, { slug: 'page-about' }, { slug: 'tag' } ] + * `templatePrefixes` is: { post_tag: 'tag' } + * It will return: { post_tag: ['apple'] } + * + * Note: We append the `-` to the given template prefix in this function for our checks. + * + * @param {Record} templatePrefixes An object with the entity's slug as key and the template prefix as value. + * @return {Record} An object with the entity's slug as key and an array with the existing template slugs as value. + */ +const useExistingTemplateSlugs = ( templatePrefixes ) => { + const existingTemplates = useExistingTemplates(); + const existingSlugs = useMemo( () => { + return Object.entries( templatePrefixes || {} ).reduce( + ( accumulator, [ slug, prefix ] ) => { + const slugsWithTemplates = ( existingTemplates || [] ).reduce( + ( _accumulator, existingTemplate ) => { + const _prefix = `${ prefix }-`; + if ( existingTemplate.slug.startsWith( _prefix ) ) { + _accumulator.push( + existingTemplate.slug.substring( + _prefix.length + ) + ); + } + return _accumulator; + }, + [] + ); + if ( slugsWithTemplates.length ) { + accumulator[ slug ] = slugsWithTemplates; + } + return accumulator; + }, + {} + ); + }, [ templatePrefixes, existingTemplates ] ); + return existingSlugs; +}; + +/** + * Helper hook that finds the existing records with an associated template, + * as they need to be excluded from the template suggestions. + * + * @param {string} entityName The entity's name. + * @param {Record} templatePrefixes An object with the entity's slug as key and the template prefix as value. + * @param {Record} additionalQueryParameters An object with the entity's slug as key and additional query parameters as value. + * @return {Record} An object with the entity's slug as key and the existing records as value. + */ +const useTemplatesToExclude = ( + entityName, + templatePrefixes, + additionalQueryParameters = {} +) => { + const slugsToExcludePerEntity = + useExistingTemplateSlugs( templatePrefixes ); + const recordsToExcludePerEntity = useSelect( + ( select ) => { + return Object.entries( slugsToExcludePerEntity || {} ).reduce( + ( accumulator, [ slug, slugsWithTemplates ] ) => { + const entitiesWithTemplates = select( + coreStore + ).getEntityRecords( entityName, slug, { + _fields: 'id', + context: 'view', + slug: slugsWithTemplates, + ...additionalQueryParameters[ slug ], + } ); + if ( entitiesWithTemplates?.length ) { + accumulator[ slug ] = entitiesWithTemplates; + } + return accumulator; + }, + {} + ); + }, + [ slugsToExcludePerEntity ] + ); + return recordsToExcludePerEntity; +}; + /** * Helper hook that returns information about an entity having * records that we can create a specific template for. @@ -593,16 +682,26 @@ const useEntitiesInfo = ( templatePrefixes, additionalQueryParameters = EMPTY_OBJECT ) => { + const recordsToExcludePerEntity = useTemplatesToExclude( + entityName, + templatePrefixes, + additionalQueryParameters + ); const entitiesHasRecords = useSelect( ( select ) => { return Object.keys( templatePrefixes || {} ).reduce( ( accumulator, slug ) => { + const existingEntitiesIds = + recordsToExcludePerEntity?.[ slug ]?.map( + ( { id } ) => id + ) || []; accumulator[ slug ] = !! select( coreStore ).getEntityRecords( entityName, slug, { per_page: 1, _fields: 'id', context: 'view', + exclude: existingEntitiesIds, ...additionalQueryParameters[ slug ], } )?.length; return accumulator; @@ -610,18 +709,28 @@ const useEntitiesInfo = ( {} ); }, - [ templatePrefixes, entityName, additionalQueryParameters ] + [ + templatePrefixes, + recordsToExcludePerEntity, + entityName, + additionalQueryParameters, + ] ); const entitiesInfo = useMemo( () => { return Object.keys( templatePrefixes || {} ).reduce( ( accumulator, slug ) => { + const existingEntitiesIds = + recordsToExcludePerEntity?.[ slug ]?.map( + ( { id } ) => id + ) || []; accumulator[ slug ] = { hasEntities: entitiesHasRecords[ slug ], + existingEntitiesIds, }; return accumulator; }, {} ); - }, [ templatePrefixes, entitiesHasRecords ] ); + }, [ templatePrefixes, recordsToExcludePerEntity, entitiesHasRecords ] ); return entitiesInfo; }; diff --git a/packages/edit-site/src/components/dataviews-actions/index.js b/packages/edit-site/src/components/dataviews-actions/index.js index 64083f36bc4218..0a7b20c712c820 100644 --- a/packages/edit-site/src/components/dataviews-actions/index.js +++ b/packages/edit-site/src/components/dataviews-actions/index.js @@ -5,8 +5,6 @@ import { __ } from '@wordpress/i18n'; import { edit } from '@wordpress/icons'; import { useMemo } from '@wordpress/element'; import { privateApis as routerPrivateApis } from '@wordpress/router'; -import { useDispatch, useSelect } from '@wordpress/data'; -import { store as coreStore } from '@wordpress/core-data'; /** * Internal dependencies @@ -16,55 +14,6 @@ import { unlock } from '../../lock-unlock'; const { useHistory } = unlock( routerPrivateApis ); -export const useSetActiveTemplateAction = () => { - const { getEntityRecord } = useSelect( coreStore ); - const { editEntityRecord, saveEditedEntityRecord } = - useDispatch( coreStore ); - return useMemo( - () => ( { - id: 'set-active-template', - label( items ) { - return items.some( ( item ) => item._isActive ) - ? __( 'Deactivate' ) - : __( 'Activate' ); - }, - isPrimary: true, - icon: edit, - isEligible( item ) { - return ! ( item.slug === 'index' && item.source === 'theme' ); - }, - async callback( items ) { - const deactivate = items.some( ( item ) => item._isActive ); - // current active templates - const activeTemplates = { - ...( ( await getEntityRecord( 'root', 'site' ) - .active_templates ) ?? {} ), - }; - for ( const item of items ) { - if ( deactivate ) { - if ( item.source === 'theme' ) { - activeTemplates[ item.slug ] = false; - } else { - delete activeTemplates[ item.slug ]; - } - } else { - activeTemplates[ item.slug ] = item.id; - } - } - // To do: figure out why the REST API deletes the option when - // it's set to an empty object. That would trigger the migration - // function, which will make all templates in the database active. - activeTemplates.__preventCollapse = 0; - await editEntityRecord( 'root', 'site', undefined, { - active_templates: activeTemplates, - } ); - await saveEditedEntityRecord( 'root', 'site' ); - }, - } ), - [ editEntityRecord, saveEditedEntityRecord, getEntityRecord ] - ); -}; - export const useEditPostAction = () => { const history = useHistory(); return useMemo( diff --git a/packages/edit-site/src/components/editor/index.js b/packages/edit-site/src/components/editor/index.js index decbf0e7f971a3..3a4828d93e911c 100644 --- a/packages/edit-site/src/components/editor/index.js +++ b/packages/edit-site/src/components/editor/index.js @@ -110,7 +110,6 @@ function getNavigationPath( location, postType ) { 'template-part-item', 'page-item', 'template-item', - 'static-template-item', 'post-item', ].includes( name ) ) { diff --git a/packages/edit-site/src/components/editor/use-resolve-edited-entity.js b/packages/edit-site/src/components/editor/use-resolve-edited-entity.js index 8261d8e761787c..8da076f9f00b71 100644 --- a/packages/edit-site/src/components/editor/use-resolve-edited-entity.js +++ b/packages/edit-site/src/components/editor/use-resolve-edited-entity.js @@ -25,12 +25,13 @@ const postTypesWithoutParentTemplate = [ TEMPLATE_PART_POST_TYPE, NAVIGATION_POST_TYPE, PATTERN_TYPES.user, - 'wp_registered_template', ]; const authorizedPostTypes = [ 'page', 'post' ]; -function getPostType( name, postId ) { +export function useResolveEditedEntity() { + const { name, params = {}, query } = useLocation(); + const { postId = query?.postId } = params; // Fallback to query param for postId for list view routes. let postType; if ( name === 'navigation-item' ) { postType = NAVIGATION_POST_TYPE; @@ -38,48 +39,19 @@ function getPostType( name, postId ) { postType = PATTERN_TYPES.user; } else if ( name === 'template-part-item' ) { postType = TEMPLATE_PART_POST_TYPE; - } else if ( name === 'templates' ) { - postType = /^\d+$/.test( postId ) - ? TEMPLATE_POST_TYPE - : 'wp_registered_template'; - } else if ( name === 'template-item' ) { + } else if ( name === 'template-item' || name === 'templates' ) { postType = TEMPLATE_POST_TYPE; - } else if ( name === 'static-template-item' ) { - postType = 'wp_registered_template'; } else if ( name === 'page-item' || name === 'pages' ) { postType = 'page'; } else if ( name === 'post-item' || name === 'posts' ) { postType = 'post'; } - return postType; -} - -export function useResolveEditedEntity() { - const { name, params = {}, query } = useLocation(); - const { postId: _postId = query?.postId } = params; // Fallback to query param for postId for list view routes. - const _postType = getPostType( name, _postId ) ?? query?.postType; - const homePage = useSelect( ( select ) => { const { getHomePage } = unlock( select( coreDataStore ) ); return getHomePage(); }, [] ); - const [ postType, postId ] = useSelect( - ( select ) => { - if ( _postType !== 'wp_registered_template' ) { - return [ _postType, _postId ]; - } - return [ - TEMPLATE_POST_TYPE, - unlock( select( coreDataStore ) ).getTemplateAutoDraftId( - _postId - ), - ]; - }, - [ _postType, _postId ] - ); - /** * This is a hook that recreates the logic to resolve a template for a given WordPress postID postTypeId * in order to match the frontend as closely as possible in the site editor. @@ -126,18 +98,6 @@ export function useResolveEditedEntity() { [ homePage, postId, postType ] ); - const editableResolvedTemplateId = useSelect( - ( select ) => { - if ( typeof resolvedTemplateId !== 'string' ) { - return resolvedTemplateId; - } - return unlock( select( coreDataStore ) ).getTemplateAutoDraftId( - resolvedTemplateId - ); - }, - [ resolvedTemplateId ] - ); - const context = useMemo( () => { if ( postTypesWithoutParentTemplate.includes( postType ) && postId ) { return {}; @@ -161,9 +121,9 @@ export function useResolveEditedEntity() { if ( !! homePage ) { return { - isReady: editableResolvedTemplateId !== undefined, + isReady: resolvedTemplateId !== undefined, postType: TEMPLATE_POST_TYPE, - postId: editableResolvedTemplateId, + postId: resolvedTemplateId, context, }; } @@ -181,14 +141,7 @@ export function useSyncDeprecatedEntityIntoState( { useEffect( () => { if ( isReady ) { - // setEditedEntity expects a string (because the postId used to be - // the template slug, even for edited templates). Now the postId can - // be a number (either because it's an auto-draft or edited - // template). Passing a number could break plugins doing things like - // `id.includes`. It would be way more complex to keep passing the - // template slug, while also being incorrect, so the easiest - // solution is to cast the postId to a string. - setEditedEntity( postType, String( postId ), context ); + setEditedEntity( postType, postId, context ); } }, [ isReady, postType, postId, context, setEditedEntity ] ); } diff --git a/packages/edit-site/src/components/page-templates/fields.js b/packages/edit-site/src/components/page-templates/fields.js index 04aba955e07b27..d532cbc43fac76 100644 --- a/packages/edit-site/src/components/page-templates/fields.js +++ b/packages/edit-site/src/components/page-templates/fields.js @@ -6,11 +6,7 @@ import clsx from 'clsx'; /** * WordPress dependencies */ -import { - Icon, - __experimentalHStack as HStack, - privateApis as componentsPrivateApis, -} from '@wordpress/components'; +import { Icon, __experimentalHStack as HStack } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { useState, useMemo } from '@wordpress/element'; import { decodeEntities } from '@wordpress/html-entities'; @@ -20,40 +16,15 @@ import { privateApis as blockEditorPrivateApis, } from '@wordpress/block-editor'; import { EditorProvider } from '@wordpress/editor'; -import { privateApis as corePrivateApis } from '@wordpress/core-data'; /** * Internal dependencies */ import { useAddedBy } from './hooks'; -import { useDefaultTemplateTypes } from '../add-new-template/utils'; import usePatternSettings from '../page-patterns/use-pattern-settings'; import { unlock } from '../../lock-unlock'; const { useGlobalStyle } = unlock( blockEditorPrivateApis ); -const { Badge } = unlock( componentsPrivateApis ); -const { useEntityRecordsWithPermissions } = unlock( corePrivateApis ); - -function useAllDefaultTemplateTypes() { - const defaultTemplateTypes = useDefaultTemplateTypes(); - const { records: staticRecords } = useEntityRecordsWithPermissions( - 'postType', - 'wp_registered_template', - { per_page: -1 } - ); - return [ - ...defaultTemplateTypes, - ...staticRecords - ?.filter( ( record ) => ! record.is_custom ) - .map( ( record ) => { - return { - slug: record.slug, - title: record.title.rendered, - description: record.description, - }; - } ), - ]; -} function PreviewField( { item } ) { const settings = usePatternSettings(); @@ -97,14 +68,8 @@ export const previewField = { export const descriptionField = { label: __( 'Description' ), id: 'description', - render: function RenderDescription( { item } ) { - const defaultTemplateTypes = useAllDefaultTemplateTypes(); - const defaultTemplateType = defaultTemplateTypes.find( - ( type ) => type.slug === item.slug - ); - return item.description - ? decodeEntities( item.description ) - : defaultTemplateType?.description; + render: ( { item } ) => { + return item.description && decodeEntities( item.description ); }, enableSorting: false, enableGlobalSearch: true, @@ -142,37 +107,6 @@ function AuthorField( { item } ) { export const authorField = { label: __( 'Author' ), id: 'author', - getValue: ( { item } ) => item.author_text ?? item.author, + getValue: ( { item } ) => item.author_text, render: AuthorField, }; - -export const activeField = { - label: __( 'Status' ), - id: 'active', - getValue: ( { item } ) => item._isActive, - render: function Render( { item } ) { - const isActive = item._isActive; - return ( - - { isActive ? __( 'Active' ) : __( 'Inactive' ) } - - ); - }, -}; - -export const slugField = { - label: __( 'Template Type' ), - id: 'slug', - getValue: ( { item } ) => item.slug, - render: function Render( { item } ) { - const defaultTemplateTypes = useAllDefaultTemplateTypes(); - const defaultTemplateType = defaultTemplateTypes.find( - ( type ) => type.slug === item.slug - ); - return ( - defaultTemplateType?.title || - // translators: %s is the slug of a custom template. - __( 'Custom' ) - ); - }, -}; diff --git a/packages/edit-site/src/components/page-templates/hooks.js b/packages/edit-site/src/components/page-templates/hooks.js index fa84728795bc0d..99b05dacf8e1e0 100644 --- a/packages/edit-site/src/components/page-templates/hooks.js +++ b/packages/edit-site/src/components/page-templates/hooks.js @@ -89,7 +89,7 @@ export function useAddedBy( postType, postId ) { type: 'user', icon: authorIcon, imageUrl: user?.avatar_urls?.[ 48 ], - text: authorText ?? user?.name, + text: authorText, isCustomized: false, }; } diff --git a/packages/edit-site/src/components/page-templates/index.js b/packages/edit-site/src/components/page-templates/index.js index 22b7bded399d17..033370f4ec449c 100644 --- a/packages/edit-site/src/components/page-templates/index.js +++ b/packages/edit-site/src/components/page-templates/index.js @@ -3,16 +3,11 @@ */ import { __ } from '@wordpress/i18n'; import { useState, useMemo, useCallback, useEffect } from '@wordpress/element'; -import { - privateApis as corePrivateApis, - store as coreStore, -} from '@wordpress/core-data'; +import { privateApis as corePrivateApis } from '@wordpress/core-data'; import { DataViews, filterSortAndPaginate } from '@wordpress/dataviews'; import { privateApis as routerPrivateApis } from '@wordpress/router'; import { privateApis as editorPrivateApis } from '@wordpress/editor'; import { addQueryArgs } from '@wordpress/url'; -import { useSelect } from '@wordpress/data'; -import { useEvent } from '@wordpress/compose'; /** * Internal dependencies @@ -27,22 +22,16 @@ import { LAYOUT_LIST, } from '../../utils/constants'; import { unlock } from '../../lock-unlock'; -import { - useEditPostAction, - useSetActiveTemplateAction, -} from '../dataviews-actions'; -import { - authorField, - descriptionField, - previewField, - activeField, - slugField, -} from './fields'; +import { useEditPostAction } from '../dataviews-actions'; +import { authorField, descriptionField, previewField } from './fields'; +import { useEvent } from '@wordpress/compose'; const { usePostActions, templateTitleField } = unlock( editorPrivateApis ); const { useHistory, useLocation } = unlock( routerPrivateApis ); const { useEntityRecordsWithPermissions } = unlock( corePrivateApis ); +const EMPTY_ARRAY = []; + const defaultLayouts = { [ LAYOUT_TABLE ]: { showMedia: false, @@ -67,29 +56,31 @@ const DEFAULT_VIEW = { titleField: 'title', descriptionField: 'description', mediaField: 'preview', - fields: [ 'author', 'active', 'slug' ], + fields: [ 'author' ], filters: [], ...defaultLayouts[ LAYOUT_GRID ], }; export default function PageTemplates() { const { path, query } = useLocation(); - const { activeView = 'active', layout, postId } = query; + const { activeView = 'all', layout, postId } = query; const [ selection, setSelection ] = useState( [ postId ] ); + const defaultView = useMemo( () => { const usedType = layout ?? DEFAULT_VIEW.type; return { ...DEFAULT_VIEW, type: usedType, - filters: ! [ 'active', 'user' ].includes( activeView ) - ? [ - { - field: 'author', - operator: 'isAny', - value: [ activeView ], - }, - ] - : [], + filters: + activeView !== 'all' + ? [ + { + field: 'author', + operator: 'isAny', + value: [ activeView ], + }, + ] + : [], ...defaultLayouts[ usedType ], }; }, [ layout, activeView ] ); @@ -107,116 +98,23 @@ export default function PageTemplates() { useEffect( () => { setView( ( currentView ) => ( { ...currentView, - filters: ! [ 'active', 'user' ].includes( activeView ) - ? [ - { - field: 'author', - operator: OPERATOR_IS_ANY, - value: [ activeView ], - }, - ] - : [], + filters: + activeView !== 'all' + ? [ + { + field: 'author', + operator: OPERATOR_IS_ANY, + value: [ activeView ], + }, + ] + : [], } ) ); }, [ setView, activeView ] ); - const activeTemplatesOption = useSelect( - ( select ) => - select( coreStore ).getEntityRecord( 'root', 'site' ) - ?.active_templates - ); - // Todo: this will have to be better so that we're not fetching all the - // records all the time. Active templates query will need to move server - // side. - const { records: userRecords, isResolving: isLoadingUserRecords } = + const { records, isResolving: isLoadingData } = useEntityRecordsWithPermissions( 'postType', TEMPLATE_POST_TYPE, { per_page: -1, } ); - const { records: staticRecords, isResolving: isLoadingStaticData } = - useEntityRecordsWithPermissions( 'postType', 'wp_registered_template', { - per_page: -1, - } ); - - const activeTemplates = useMemo( () => { - const _active = [ ...staticRecords ].filter( - ( record ) => ! record.is_custom - ); - if ( activeTemplatesOption ) { - for ( const activeSlug in activeTemplatesOption ) { - const activeId = activeTemplatesOption[ activeSlug ]; - if ( activeId === false ) { - // Remove the template from the array. - const index = _active.findIndex( - ( template ) => template.slug === activeSlug - ); - if ( index !== -1 ) { - _active.splice( index, 1 ); - } - } else { - // Replace the template in the array. - const template = userRecords.find( - ( { id } ) => id === activeId - ); - if ( template ) { - const index = _active.findIndex( - ( { slug } ) => slug === template.slug - ); - if ( index !== -1 ) { - _active[ index ] = template; - } else { - _active.push( template ); - } - } - } - } - } - return _active; - }, [ userRecords, staticRecords, activeTemplatesOption ] ); - - let _records; - let isLoadingData; - if ( activeView === 'active' ) { - _records = activeTemplates; - isLoadingData = isLoadingUserRecords || isLoadingStaticData; - } else if ( activeView === 'user' ) { - _records = userRecords; - isLoadingData = isLoadingUserRecords; - } else { - _records = staticRecords; - isLoadingData = isLoadingStaticData; - } - - const records = useMemo( () => { - return _records.map( ( record ) => ( { - ...record, - _isActive: - typeof record.id === 'string' - ? activeTemplatesOption[ record.slug ] === record.id || - activeTemplatesOption[ record.slug ] === undefined - : Object.values( activeTemplatesOption ).includes( - record.id - ), - } ) ); - }, [ _records, activeTemplatesOption ] ); - - const users = useSelect( - ( select ) => { - const { getUser } = select( coreStore ); - return records.reduce( ( acc, record ) => { - if ( record.author_text ) { - if ( ! acc[ record.author_text ] ) { - acc[ record.author_text ] = record.author_text; - } - } else if ( record.author ) { - if ( ! acc[ record.author ] ) { - acc[ record.author ] = getUser( record.author ); - } - } - return acc; - }, {} ); - }, - [ records ] - ); - const history = useHistory(); const onChangeSelection = useCallback( ( items ) => { @@ -232,27 +130,32 @@ export default function PageTemplates() { [ history, path, view?.type ] ); - const fields = useMemo( () => { - const _fields = [ + const authors = useMemo( () => { + if ( ! records ) { + return EMPTY_ARRAY; + } + const authorsSet = new Set(); + records.forEach( ( template ) => { + authorsSet.add( template.author_text ); + } ); + return Array.from( authorsSet ).map( ( author ) => ( { + value: author, + label: author, + } ) ); + }, [ records ] ); + + const fields = useMemo( + () => [ previewField, templateTitleField, descriptionField, - activeField, - slugField, - ]; - const elements = []; - for ( const author in users ) { - elements.push( { - value: users[ author ]?.id ?? author, - label: users[ author ]?.name ?? author, - } ); - } - _fields.push( { - ...authorField, - elements, - } ); - return _fields; - }, [ users ] ); + { + ...authorField, + elements: authors, + }, + ], + [ authors ] + ); const { data, paginationInfo } = useMemo( () => { return filterSortAndPaginate( records, view, fields ); @@ -263,13 +166,9 @@ export default function PageTemplates() { context: 'list', } ); const editAction = useEditPostAction(); - const setActiveTemplateAction = useSetActiveTemplateAction(); const actions = useMemo( - () => - activeView === 'user' - ? [ setActiveTemplateAction, editAction, ...postTypeActions ] - : [ setActiveTemplateAction, ...postTypeActions ], - [ postTypeActions, setActiveTemplateAction, editAction, activeView ] + () => [ editAction, ...postTypeActions ], + [ postTypeActions, editAction ] ); const onChangeView = useEvent( ( newView ) => { @@ -300,10 +199,8 @@ export default function PageTemplates() { onChangeView={ onChangeView } onChangeSelection={ onChangeSelection } isItemClickable={ () => true } - onClickItem={ ( item ) => { - history.navigate( - `/${ item.type }/${ item.id }?canvas=edit` - ); + onClickItem={ ( { id } ) => { + history.navigate( `/wp_template/${ id }?canvas=edit` ); } } selection={ selection } defaultLayouts={ defaultLayouts } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen-templates-browse/content.js b/packages/edit-site/src/components/sidebar-navigation-screen-templates-browse/content.js index b7fde2d056b2f3..7920d49a43c8cd 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen-templates-browse/content.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen-templates-browse/content.js @@ -14,6 +14,7 @@ import { addQueryArgs } from '@wordpress/url'; import SidebarNavigationItem from '../sidebar-navigation-item'; import { useAddedBy } from '../page-templates/hooks'; import { layout } from '@wordpress/icons'; +import { TEMPLATE_POST_TYPE } from '../../utils/constants'; import { unlock } from '../../lock-unlock'; const { useLocation } = unlock( routerPrivateApis ); @@ -36,15 +37,11 @@ function TemplateDataviewItem( { template, isActive } ) { export default function DataviewsTemplatesSidebarContent() { const { - query: { activeView = 'active' }, + query: { activeView = 'all' }, } = useLocation(); - const { records } = useEntityRecords( - 'postType', - 'wp_registered_template', - { - per_page: -1, - } - ); + const { records } = useEntityRecords( 'postType', TEMPLATE_POST_TYPE, { + per_page: -1, + } ); const firstItemPerAuthorText = useMemo( () => { const firstItemPerAuthor = records?.reduce( ( acc, template ) => { const author = template.author_text; @@ -64,16 +61,9 @@ export default function DataviewsTemplatesSidebarContent() { - { __( 'Active templates' ) } - - - { __( 'Custom templates' ) } + { __( 'All templates' ) } { firstItemPerAuthorText.map( ( template ) => { return ( diff --git a/packages/edit-site/src/components/site-editor-routes/index.js b/packages/edit-site/src/components/site-editor-routes/index.js index f1ab32b5e48d73..fe2210ae6e5e64 100644 --- a/packages/edit-site/src/components/site-editor-routes/index.js +++ b/packages/edit-site/src/components/site-editor-routes/index.js @@ -17,7 +17,7 @@ import { patternsRoute } from './patterns'; import { patternItemRoute } from './pattern-item'; import { templatePartItemRoute } from './template-part-item'; import { templatesRoute } from './templates'; -import { templateItemRoute, staticTemplateItemRoute } from './template-item'; +import { templateItemRoute } from './template-item'; import { pagesRoute } from './pages'; import { pageItemRoute } from './page-item'; import { stylebookRoute } from './stylebook'; @@ -27,7 +27,6 @@ const routes = [ pageItemRoute, pagesRoute, templateItemRoute, - staticTemplateItemRoute, templatesRoute, templatePartItemRoute, patternItemRoute, diff --git a/packages/edit-site/src/components/site-editor-routes/template-item.js b/packages/edit-site/src/components/site-editor-routes/template-item.js index 55c557a2b47a77..7711495b214773 100644 --- a/packages/edit-site/src/components/site-editor-routes/template-item.js +++ b/packages/edit-site/src/components/site-editor-routes/template-item.js @@ -5,41 +5,33 @@ import Editor from '../editor'; import SidebarNavigationScreenTemplatesBrowse from '../sidebar-navigation-screen-templates-browse'; import SidebarNavigationScreenUnsupported from '../sidebar-navigation-screen-unsupported'; -const areas = { - sidebar( { siteData } ) { - const isBlockTheme = siteData.currentTheme?.is_block_theme; - return isBlockTheme ? ( - - ) : ( - - ); - }, - mobile( { siteData } ) { - const isBlockTheme = siteData.currentTheme?.is_block_theme; - return isBlockTheme ? ( - - ) : ( - - ); - }, - preview( { siteData } ) { - const isBlockTheme = siteData.currentTheme?.is_block_theme; - return isBlockTheme ? ( - - ) : ( - - ); - }, -}; - export const templateItemRoute = { name: 'template-item', path: '/wp_template/*postId', - areas, -}; - -export const staticTemplateItemRoute = { - name: 'static-template-item', - path: '/wp_registered_template/*postId', - areas, + areas: { + sidebar( { siteData } ) { + const isBlockTheme = siteData.currentTheme?.is_block_theme; + return isBlockTheme ? ( + + ) : ( + + ); + }, + mobile( { siteData } ) { + const isBlockTheme = siteData.currentTheme?.is_block_theme; + return isBlockTheme ? ( + + ) : ( + + ); + }, + preview( { siteData } ) { + const isBlockTheme = siteData.currentTheme?.is_block_theme; + return isBlockTheme ? ( + + ) : ( + + ); + }, + }, }; diff --git a/packages/edit-widgets/CHANGELOG.md b/packages/edit-widgets/CHANGELOG.md index d3d6ef797dd409..5365f677112f64 100644 --- a/packages/edit-widgets/CHANGELOG.md +++ b/packages/edit-widgets/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.31.0 (2025-09-17) + ## 6.30.0 (2025-09-03) ## 6.29.0 (2025-08-20) diff --git a/packages/edit-widgets/package.json b/packages/edit-widgets/package.json index ae15d55d965905..e71ad38f7bc45f 100644 --- a/packages/edit-widgets/package.json +++ b/packages/edit-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/edit-widgets", - "version": "6.30.0", + "version": "6.31.0", "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 80d17744e86678..475942978147c4 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 14.31.0 (2025-09-17) + ## 14.30.0 (2025-09-03) ## 14.29.0 (2025-08-20) diff --git a/packages/editor/package.json b/packages/editor/package.json index 97cd6368dc1a7a..8f6dd34e4acd58 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/editor", - "version": "14.30.0", + "version": "14.31.0", "description": "Enhanced block editor for WordPress posts.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", 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/post-excerpt/panel.js b/packages/editor/src/components/post-excerpt/panel.js index c38c162e72c950..d4f2b27126c7c1 100644 --- a/packages/editor/src/components/post-excerpt/panel.js +++ b/packages/editor/src/components/post-excerpt/panel.js @@ -109,7 +109,6 @@ function PrivateExcerpt() { getCurrentPostId, getEditedPostAttribute, isEditorPanelEnabled, - __experimentalGetDefaultTemplateType, } = select( editorStore ); const postType = getCurrentPostType(); const isTemplateOrTemplatePart = [ @@ -132,17 +131,13 @@ function PrivateExcerpt() { postType, getCurrentPostId() ); - const fallback = isTemplateOrTemplatePart - ? __experimentalGetDefaultTemplateType( template.slug ) - .description - : undefined; // For post types that use excerpt as description, we do not abide // by the `isEnabled` panel flag in order to render them as text. const _shouldRender = isEditorPanelEnabled( PANEL_NAME ) || _shouldBeUsedAsDescription; return { - excerpt: getEditedPostAttribute( _usedAttribute ) ?? fallback, + excerpt: getEditedPostAttribute( _usedAttribute ), shouldRender: _shouldRender, shouldBeUsedAsDescription: _shouldBeUsedAsDescription, // If we should render, allow editing for all post types that are not used as description. diff --git a/packages/editor/src/components/post-locked-modal/index.js b/packages/editor/src/components/post-locked-modal/index.js index 65225a96508ac1..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,11 @@ function PostLockedModal() { return null; } + // Potentially refactor this into the above shortcircuit (!isLocked). + if ( window.__experimentalEnableSync ) { + return null; + } + const userDisplayName = user.name; const userAvatar = user.avatar; diff --git a/packages/editor/src/components/post-template/hooks.js b/packages/editor/src/components/post-template/hooks.js index 78ecb17c17c6f8..1aa82a828573f0 100644 --- a/packages/editor/src/components/post-template/hooks.js +++ b/packages/editor/src/components/post-template/hooks.js @@ -52,30 +52,14 @@ export function useAllowSwitchingTemplates() { } function useTemplates( postType ) { - // To do: create a new selector to checks if templates exist at all instead - // of and unbound request. In the modal, the user templates should be - // paginated and we should not make an unbound request. - const { staticTemplates, templates } = useSelect( - ( select ) => { - return { - staticTemplates: select( coreStore ).getEntityRecords( - 'postType', - 'wp_registered_template', - { per_page: -1, post_type: postType } - ), - templates: select( coreStore ).getEntityRecords( - 'postType', - 'wp_template', - { per_page: -1, post_type: postType } - ), - }; - }, + return useSelect( + ( select ) => + select( coreStore ).getEntityRecords( 'postType', 'wp_template', { + per_page: -1, + post_type: postType, + } ), [ postType ] ); - return useMemo( - () => [ ...( staticTemplates || [] ), ...( templates || [] ) ], - [ staticTemplates, templates ] - ); } export function useAvailableTemplates( postType ) { @@ -87,7 +71,7 @@ export function useAvailableTemplates( postType ) { allowSwitchingTemplate && templates?.filter( ( template ) => - ( template.is_custom || template.type === 'wp_template' ) && + template.is_custom && template.slug !== currentTemplateSlug && !! template.content.raw // Skip empty templates. ), diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index 55db84b93fcde1..e81378b7b523e4 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -220,10 +220,7 @@ export const ExperimentalEditorProvider = withRegistryProvider( const defaultBlockContext = useMemo( () => { const postContext = {}; // If it is a template, try to inherit the post type from the name. - if ( - post.type === 'wp_template' || - post.type === 'wp_registered_template' - ) { + if ( post.type === 'wp_template' ) { if ( post.slug === 'page' ) { postContext.postType = 'page'; } else if ( post.slug === 'single' ) { @@ -296,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.' diff --git a/packages/editor/src/dataviews/store/private-actions.ts b/packages/editor/src/dataviews/store/private-actions.ts index 831d138734aa60..82c2c8911c7c96 100644 --- a/packages/editor/src/dataviews/store/private-actions.ts +++ b/packages/editor/src/dataviews/store/private-actions.ts @@ -138,7 +138,7 @@ export const registerPostTypeSchema = : undefined, // @ts-ignore globalThis.IS_GUTENBERG_PLUGIN - ? ! [ 'wp_block', 'wp_template_part' ].includes( + ? ! [ 'wp_template', 'wp_block', 'wp_template_part' ].includes( postTypeConfig.slug ) && canCreate && diff --git a/packages/element/CHANGELOG.md b/packages/element/CHANGELOG.md index 894242c224b960..9112e3e3a3894a 100644 --- a/packages/element/CHANGELOG.md +++ b/packages/element/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.31.0 (2025-09-17) + ## 6.30.0 (2025-09-03) ## 6.29.0 (2025-08-20) diff --git a/packages/element/package.json b/packages/element/package.json index 78865050c3bb22..9554e665fa4383 100644 --- a/packages/element/package.json +++ b/packages/element/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/element", - "version": "6.30.0", + "version": "6.31.0", "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 63895eb9995a29..62102a88825d1e 100644 --- a/packages/env/CHANGELOG.md +++ b/packages/env/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 10.31.0 (2025-09-17) + ## 10.30.0 (2025-09-03) ## 10.29.0 (2025-08-20) diff --git a/packages/env/package.json b/packages/env/package.json index ff886efa38ff70..057599e0b3edfc 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/env", - "version": "10.30.0", + "version": "10.31.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/CHANGELOG.md b/packages/escape-html/CHANGELOG.md index 88384ca5cfa2f5..6a092fae9aaaef 100644 --- a/packages/escape-html/CHANGELOG.md +++ b/packages/escape-html/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 3.31.0 (2025-09-17) + ## 3.30.0 (2025-09-03) ## 3.29.0 (2025-08-20) diff --git a/packages/escape-html/package.json b/packages/escape-html/package.json index 889d519625280a..3af562db77813d 100644 --- a/packages/escape-html/package.json +++ b/packages/escape-html/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/escape-html", - "version": "3.30.0", + "version": "3.31.0", "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 60be29bd8040d2..805b39cb5d4fb1 100644 --- a/packages/eslint-plugin/CHANGELOG.md +++ b/packages/eslint-plugin/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 22.17.0 (2025-09-17) + ## 22.16.0 (2025-09-03) ## 22.15.0 (2025-08-20) diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json index 935356bfc9668e..d775e0361ea4ca 100644 --- a/packages/eslint-plugin/package.json +++ b/packages/eslint-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/eslint-plugin", - "version": "22.16.0", + "version": "22.17.0", "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 f7711d13880df2..76d5173c24bbab 100644 --- a/packages/fields/CHANGELOG.md +++ b/packages/fields/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.23.0 (2025-09-17) + ## 0.22.0 (2025-09-03) ### Enhancements diff --git a/packages/fields/package.json b/packages/fields/package.json index 8c5348b8dea139..19f41cd0b2cf7a 100644 --- a/packages/fields/package.json +++ b/packages/fields/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/fields", - "version": "0.22.0", + "version": "0.23.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/fields/src/actions/duplicate-post.tsx b/packages/fields/src/actions/duplicate-post.tsx index 78c17fe25dd101..012a4f5ed50661 100644 --- a/packages/fields/src/actions/duplicate-post.tsx +++ b/packages/fields/src/actions/duplicate-post.tsx @@ -56,14 +56,10 @@ const duplicatePost: Action< BasePost > = { return; } - const isTemplate = - item.type === 'wp_template' || - item.type === 'wp_registered_template'; - const newItemObject = { - status: isTemplate ? 'publish' : 'draft', + status: 'draft', title: item.title, - slug: isTemplate ? item.slug : item.title || __( 'No title' ), + slug: item.title || __( 'No title' ), comment_status: item.comment_status, content: typeof item.content === 'string' @@ -102,9 +98,7 @@ const duplicatePost: Action< BasePost > = { try { const newItem = await saveEntityRecord( 'postType', - item.type === 'wp_registered_template' - ? 'wp_template' - : item.type, + item.type, newItemObject, { throwOnError: true } ); diff --git a/packages/fields/src/actions/rename-post.tsx b/packages/fields/src/actions/rename-post.tsx index 4d0f6b27b30293..5c79e07da0c8a5 100644 --- a/packages/fields/src/actions/rename-post.tsx +++ b/packages/fields/src/actions/rename-post.tsx @@ -21,7 +21,12 @@ import { store as noticesStore } from '@wordpress/notices'; */ import { unlock } from '../lock-unlock'; -import { getItemTitle, isTemplatePart } from './utils'; +import { + getItemTitle, + isTemplateRemovable, + isTemplate, + isTemplatePart, +} from './utils'; import type { CoreDataError, PostWithPermissions } from '../types'; // Patterns. @@ -38,6 +43,7 @@ const renamePost: Action< PostWithPermissions > = { // Templates, template parts and patterns have special checks for renaming. if ( ! [ + 'wp_template', 'wp_template_part', ...Object.values( PATTERN_TYPES ), ].includes( post.type ) @@ -45,6 +51,15 @@ const renamePost: Action< PostWithPermissions > = { return post.permissions?.update; } + // In the case of templates, we can only rename custom templates. + if ( isTemplate( post ) ) { + return ( + isTemplateRemovable( post ) && + post.is_custom && + post.permissions?.update + ); + } + if ( isTemplatePart( post ) ) { return ( post.source === 'custom' && diff --git a/packages/fields/src/actions/trash-post.tsx b/packages/fields/src/actions/trash-post.tsx index ff10ca983f77cb..d110d73670314d 100644 --- a/packages/fields/src/actions/trash-post.tsx +++ b/packages/fields/src/actions/trash-post.tsx @@ -18,7 +18,7 @@ import type { Action } from '@wordpress/dataviews'; /** * Internal dependencies */ -import { getItemTitle } from './utils'; +import { getItemTitle, isTemplateOrTemplatePart } from './utils'; import type { CoreDataError, PostWithPermissions } from '../types'; const trashPost: Action< PostWithPermissions > = { @@ -27,7 +27,7 @@ const trashPost: Action< PostWithPermissions > = { isPrimary: true, icon: trash, isEligible( item ) { - if ( item.type === 'wp_template_part' || item.type === 'wp_block' ) { + if ( isTemplateOrTemplatePart( item ) || item.type === 'wp_block' ) { return false; } diff --git a/packages/format-library/CHANGELOG.md b/packages/format-library/CHANGELOG.md index 2e7d5184c1c300..24aa6829ef755b 100644 --- a/packages/format-library/CHANGELOG.md +++ b/packages/format-library/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.31.0 (2025-09-17) + ## 5.30.0 (2025-09-03) ## 5.29.0 (2025-08-20) diff --git a/packages/format-library/package.json b/packages/format-library/package.json index 93d8f7d15393f4..9dffa2a24bc133 100644 --- a/packages/format-library/package.json +++ b/packages/format-library/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/format-library", - "version": "5.30.0", + "version": "5.31.0", "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 7323d79ae3e337..a04d83e9b552b3 100644 --- a/packages/hooks/CHANGELOG.md +++ b/packages/hooks/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.31.0 (2025-09-17) + ## 4.30.0 (2025-09-03) ## 4.29.0 (2025-08-20) diff --git a/packages/hooks/package.json b/packages/hooks/package.json index 249f37acc96a62..41d5008e3331cc 100644 --- a/packages/hooks/package.json +++ b/packages/hooks/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/hooks", - "version": "4.30.0", + "version": "4.31.0", "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 db9bc6a9faa189..eb6fb8de16e215 100644 --- a/packages/html-entities/CHANGELOG.md +++ b/packages/html-entities/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.31.0 (2025-09-17) + ## 4.30.0 (2025-09-03) ## 4.29.0 (2025-08-20) diff --git a/packages/html-entities/package.json b/packages/html-entities/package.json index 11a176b28ee476..8d99de755e9db2 100644 --- a/packages/html-entities/package.json +++ b/packages/html-entities/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/html-entities", - "version": "4.30.0", + "version": "4.31.0", "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 40541a93e17a00..f8ed1fa4196f8d 100644 --- a/packages/i18n/CHANGELOG.md +++ b/packages/i18n/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.4.0 (2025-09-17) + ## 6.3.0 (2025-09-03) ## 6.2.0 (2025-08-20) diff --git a/packages/i18n/package.json b/packages/i18n/package.json index f96cc0da06b8a4..237fc24e7bd6a2 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/i18n", - "version": "6.3.0", + "version": "6.4.0", "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 5fbe5f715cdbea..8c5a60ffaedaee 100644 --- a/packages/icons/CHANGELOG.md +++ b/packages/icons/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 10.31.0 (2025-09-17) + ## 10.30.0 (2025-09-03) ## 10.29.0 (2025-08-20) diff --git a/packages/icons/package.json b/packages/icons/package.json index 1b8efde46a758a..34c9075ae9ce40 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/icons", - "version": "10.30.0", + "version": "10.31.0", "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 2e6c671b39ce4f..b8fa0adce6d950 100644 --- a/packages/interactivity-router/CHANGELOG.md +++ b/packages/interactivity-router/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 2.31.0 (2025-09-17) + ## 2.30.0 (2025-09-03) ## 2.29.0 (2025-08-20) diff --git a/packages/interactivity-router/package.json b/packages/interactivity-router/package.json index 93fbfe46a77e02..958111100f76d0 100644 --- a/packages/interactivity-router/package.json +++ b/packages/interactivity-router/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/interactivity-router", - "version": "2.30.0", + "version": "2.31.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/CHANGELOG.md b/packages/interactivity/CHANGELOG.md index 7a0016e8dffded..56fbcaa7f45bcf 100644 --- a/packages/interactivity/CHANGELOG.md +++ b/packages/interactivity/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.31.0 (2025-09-17) + ## 6.30.0 (2025-09-03) ## 6.29.0 (2025-08-20) diff --git a/packages/interactivity/package.json b/packages/interactivity/package.json index b5215e6ec2965b..70de62920ca230 100644 --- a/packages/interactivity/package.json +++ b/packages/interactivity/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/interactivity", - "version": "6.30.0", + "version": "6.31.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/CHANGELOG.md b/packages/interface/CHANGELOG.md index 56ff448b4967da..12db8f0d4af7f2 100644 --- a/packages/interface/CHANGELOG.md +++ b/packages/interface/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 9.16.0 (2025-09-17) + ## 9.15.0 (2025-09-03) ## 9.14.0 (2025-08-20) diff --git a/packages/interface/package.json b/packages/interface/package.json index a27ab40e3c04c4..f6a6278ef5fd78 100644 --- a/packages/interface/package.json +++ b/packages/interface/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/interface", - "version": "9.15.0", + "version": "9.16.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/CHANGELOG.md b/packages/is-shallow-equal/CHANGELOG.md index 9b2db562d74771..4b0b0eb27c4ca9 100644 --- a/packages/is-shallow-equal/CHANGELOG.md +++ b/packages/is-shallow-equal/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.31.0 (2025-09-17) + ## 5.30.0 (2025-09-03) ## 5.29.0 (2025-08-20) diff --git a/packages/is-shallow-equal/package.json b/packages/is-shallow-equal/package.json index dd56a4c5738fc3..84b69a9028772d 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.30.0", + "version": "5.31.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/CHANGELOG.md b/packages/jest-console/CHANGELOG.md index b6669b0580ef6f..5148c26bc241db 100644 --- a/packages/jest-console/CHANGELOG.md +++ b/packages/jest-console/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 8.31.0 (2025-09-17) + ## 8.30.0 (2025-09-03) ## 8.29.0 (2025-08-20) diff --git a/packages/jest-console/package.json b/packages/jest-console/package.json index 7ce83d27d226a7..ed2f1e2a3fd536 100644 --- a/packages/jest-console/package.json +++ b/packages/jest-console/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/jest-console", - "version": "8.30.0", + "version": "8.31.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/CHANGELOG.md b/packages/jest-preset-default/CHANGELOG.md index e617e1e8b93a7d..0f233ef781bea4 100644 --- a/packages/jest-preset-default/CHANGELOG.md +++ b/packages/jest-preset-default/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 12.31.0 (2025-09-17) + ## 12.30.0 (2025-09-03) ## 12.29.0 (2025-08-20) diff --git a/packages/jest-preset-default/package.json b/packages/jest-preset-default/package.json index 734c494c2cd3e3..d6c8805499f6a5 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.30.0", + "version": "12.31.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/CHANGELOG.md b/packages/jest-puppeteer-axe/CHANGELOG.md index 50b8437b956960..87e79773346762 100644 --- a/packages/jest-puppeteer-axe/CHANGELOG.md +++ b/packages/jest-puppeteer-axe/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 7.31.0 (2025-09-17) + ## 7.30.0 (2025-09-03) ## 7.29.0 (2025-08-20) diff --git a/packages/jest-puppeteer-axe/package.json b/packages/jest-puppeteer-axe/package.json index 13e350126aa6f0..beada3a368e26e 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.30.0", + "version": "7.31.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/CHANGELOG.md b/packages/keyboard-shortcuts/CHANGELOG.md index f6a024abad727c..4aa8fb38b484ba 100644 --- a/packages/keyboard-shortcuts/CHANGELOG.md +++ b/packages/keyboard-shortcuts/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.31.0 (2025-09-17) + ## 5.30.0 (2025-09-03) ## 5.29.0 (2025-08-20) diff --git a/packages/keyboard-shortcuts/package.json b/packages/keyboard-shortcuts/package.json index 666c4bd0bd40b5..c489357afeb476 100644 --- a/packages/keyboard-shortcuts/package.json +++ b/packages/keyboard-shortcuts/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/keyboard-shortcuts", - "version": "5.30.0", + "version": "5.31.0", "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 6c2da7ffa24b6a..8ecd4372b6a74b 100644 --- a/packages/keycodes/CHANGELOG.md +++ b/packages/keycodes/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.31.0 (2025-09-17) + ## 4.30.0 (2025-09-03) ## 4.29.0 (2025-08-20) diff --git a/packages/keycodes/package.json b/packages/keycodes/package.json index fbed6ba1c12ba2..d09552e67ccb1a 100644 --- a/packages/keycodes/package.json +++ b/packages/keycodes/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/keycodes", - "version": "4.30.0", + "version": "4.31.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/CHANGELOG.md b/packages/lazy-import/CHANGELOG.md index 290180812f42d5..4f26620c375ed0 100644 --- a/packages/lazy-import/CHANGELOG.md +++ b/packages/lazy-import/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 2.31.0 (2025-09-17) + ## 2.30.0 (2025-09-03) ## 2.29.0 (2025-08-20) diff --git a/packages/lazy-import/package.json b/packages/lazy-import/package.json index 082e3a65841112..aac10b1da6f3e4 100644 --- a/packages/lazy-import/package.json +++ b/packages/lazy-import/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/lazy-import", - "version": "2.30.0", + "version": "2.31.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/CHANGELOG.md b/packages/list-reusable-blocks/CHANGELOG.md index a25eb99b88fd13..f46e26abd4cb0e 100644 --- a/packages/list-reusable-blocks/CHANGELOG.md +++ b/packages/list-reusable-blocks/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.31.0 (2025-09-17) + ## 5.30.0 (2025-09-03) ## 5.29.0 (2025-08-20) diff --git a/packages/list-reusable-blocks/package.json b/packages/list-reusable-blocks/package.json index bfeb7846434f6f..a963a3f92648d4 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.30.0", + "version": "5.31.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/CHANGELOG.md b/packages/media-utils/CHANGELOG.md index d088316009adca..420dbb91a52c38 100644 --- a/packages/media-utils/CHANGELOG.md +++ b/packages/media-utils/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.31.0 (2025-09-17) + ## 5.30.0 (2025-09-03) ## 5.29.0 (2025-08-20) diff --git a/packages/media-utils/package.json b/packages/media-utils/package.json index 4f02ca4d358201..2061cffe513e5c 100644 --- a/packages/media-utils/package.json +++ b/packages/media-utils/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/media-utils", - "version": "5.30.0", + "version": "5.31.0", "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 a421968a2c5cce..a26d488b5d7be9 100644 --- a/packages/notices/CHANGELOG.md +++ b/packages/notices/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.31.0 (2025-09-17) + ## 5.30.0 (2025-09-03) ## 5.29.0 (2025-08-20) diff --git a/packages/notices/package.json b/packages/notices/package.json index fb3e2f9fdcc8f3..1d4498da565fe3 100644 --- a/packages/notices/package.json +++ b/packages/notices/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/notices", - "version": "5.30.0", + "version": "5.31.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/CHANGELOG.md b/packages/npm-package-json-lint-config/CHANGELOG.md index 760dfc10169a36..9687e7ff392524 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.31.0 (2025-09-17) + ## 5.30.0 (2025-09-03) ## 5.29.0 (2025-08-20) diff --git a/packages/npm-package-json-lint-config/package.json b/packages/npm-package-json-lint-config/package.json index d3940c60d81489..630b654498ff5e 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.30.0", + "version": "5.31.0", "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 d52c3789cffa6f..4d7612293864f0 100644 --- a/packages/nux/CHANGELOG.md +++ b/packages/nux/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 9.31.0 (2025-09-17) + ## 9.30.0 (2025-09-03) ## 9.29.0 (2025-08-20) diff --git a/packages/nux/package.json b/packages/nux/package.json index 4b1e230930e4f3..d929eca3536ebb 100644 --- a/packages/nux/package.json +++ b/packages/nux/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/nux", - "version": "9.30.0", + "version": "9.31.0", "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 f77e359a041516..7cf858c9201818 100644 --- a/packages/patterns/CHANGELOG.md +++ b/packages/patterns/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 2.31.0 (2025-09-17) + ## 2.30.0 (2025-09-03) ## 2.29.0 (2025-08-20) diff --git a/packages/patterns/package.json b/packages/patterns/package.json index 069f2e6e5cafbe..1ccfdb507a7086 100644 --- a/packages/patterns/package.json +++ b/packages/patterns/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/patterns", - "version": "2.30.0", + "version": "2.31.0", "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 ab722bf29e4eed..1877f027149f57 100644 --- a/packages/plugins/CHANGELOG.md +++ b/packages/plugins/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 7.31.0 (2025-09-17) + ## 7.30.0 (2025-09-03) ## 7.29.0 (2025-08-20) diff --git a/packages/plugins/package.json b/packages/plugins/package.json index 1049d21f96534c..48433801288df4 100644 --- a/packages/plugins/package.json +++ b/packages/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/plugins", - "version": "7.30.0", + "version": "7.31.0", "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 bf8952aab4ffbd..05c3162b801daf 100644 --- a/packages/postcss-plugins-preset/CHANGELOG.md +++ b/packages/postcss-plugins-preset/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.31.0 (2025-09-17) + ## 5.30.0 (2025-09-03) ## 5.29.0 (2025-08-20) diff --git a/packages/postcss-plugins-preset/package.json b/packages/postcss-plugins-preset/package.json index 82b96b501eea57..8acadd666411ff 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.30.0", + "version": "5.31.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/CHANGELOG.md b/packages/postcss-themes/CHANGELOG.md index 8930b1060a89c0..a5b418b7039a5a 100644 --- a/packages/postcss-themes/CHANGELOG.md +++ b/packages/postcss-themes/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.31.0 (2025-09-17) + ## 6.30.0 (2025-09-03) ## 6.29.0 (2025-08-20) diff --git a/packages/postcss-themes/package.json b/packages/postcss-themes/package.json index 1b7553266e3197..55998825482732 100644 --- a/packages/postcss-themes/package.json +++ b/packages/postcss-themes/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/postcss-themes", - "version": "6.30.0", + "version": "6.31.0", "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 452864dcf2e2e3..3eb55892a87a37 100644 --- a/packages/preferences-persistence/CHANGELOG.md +++ b/packages/preferences-persistence/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 2.31.0 (2025-09-17) + ## 2.30.0 (2025-09-03) ## 2.29.0 (2025-08-20) diff --git a/packages/preferences-persistence/package.json b/packages/preferences-persistence/package.json index 5c5836a3ec0644..11a69a140a67f6 100644 --- a/packages/preferences-persistence/package.json +++ b/packages/preferences-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/preferences-persistence", - "version": "2.30.0", + "version": "2.31.0", "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 3e336720b10121..33056d5c334b2f 100644 --- a/packages/preferences/CHANGELOG.md +++ b/packages/preferences/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.31.0 (2025-09-17) + ## 4.30.0 (2025-09-03) ## 4.29.0 (2025-08-20) diff --git a/packages/preferences/package.json b/packages/preferences/package.json index 859e3029725249..d08b27bea851b6 100644 --- a/packages/preferences/package.json +++ b/packages/preferences/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/preferences", - "version": "4.30.0", + "version": "4.31.0", "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 0f389954a280cf..0f608e36942bfa 100644 --- a/packages/prettier-config/CHANGELOG.md +++ b/packages/prettier-config/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.31.0 (2025-09-17) + ## 4.30.0 (2025-09-03) ## 4.29.0 (2025-08-20) diff --git a/packages/prettier-config/package.json b/packages/prettier-config/package.json index c83383f5360966..30f03247bdbe46 100644 --- a/packages/prettier-config/package.json +++ b/packages/prettier-config/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/prettier-config", - "version": "4.30.0", + "version": "4.31.0", "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 3992dfbe335c49..150f5c55ee6f29 100644 --- a/packages/primitives/CHANGELOG.md +++ b/packages/primitives/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.31.0 (2025-09-17) + ## 4.30.0 (2025-09-03) ## 4.29.0 (2025-08-20) diff --git a/packages/primitives/package.json b/packages/primitives/package.json index 06261e527ede93..118c0b9ad50db9 100644 --- a/packages/primitives/package.json +++ b/packages/primitives/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/primitives", - "version": "4.30.0", + "version": "4.31.0", "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 46c3221c94885f..355cdcde5600c8 100644 --- a/packages/priority-queue/CHANGELOG.md +++ b/packages/priority-queue/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 3.31.0 (2025-09-17) + ## 3.30.0 (2025-09-03) ## 3.29.0 (2025-08-20) diff --git a/packages/priority-queue/package.json b/packages/priority-queue/package.json index d4190b2e59e968..fc8bd5bc0e74f3 100644 --- a/packages/priority-queue/package.json +++ b/packages/priority-queue/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/priority-queue", - "version": "3.30.0", + "version": "3.31.0", "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 85a00990a7039b..c1be59b9b49b55 100644 --- a/packages/private-apis/CHANGELOG.md +++ b/packages/private-apis/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 1.31.0 (2025-09-17) + ## 1.30.0 (2025-09-03) ## 1.29.0 (2025-08-20) diff --git a/packages/private-apis/package.json b/packages/private-apis/package.json index f92e52184eb16f..1aac014806410c 100644 --- a/packages/private-apis/package.json +++ b/packages/private-apis/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/private-apis", - "version": "1.30.0", + "version": "1.31.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/CHANGELOG.md b/packages/project-management-automation/CHANGELOG.md index 2e1044de9ae4d3..9b6961262020fc 100644 --- a/packages/project-management-automation/CHANGELOG.md +++ b/packages/project-management-automation/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 2.31.0 (2025-09-17) + ## 2.30.0 (2025-09-03) ## 2.29.0 (2025-08-20) diff --git a/packages/project-management-automation/package.json b/packages/project-management-automation/package.json index a9ec091923e4d5..6ad80dbce8ba1a 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.30.0", + "version": "2.31.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/CHANGELOG.md b/packages/react-i18n/CHANGELOG.md index 473ade2c10a2de..2f0374827aa13f 100644 --- a/packages/react-i18n/CHANGELOG.md +++ b/packages/react-i18n/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.31.0 (2025-09-17) + ## 4.30.0 (2025-09-03) ## 4.29.0 (2025-08-20) diff --git a/packages/react-i18n/package.json b/packages/react-i18n/package.json index 468a59b5395c06..4957c279d243f4 100644 --- a/packages/react-i18n/package.json +++ b/packages/react-i18n/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/react-i18n", - "version": "4.30.0", + "version": "4.31.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/CHANGELOG.md b/packages/readable-js-assets-webpack-plugin/CHANGELOG.md index 0d4c01fccee774..7949a86cb9d064 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.31.0 (2025-09-17) + ## 3.30.0 (2025-09-03) ## 3.29.0 (2025-08-20) diff --git a/packages/readable-js-assets-webpack-plugin/package.json b/packages/readable-js-assets-webpack-plugin/package.json index 9e3b007f2b6ec7..8b85f401030b6d 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.30.0", + "version": "3.31.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/CHANGELOG.md b/packages/redux-routine/CHANGELOG.md index c4bfe073e1a2cb..765b31e91717a6 100644 --- a/packages/redux-routine/CHANGELOG.md +++ b/packages/redux-routine/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.31.0 (2025-09-17) + ## 5.30.0 (2025-09-03) ## 5.29.0 (2025-08-20) diff --git a/packages/redux-routine/package.json b/packages/redux-routine/package.json index bff11e069ade0b..fe704815f81c57 100644 --- a/packages/redux-routine/package.json +++ b/packages/redux-routine/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/redux-routine", - "version": "5.30.0", + "version": "5.31.0", "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 8b6040f77625ee..f31abd76b43808 100644 --- a/packages/reusable-blocks/CHANGELOG.md +++ b/packages/reusable-blocks/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 5.31.0 (2025-09-17) + ## 5.30.0 (2025-09-03) ## 5.29.0 (2025-08-20) diff --git a/packages/reusable-blocks/package.json b/packages/reusable-blocks/package.json index c874ef4e6edeb8..c54cf9b1670c7d 100644 --- a/packages/reusable-blocks/package.json +++ b/packages/reusable-blocks/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/reusable-blocks", - "version": "5.30.0", + "version": "5.31.0", "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 71722ff9650fed..c3cb98ff4604d6 100644 --- a/packages/rich-text/CHANGELOG.md +++ b/packages/rich-text/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 7.31.0 (2025-09-17) + ## 7.30.0 (2025-09-03) ## 7.29.0 (2025-08-20) diff --git a/packages/rich-text/package.json b/packages/rich-text/package.json index c7fea4d5773aa2..846dbaa1ac862d 100644 --- a/packages/rich-text/package.json +++ b/packages/rich-text/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/rich-text", - "version": "7.30.0", + "version": "7.31.0", "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 2cfe12d3e3a97c..97cf8c33cb8b9d 100644 --- a/packages/router/CHANGELOG.md +++ b/packages/router/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 1.31.0 (2025-09-17) + ## 1.30.0 (2025-09-03) ## 1.29.0 (2025-08-20) diff --git a/packages/router/package.json b/packages/router/package.json index 2b9438cdb5879a..47f28e9e4f6eb8 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/router", - "version": "1.30.0", + "version": "1.31.0", "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 c95645082813e1..a43dfe4cdec644 100644 --- a/packages/scripts/CHANGELOG.md +++ b/packages/scripts/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 30.24.0 (2025-09-17) + ## 30.23.0 (2025-09-03) ### Internal diff --git a/packages/scripts/package.json b/packages/scripts/package.json index 1e0a51e0301129..30ed163894d8c2 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/scripts", - "version": "30.23.0", + "version": "30.24.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/CHANGELOG.md b/packages/server-side-render/CHANGELOG.md index d872c9f25dc3fc..f274fd9f8e594f 100644 --- a/packages/server-side-render/CHANGELOG.md +++ b/packages/server-side-render/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.7.0 (2025-09-17) + ## 6.6.0 (2025-09-03) ## 6.5.0 (2025-08-20) diff --git a/packages/server-side-render/package.json b/packages/server-side-render/package.json index 2acf5fc0260c76..65dbfe3d01d4da 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.6.0", + "version": "6.7.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/CHANGELOG.md b/packages/shortcode/CHANGELOG.md index ae9b2081495103..e834064a354871 100644 --- a/packages/shortcode/CHANGELOG.md +++ b/packages/shortcode/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.31.0 (2025-09-17) + ## 4.30.0 (2025-09-03) ## 4.29.0 (2025-08-20) diff --git a/packages/shortcode/package.json b/packages/shortcode/package.json index 26dadc056d7739..8af60cae902593 100644 --- a/packages/shortcode/package.json +++ b/packages/shortcode/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/shortcode", - "version": "4.30.0", + "version": "4.31.0", "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 461475ad983166..08b530ec40cef8 100644 --- a/packages/style-engine/CHANGELOG.md +++ b/packages/style-engine/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 2.31.0 (2025-09-17) + ## 2.30.0 (2025-09-03) ## 2.29.0 (2025-08-20) diff --git a/packages/style-engine/package.json b/packages/style-engine/package.json index fd5abac06e6afb..7ca08a330cdeb0 100644 --- a/packages/style-engine/package.json +++ b/packages/style-engine/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/style-engine", - "version": "2.30.0", + "version": "2.31.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/CHANGELOG.md b/packages/stylelint-config/CHANGELOG.md index df3d1a92dda111..8b03379972cd2c 100644 --- a/packages/stylelint-config/CHANGELOG.md +++ b/packages/stylelint-config/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 23.23.0 (2025-09-17) + ## 23.22.0 (2025-09-03) ## 23.21.0 (2025-08-20) diff --git a/packages/stylelint-config/package.json b/packages/stylelint-config/package.json index df764f67df41f0..47688e0f101c8b 100644 --- a/packages/stylelint-config/package.json +++ b/packages/stylelint-config/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/stylelint-config", - "version": "23.22.0", + "version": "23.23.0", "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 347e611763ab14..aff2ca7031cddb 100644 --- a/packages/sync/CHANGELOG.md +++ b/packages/sync/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 1.31.0 (2025-09-17) + ## 1.30.0 (2025-09-03) ## 1.29.0 (2025-08-20) diff --git a/packages/sync/CODE.md b/packages/sync/CODE.md index 40a4b76d2cfd42..e129a41f3a7cd2 100644 --- a/packages/sync/CODE.md +++ b/packages/sync/CODE.md @@ -1,54 +1,60 @@ # 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 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 experiment -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: +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: -- `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. +```js +addFilter( 'core.getSyncProvider', 'my-plugin/custom-sync-provider', () => { + return new SyncProvider( /* ...args */ ); +} ); +``` + +When it is enabled, the following global variables are defined:: + +- `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. +- `syncedProperties` is the set of entity properties that should be synced (possibly including computed or meta properties from `getInitialObjectData`). + +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. diff --git a/packages/sync/README.md b/packages/sync/README.md index f15d61b5a1eb5f..5e19247f035ae2 100644 --- a/packages/sync/README.md +++ b/packages/sync/README.md @@ -26,20 +26,73 @@ _Parameters_ _Returns_ -- `Promise<() => void>`: Promise that resolves when the connection is established. +- `Promise< ConnectDocResult >`: Promise that resolves when the connection is established. -### createSyncProvider +### CRDT_DOC_VERSION -Create a sync provider. +The version number for the CRDT Document. -_Parameters_ +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. -- _connectLocal_ `ConnectDoc`: Connect the document to a local database. -- _connectRemote_ `ConnectDoc`: Connect the document to a remote sync connection. +_Type_ -_Returns_ +- `number` + +### CRDT_RECORD_MAP_KEY + +The key used to store the Y.Map containing the document content. + +_Type_ + +- `string` + +### CRDT_STATE_MAP_KEY + +The key used to store the Y.Map containing the document state (metadata). + +_Type_ + +- `string` + +### CRDT_STATE_PERSISTED_AT_KEY + +The key used to store the last persisted date in the state map. + +_Type_ + +- `string` + +### CRDT_STATE_PERSISTED_BY_KEY + +The key used to store the user ID of the last user who persisted the document. + +_Type_ + +- `string` + +### CRDT_STATE_RESTORED_AT_KEY + +The key used to store the date when the document was last restored. + +_Type_ + +- `string` + +### CRDT_STATE_RESTORED_BY_KEY + +The key used to store the user ID of the last user who restored the document. -- `SyncProvider`: Sync provider. +_Type_ + +- `string` + +### CRDT_STATE_VERSION_KEY + +The key used to store the version of the document. + +_Type_ + +- `string` ### createWebRTCConnection @@ -47,13 +100,47 @@ 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_ + +- `ConnectDoc`: Promise that resolves when the connection is established. + +### getWebRTCSyncProvider + +Returns a WebRTC sync provider. This is the curent default sync provider. _Returns_ -- `Function`: Promise that resolves when the connection is established. +- `SyncProvider`: The WebRTC sync provider. + +### LOCAL_EDITOR_ORIGIN + +The origin string used to identify changes made by the editor. + +_Type_ + +- `string` + +### LOCAL_SYNC_PROVIDER_ORIGIN + +The origin string used to identify changes made by the sync provider. + +_Type_ + +- `string` + +### SyncProvider + +The SyncProvider manages access to CRDT documents for multiple entities, including their lifecycle, connections, and syncing changes between the CRDT document and the local store. + +_Type_ + +- `SyncProvider` + +### Y + +Exported instance of Yjs to prevent double instantiation issues. diff --git a/packages/sync/package.json b/packages/sync/package.json index aa345bbf32b3ce..3cdae4d7908346 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/sync", - "version": "1.30.0", + "version": "1.31.0", "description": "Sync Data.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", @@ -29,16 +29,14 @@ "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-protocols": "^1.0.5", - "y-webrtc": "~10.2.5", - "yjs": "~13.6.6" + "y-indexeddb": "^9.0.12", + "y-protocols": "^1.0.6", + "yjs": "13.6.27" }, "publishConfig": { "access": "public" diff --git a/packages/sync/src/config.ts b/packages/sync/src/config.ts new file mode 100644 index 00000000000000..95882c122b244f --- /dev/null +++ b/packages/sync/src/config.ts @@ -0,0 +1,73 @@ +/** + * The version number for the CRDT Document. + * + * 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. + * + * @type {number} + */ +export const CRDT_DOC_VERSION = 1; + +// Map keys in the root Yjs document. + +/** + * The key used to store the Y.Map containing the document content. + * + * @type {string} + */ +export const CRDT_RECORD_MAP_KEY = 'document'; +/** + * The key used to store the Y.Map containing the document state (metadata). + * + * @type {string} + */ +export const CRDT_STATE_MAP_KEY = 'state'; + +// Sub-keys. + +/** + * The key used to store the last persisted date in the state map. + * + * @type {string} + */ +export const CRDT_STATE_PERSISTED_AT_KEY = 'persistedAt'; +/** + * The key used to store the user ID of the last user who persisted the document. + * + * @type {string} + */ +export const CRDT_STATE_PERSISTED_BY_KEY = 'persistedBy'; +/** + * The key used to store the date when the document was last restored. + * + * @type {string} + */ +export const CRDT_STATE_RESTORED_AT_KEY = 'restoredAt'; +/** + * The key used to store the user ID of the last user who restored the document. + * + * @type {string} + */ +export const CRDT_STATE_RESTORED_BY_KEY = 'restoredBy'; +/** + * The key used to store the version of the document. + * + * @type {string} + */ +export const CRDT_STATE_VERSION_KEY = 'version'; + +// Origin strings. + +/** + * The origin string used to identify changes made by the editor. + * + * @type {string} + */ +export const LOCAL_EDITOR_ORIGIN = 'gutenberg'; +/** + * The origin string used to identify changes made by the sync provider. + * + * @type {string} + */ +export const LOCAL_SYNC_PROVIDER_ORIGIN = 'syncProvider'; diff --git a/packages/sync/src/connect-indexdb.js b/packages/sync/src/connect-indexdb.js index 5523640408f575..5329f066b296ea 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,13 @@ 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( { + destroy: () => provider.destroy(), } ); } 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..2ea51777e8a91e --- /dev/null +++ b/packages/sync/src/create-webrtc-connection.ts @@ -0,0 +1,42 @@ +/** + * 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( { + destroy: () => {}, + } ); + }; +} 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..3948d915746a51 --- /dev/null +++ b/packages/sync/src/index.ts @@ -0,0 +1,51 @@ +/** + * WordPress dependencies + */ + +/** + * Internal dependencies + */ +import { connectIndexDb } from './connect-indexdb'; +import { createWebRTCConnection } from './create-webrtc-connection'; +import { SyncProvider } from './provider'; + +/** + * Exported instance of Yjs to prevent double instantiation issues. + */ +export * as Y from 'yjs'; +export * from './config'; +export { connectIndexDb } from './connect-indexdb'; +export { createWebRTCConnection } from './create-webrtc-connection'; +export { SyncProvider } 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. + * + * @return {SyncProvider} The WebRTC sync provider. + */ +export function getWebRTCSyncProvider(): SyncProvider { + return new SyncProvider( [ + connectIndexDb, + createWebRTCConnection( { + password: window?.__experimentalCollaborativeEditingSecret, + signaling: [ + //'ws://localhost:4444', + window?.wp?.ajax?.settings?.url, + ], + } ), + ] ); +} 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..40774eb8a32323 --- /dev/null +++ b/packages/sync/src/provider.ts @@ -0,0 +1,436 @@ +/** + * External dependencies + */ +import * as Y from 'yjs'; +import { Awareness } from 'y-protocols/awareness'; + +/** + * Internal dependencies + */ +import { + CRDT_DOC_VERSION, + 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 { + ConnectDoc, + ConnectDocResult, + CRDTDoc, + EntityID, + ObjectID, + ObjectData, + ObjectType, + SyncConfig, + RecordHandlers, +} from './types'; +import { UndoManager } from './undo-manager'; +import { createYjsDoc } from './utils'; + +interface EntityState { + awareness?: Awareness; + discard: () => void; + handlers: RecordHandlers; + objectId: ObjectID; + syncConfig: SyncConfig; + ydoc: CRDTDoc; +} + +/** + * The SyncProvider manages access to CRDT documents for multiple entities, + * including their lifecycle, connections, and syncing changes between the CRDT + * document and the local store. + */ +export class SyncProvider { + private connectionCreators: ConnectDoc[]; + private undoManager: UndoManager; + + protected entityStates: Map< EntityID, EntityState > = new Map(); + + /** + * Constructor. + * + * @param {ConnectDoc[]} connectionCreators Functions that create Yjs connection providers. + */ + public constructor( connectionCreators: ConnectDoc[] = [] ) { + this.connectionCreators = connectionCreators; + this.undoManager = UndoManager.create(); + } + + /** + * Bootstrap an entity for syncing and manage its lifecycle. + * + * @param {SyncConfig} syncConfig Sync configuration for the 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, + rawRecord: ObjectData, + handlers: RecordHandlers + ): Promise< void > { + const now = Date.now(); + const objectId = syncConfig.getObjectId( rawRecord ); + const objectType = syncConfig.objectType; + const ydoc = createYjsDoc( { objectType } ); + 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() ); + recordMap.unobserveDeep( onRecordUpdate ); + stateMap.unobserve( onStateUpdate ); + ydoc.destroy(); + this.entityStates.delete( entityId ); + }; + + // 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 && + ! ( transaction.origin instanceof Y.UndoManager ) + ) { + 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, + objectId, + syncConfig, + ydoc, + }; + + if ( syncConfig.supports?.awareness ) { + entityState.awareness = new Awareness( ydoc ); + } + + if ( syncConfig.supports?.undo ) { + this.undoManager.addToScope( recordMap ); + } + + 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( + syncConfig, + rawRecord + ); + + // Apply the initial document to the current document as a singular update. + if ( initialDoc ) { + ydoc.transact( () => { + Y.applyUpdateV2( ydoc, Y.encodeStateAsUpdateV2( initialDoc ) ); + }, LOCAL_SYNC_PROVIDER_ORIGIN ); + } + + if ( ! initialDoc || true === initialDoc?.meta?.get( 'invalidated' ) ) { + ydoc.transact( () => { + syncConfig.applyChangesToCRDTDoc( + ydoc, + syncConfig.getInitialObjectData( rawRecord ), + rawRecord, + LOCAL_SYNC_PROVIDER_ORIGIN + ); + + // 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 + // 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 ) ), + }, + } ); + } + } + + /** + * Establish connections for the given entity and its Yjs document. + * + * @param {EntityState} entityState State for the entity. + */ + private async connect( + entityState: EntityState + ): Promise< ConnectDocResult[] > { + return await Promise.all( + this.connectionCreators?.map( ( create ) => + create( + entityState.objectId, + entityState.syncConfig.objectType, + entityState.ydoc, + entityState.awareness + ) + ) + ); + } + + /** + * Stop syncing an entity and destroy its in-memory state. + * + * @param {ObjectType} objectType Object type to discard. + * @param {ObjectID} objectId Object ID to discard. + */ + public discard( objectType: ObjectType, objectId: ObjectID ): void { + this.entityStates + .get( this.getEntityId( objectType, objectId ) ) + ?.discard(); + } + + /** + * 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 + ): EntityID { + return `${ objectType }_${ objectId }`; + } + + /** + * 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} rawRecord Initial data to apply to the document. + */ + private async getInitialCRDTDoc( + syncConfig: SyncConfig, + rawRecord: ObjectData + ): Promise< CRDTDoc | null > { + // Load the persisted document from previous sessions. + const persistedDoc = await this.getPersistedCRDTDoc( + syncConfig, + rawRecord + ); + + // If it exists and matches the current version, apply it as the base state + // of the initial document. + if ( ! persistedDoc ) { + return null; + } + + const stateMap = persistedDoc.getMap( STATE_KEY ); + + 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 persistedDoc; + } + + /* 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} _rawRecord Raw record representing this object type. + * @return {Promise< Record< string, any > >} Entity meta. + */ + public async createEntityMeta( + _syncConfig: SyncConfig, + _rawRecord: 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. + * + * 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. + */ + protected async getPersistedCRDTDoc( + _syncConfig: SyncConfig, + _rawRecord: ObjectData + ): Promise< CRDTDoc | null > { + return Promise.resolve( null ); + } + + /* eslint-enable @typescript-eslint/no-unused-vars */ + + /** + * Get the undo manager. + * + * @return {UndoManager} The undo manager. + */ + public getUndoManager(): UndoManager { + return this.undoManager; + } + + /** + * Update CRDT document with changes from the local store. + * + * @param {SyncConfig} syncConfig Sync configuration for the object type. + * @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, + rawRecord: ObjectData, + changes: Partial< ObjectData >, + origin: string + ): void { + const objectType = syncConfig.objectType; + const objectId = syncConfig.getObjectId( rawRecord ); + const entityId = this.getEntityId( objectType, objectId ); + const ydoc = this.entityStates.get( entityId )?.ydoc; + + ydoc?.transact( () => { + syncConfig.applyChangesToCRDTDoc( + ydoc, + changes, + rawRecord, + 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( + objectType: ObjectType, + objectId: ObjectID + ): Promise< void > { + const entityId = this.getEntityId( objectType, objectId ); + const entityState = this.entityStates.get( entityId ); + + if ( ! entityState ) { + return; + } + + const { handlers, syncConfig, ydoc } = entityState; + + const currentRecord = await handlers.getEditedRecord(); + + // 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 ); + } + + /** + * 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 {SyncConfig} syncConfig Sync configuration for the object type. + * @param {ObjectData} rawRecord Raw record representing this object type. + */ + public updateLastPersistedDate( + syncConfig: SyncConfig, + rawRecord: ObjectData + ): void { + const objectId = syncConfig.getObjectId( rawRecord ); + const objectType = syncConfig.objectType; + const entityId = this.getEntityId( objectType, objectId ); + const entityState = this.entityStates.get( entityId ); + + if ( ! entityState ) { + return; + } + + const ydoc = entityState.ydoc; + + 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 ); + } +} diff --git a/packages/sync/src/test/config.ts b/packages/sync/src/test/config.ts new file mode 100644 index 00000000000000..921ae3ee5cc8ac --- /dev/null +++ b/packages/sync/src/test/config.ts @@ -0,0 +1,74 @@ +/** + * External dependencies + */ +import { describe, expect, it } from '@jest/globals'; + +/** + * Internal dependencies + */ +import { + CRDT_DOC_VERSION, + CRDT_RECORD_MAP_KEY, + CRDT_STATE_MAP_KEY, + CRDT_STATE_PERSISTED_AT_KEY, + CRDT_STATE_PERSISTED_BY_KEY, + CRDT_STATE_RESTORED_AT_KEY, + CRDT_STATE_RESTORED_BY_KEY, + CRDT_STATE_VERSION_KEY, + LOCAL_EDITOR_ORIGIN, + LOCAL_SYNC_PROVIDER_ORIGIN, +} from '../config'; + +describe( 'config', () => { + describe( 'CRDT_DOC_VERSION', () => { + it( 'is defined as a number', () => { + expect( typeof CRDT_DOC_VERSION ).toBe( 'number' ); + } ); + + it( 'has the correct version value', () => { + expect( CRDT_DOC_VERSION ).toBe( 1 ); + } ); + } ); + + describe( 'Map keys', () => { + it( 'defines CRDT_RECORD_MAP_KEY', () => { + expect( CRDT_RECORD_MAP_KEY ).toBe( 'document' ); + } ); + + it( 'defines CRDT_STATE_MAP_KEY', () => { + expect( CRDT_STATE_MAP_KEY ).toBe( 'state' ); + } ); + } ); + + describe( 'State sub-keys', () => { + it( 'defines CRDT_STATE_PERSISTED_AT_KEY', () => { + expect( CRDT_STATE_PERSISTED_AT_KEY ).toBe( 'persistedAt' ); + } ); + + it( 'defines CRDT_STATE_PERSISTED_BY_KEY', () => { + expect( CRDT_STATE_PERSISTED_BY_KEY ).toBe( 'persistedBy' ); + } ); + + it( 'defines CRDT_STATE_RESTORED_AT_KEY', () => { + expect( CRDT_STATE_RESTORED_AT_KEY ).toBe( 'restoredAt' ); + } ); + + it( 'defines CRDT_STATE_RESTORED_BY_KEY', () => { + expect( CRDT_STATE_RESTORED_BY_KEY ).toBe( 'restoredBy' ); + } ); + + it( 'defines CRDT_STATE_VERSION_KEY', () => { + expect( CRDT_STATE_VERSION_KEY ).toBe( 'version' ); + } ); + } ); + + describe( 'Origin strings', () => { + it( 'defines LOCAL_EDITOR_ORIGIN', () => { + expect( LOCAL_EDITOR_ORIGIN ).toBe( 'gutenberg' ); + } ); + + it( 'defines LOCAL_SYNC_PROVIDER_ORIGIN', () => { + expect( LOCAL_SYNC_PROVIDER_ORIGIN ).toBe( 'syncProvider' ); + } ); + } ); +} ); diff --git a/packages/sync/src/test/connect-indexdb.js b/packages/sync/src/test/connect-indexdb.js new file mode 100644 index 00000000000000..bcb99dc8665bd8 --- /dev/null +++ b/packages/sync/src/test/connect-indexdb.js @@ -0,0 +1,60 @@ +/** + * External dependencies + */ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; + +const mockIndexeddbPersistence = { + destroy: jest.fn(), +}; + +jest.mock( 'y-indexeddb', () => { + return { + IndexeddbPersistence: jest + .fn() + .mockImplementation( () => mockIndexeddbPersistence ), + }; +} ); + +const mockYDoc = { + clientID: 12345, + meta: new Map(), + getMap: jest.fn(), + transact: jest.fn( ( fn ) => fn() ), + destroy: jest.fn(), +}; + +jest.mock( 'yjs', () => ( { + Doc: jest.fn().mockImplementation( () => mockYDoc ), +} ) ); + +/** + * Internal dependencies + */ +import { connectIndexDb } from '../connect-indexdb'; + +describe( 'connectIndexDb', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'creates an IndexeddbPersistence provider correctly', async () => { + const { IndexeddbPersistence } = jest.requireMock( 'y-indexeddb' ); + const objectId = '123'; + const objectType = 'post'; + const doc = mockYDoc; + + const result = await connectIndexDb( objectId, objectType, doc ); + + expect( result ).toBeDefined(); + expect( typeof result.destroy ).toBe( 'function' ); + expect( IndexeddbPersistence ).toHaveBeenCalledWith( 'post-123', doc ); + } ); + + it( 'destroy method calls provider.destroy', async () => { + const result = await connectIndexDb( '789', 'post', mockYDoc ); + + result.destroy(); + + expect( mockIndexeddbPersistence.destroy ).toHaveBeenCalled(); + } ); +} ); diff --git a/packages/sync/src/test/create-webrtc-connection.ts b/packages/sync/src/test/create-webrtc-connection.ts new file mode 100644 index 00000000000000..83b442e174064e --- /dev/null +++ b/packages/sync/src/test/create-webrtc-connection.ts @@ -0,0 +1,161 @@ +/** + * External dependencies + */ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; +import type * as Y from 'yjs'; + +const mockWebrtcProvider = { + destroy: jest.fn(), +}; + +jest.mock( '../webrtc-http-stream-signaling', () => { + return { + WebrtcProviderWithHttpSignaling: jest + .fn() + .mockImplementation( () => mockWebrtcProvider ), + }; +} ); + +const mockYDoc = { + clientID: 12345, + meta: new Map(), + getMap: jest.fn(), + transact: jest.fn( ( fn: () => void ) => fn() ), + destroy: jest.fn(), +}; + +jest.mock( 'yjs', () => ( { + Doc: jest.fn().mockImplementation( () => mockYDoc ), +} ) ); + +/** + * Internal dependencies + */ +import { + createWebRTCConnection, + type WebRTCConnectionConfig, +} from '../create-webrtc-connection'; + +describe( 'createWebRTCConnection', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + describe( 'configuration', () => { + it( 'creates a connection function with signaling servers', () => { + const config: WebRTCConnectionConfig = { + signaling: [ 'ws://localhost:4444' ], + }; + + const connectDoc = createWebRTCConnection( config ); + + expect( typeof connectDoc ).toBe( 'function' ); + } ); + + it( 'accepts password in configuration', () => { + const config: WebRTCConnectionConfig = { + signaling: [ 'ws://localhost:4444' ], + password: 'test-password', + }; + + const connectDoc = createWebRTCConnection( config ); + + expect( typeof connectDoc ).toBe( 'function' ); + } ); + + it( 'accepts multiple signaling servers', () => { + const config: WebRTCConnectionConfig = { + signaling: [ + 'ws://localhost:4444', + 'ws://localhost:5555', + 'wss://example.com/signaling', + ], + }; + + const connectDoc = createWebRTCConnection( config ); + + expect( typeof connectDoc ).toBe( 'function' ); + } ); + } ); + + describe( 'connection function', () => { + it( 'creates WebrtcProvider with correct room name', async () => { + const { WebrtcProviderWithHttpSignaling } = jest.requireMock( + '../webrtc-http-stream-signaling' + ) as { + WebrtcProviderWithHttpSignaling: jest.Mock; + }; + + const config: WebRTCConnectionConfig = { + signaling: [ 'ws://localhost:4444' ], + }; + + const connectDoc = createWebRTCConnection( config ); + await connectDoc( '123', 'post', mockYDoc as unknown as Y.Doc ); + + expect( WebrtcProviderWithHttpSignaling ).toHaveBeenCalledWith( + 'post-123', + mockYDoc, + expect.objectContaining( { + signaling: [ 'ws://localhost:4444' ], + } ) + ); + } ); + + it( 'passes password to WebrtcProvider', async () => { + const { WebrtcProviderWithHttpSignaling } = jest.requireMock( + '../webrtc-http-stream-signaling' + ) as { + WebrtcProviderWithHttpSignaling: jest.Mock; + }; + + const config: WebRTCConnectionConfig = { + signaling: [ 'ws://localhost:4444' ], + password: 'secret-password', + }; + + const connectDoc = createWebRTCConnection( config ); + await connectDoc( '456', 'page', mockYDoc as unknown as Y.Doc ); + + expect( WebrtcProviderWithHttpSignaling ).toHaveBeenCalledWith( + 'page-456', + mockYDoc, + expect.objectContaining( { + signaling: [ 'ws://localhost:4444' ], + password: 'secret-password', + } ) + ); + } ); + + it( 'returns promise with destroy method', async () => { + const config: WebRTCConnectionConfig = { + signaling: [ 'ws://localhost:4444' ], + }; + + const connectDoc = createWebRTCConnection( config ); + const result = await connectDoc( + '789', + 'post', + mockYDoc as unknown as Y.Doc + ); + + expect( result ).toBeDefined(); + expect( typeof result.destroy ).toBe( 'function' ); + } ); + + it( 'destroy method is a no-op', async () => { + const config: WebRTCConnectionConfig = { + signaling: [ 'ws://localhost:4444' ], + }; + + const connectDoc = createWebRTCConnection( config ); + const result = await connectDoc( + '100', + 'post', + mockYDoc as unknown as Y.Doc + ); + + expect( () => result.destroy() ).not.toThrow(); + } ); + } ); +} ); diff --git a/packages/sync/src/test/index.ts b/packages/sync/src/test/index.ts new file mode 100644 index 00000000000000..dd264e438e6782 --- /dev/null +++ b/packages/sync/src/test/index.ts @@ -0,0 +1,94 @@ +/** + * External dependencies + */ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; + +jest.mock( '../connect-indexdb', () => ( { + connectIndexDb: jest.fn(), +} ) ); + +jest.mock( '../create-webrtc-connection', () => ( { + createWebRTCConnection: jest.fn(), +} ) ); + +/** + * Internal dependencies + */ +import { getWebRTCSyncProvider, SyncProvider } from '../index'; + +describe( 'index', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + describe( 'getWebRTCSyncProvider', () => { + it( 'creates a SyncProvider instance', () => { + const provider = getWebRTCSyncProvider(); + + expect( provider ).toBeInstanceOf( SyncProvider ); + } ); + + it( 'calls createWebRTCConnection with window settings', () => { + const { createWebRTCConnection } = jest.requireMock( + '../create-webrtc-connection' + ) as { createWebRTCConnection: jest.Mock }; + + getWebRTCSyncProvider(); + + expect( createWebRTCConnection ).toHaveBeenCalledWith( + expect.objectContaining( { + password: undefined, + signaling: expect.arrayContaining( [ undefined ] ), + } ) + ); + } ); + + it( 'uses __experimentalCollaborativeEditingSecret and wp.ajax.settings.url when available', () => { + const { createWebRTCConnection } = jest.requireMock( + '../create-webrtc-connection' + ) as { createWebRTCConnection: jest.Mock }; + + globalThis.window.__experimentalCollaborativeEditingSecret = + 'test-secret'; + globalThis.window.wp = { + ajax: { settings: { url: 'https://example.com' } }, + }; + + getWebRTCSyncProvider(); + + expect( createWebRTCConnection ).toHaveBeenCalledWith( + expect.objectContaining( { + password: 'test-secret', + signaling: expect.arrayContaining( [ + 'https://example.com', + ] ), + } ) + ); + + delete ( globalThis.window as any ) + .__experimentalCollaborativeEditingSecret; + delete ( globalThis.window as any ).wp; + } ); + + it( 'creates new provider instance on each call', () => { + const provider1 = getWebRTCSyncProvider(); + const provider2 = getWebRTCSyncProvider(); + + expect( provider1 ).not.toBe( provider2 ); + } ); + + it( 'handles missing window.wp gracefully', () => { + const { createWebRTCConnection } = jest.requireMock( + '../create-webrtc-connection' + ) as { createWebRTCConnection: jest.Mock }; + + expect( () => getWebRTCSyncProvider() ).not.toThrow(); + + expect( createWebRTCConnection ).toHaveBeenCalledWith( + expect.objectContaining( { + signaling: expect.arrayContaining( [ undefined ] ), + } ) + ); + } ); + } ); +} ); diff --git a/packages/sync/src/test/provider.ts b/packages/sync/src/test/provider.ts new file mode 100644 index 00000000000000..52f7f8ea807ca6 --- /dev/null +++ b/packages/sync/src/test/provider.ts @@ -0,0 +1,487 @@ +/** + * External dependencies + */ +import { + describe, + expect, + it, + jest, + beforeEach, + afterEach, +} from '@jest/globals'; +import type * as Y from 'yjs'; + +/** + * Internal dependencies + */ +import { SyncProvider } from '../provider'; +import { + CRDT_STATE_VERSION_KEY, + CRDT_RECORD_MAP_KEY, + CRDT_STATE_MAP_KEY, + CRDT_STATE_PERSISTED_AT_KEY, + CRDT_STATE_PERSISTED_BY_KEY, + CRDT_STATE_RESTORED_AT_KEY, + CRDT_DOC_VERSION, +} from '../config'; +import type { SyncConfig, RecordHandlers, ObjectData } from '../types'; + +const mockYMapData = new Map(); +const mockYMap = { + get: jest.fn( ( key: string ) => mockYMapData.get( key ) ), + set: jest.fn( ( key: string, value: any ) => + mockYMapData.set( key, value ) + ), + observe: jest.fn(), + unobserve: jest.fn(), + observeDeep: jest.fn(), + unobserveDeep: jest.fn(), +}; + +const mockYDoc = { + clientID: 12345, + meta: new Map(), + getMap: jest.fn( () => mockYMap ), + transact: jest.fn( ( fn: () => void ) => fn() ), + destroy: jest.fn(), +}; + +jest.mock( 'yjs', () => ( { + Doc: jest.fn().mockImplementation( () => mockYDoc ), + UndoManager: jest.fn().mockImplementation( () => ( { + undo: jest.fn(), + redo: jest.fn(), + } ) ), + applyUpdate: jest.fn(), + encodeStateAsUpdate: jest.fn( () => new Uint8Array() ), +} ) ); + +jest.mock( 'y-protocols/awareness', () => ( { + Awareness: jest.fn().mockImplementation( () => ( { + destroy: jest.fn(), + setLocalState: jest.fn(), + getStates: jest.fn( () => new Map() ), + } ) ), +} ) ); + +class TestSyncProvider extends SyncProvider { + public get testEntityStates() { + return this.entityStates; + } +} + +jest.mock( '../y-utilities/y-multidoc-undomanager', () => ( { + YMultiDocUndoManager: jest.fn().mockImplementation( () => ( { + addToScope: jest.fn(), + undo: jest.fn(), + redo: jest.fn(), + canUndo: jest.fn( () => false ), + canRedo: jest.fn( () => false ), + } ) ), +} ) ); + +describe( 'SyncProvider', () => { + let syncProvider: TestSyncProvider; + let mockSyncConfig: SyncConfig; + let mockHandlers: RecordHandlers; + let mockRawRecord: ObjectData; + + beforeEach( () => { + jest.clearAllMocks(); + + mockYDoc.meta = new Map(); + mockYMapData.clear(); + mockYMap.get.mockClear(); + mockYMap.set.mockClear(); + mockYMap.observe.mockClear(); + mockYMap.unobserve.mockClear(); + mockYMap.observeDeep.mockClear(); + mockYMap.unobserveDeep.mockClear(); + mockYDoc.getMap.mockClear().mockReturnValue( mockYMap ); + mockYDoc.transact.mockClear(); + mockYDoc.destroy.mockClear(); + + mockSyncConfig = { + objectType: 'post', + getObjectId: jest.fn( ( record: ObjectData ) => + String( record.id ) + ) as ( record: ObjectData ) => string, + getInitialObjectData: jest.fn( + ( record: ObjectData ) => record + ) as ( record: ObjectData ) => ObjectData, + applyChangesToCRDTDoc: jest.fn() as ( + ydoc: Y.Doc, + changes: Partial< ObjectData >, + rawRecord: ObjectData, + origin: string + ) => void, + getChangesFromCRDTDoc: jest.fn( () => ( {} ) ) as ( + ydoc: Y.Doc, + record: ObjectData + ) => ObjectData, + syncedProperties: new Set( [ 'title', 'content' ] ), + supports: { + awareness: true, + undo: true, + crdtPersistence: false, + }, + }; + + mockHandlers = { + editRecord: jest.fn() as jest.Mock< + ( data: Partial< ObjectData > ) => void + >, + getEditedRecord: jest.fn( async () => mockRawRecord ) as jest.Mock< + () => Promise< ObjectData > + >, + refetchPersistedRecord: jest.fn() as jest.Mock< () => void >, + }; + + mockRawRecord = { + id: 1, + title: 'Test Post', + content: 'Test Content', + meta: {}, + }; + + syncProvider = new TestSyncProvider( [] ); + } ); + + afterEach( () => { + if ( syncProvider ) { + syncProvider.testEntityStates?.forEach( ( state ) => { + state?.discard(); + } ); + } + } ); + + describe( 'constructor', () => { + it( 'creates a new SyncProvider instance', () => { + expect( syncProvider ).toBeInstanceOf( SyncProvider ); + } ); + + it( 'initializes with empty connection creators', () => { + const provider = new TestSyncProvider(); + expect( provider ).toBeInstanceOf( SyncProvider ); + } ); + + it( 'accepts connection creators', () => { + const mockConnectionCreator = jest.fn( () => + Promise.resolve( { destroy: jest.fn() } ) + ); + const provider = new TestSyncProvider( [ mockConnectionCreator ] ); + expect( provider ).toBeInstanceOf( SyncProvider ); + } ); + + it( 'creates an undo manager', () => { + const provider = new TestSyncProvider(); + const undoManager = provider.getUndoManager(); + + expect( undoManager ).toBeDefined(); + expect( undoManager ).not.toBeNull(); + expect( typeof undoManager?.undo ).toBe( 'function' ); + expect( typeof undoManager?.redo ).toBe( 'function' ); + expect( typeof undoManager?.hasUndo ).toBe( 'function' ); + expect( typeof undoManager?.hasRedo ).toBe( 'function' ); + } ); + } ); + + describe( 'bootstrap', () => { + it( 'bootstraps an entity and creates CRDT document', async () => { + await syncProvider.bootstrap( + mockSyncConfig, + mockRawRecord, + mockHandlers + ); + + const entityId = 'post_1'; + const entityState = syncProvider.testEntityStates.get( entityId ); + + expect( entityState ).toBeDefined(); + expect( entityState?.objectId ).toBe( '1' ); + expect( entityState?.syncConfig ).toBe( mockSyncConfig ); + expect( entityState?.handlers ).toBe( mockHandlers ); + expect( entityState?.ydoc ).toBeDefined(); + expect( entityState?.awareness ).toBeDefined(); + } ); + + it( 'does not create awareness when not supported', async () => { + const configWithoutAwareness = { + ...mockSyncConfig, + supports: { ...mockSyncConfig.supports, awareness: false }, + }; + + await syncProvider.bootstrap( + configWithoutAwareness, + mockRawRecord, + mockHandlers + ); + + const entityId = 'post_1'; + const entityState = syncProvider.testEntityStates.get( entityId ); + + expect( entityState?.awareness ).toBeUndefined(); + } ); + + it( 'initializes state map with correct values', async () => { + await syncProvider.bootstrap( + mockSyncConfig, + mockRawRecord, + mockHandlers + ); + + const entityId = 'post_1'; + const entityState = syncProvider.testEntityStates.get( entityId ); + const stateMap = entityState?.ydoc.getMap( CRDT_STATE_MAP_KEY ); + + expect( stateMap?.get( CRDT_STATE_PERSISTED_AT_KEY ) ).toBe( 0 ); + expect( stateMap?.get( CRDT_STATE_RESTORED_AT_KEY ) ).toBe( 0 ); + expect( stateMap?.get( CRDT_STATE_VERSION_KEY ) ).toBe( + CRDT_DOC_VERSION + ); + } ); + + it( 'applies initial object data when no persisted doc exists', async () => { + await syncProvider.bootstrap( + mockSyncConfig, + mockRawRecord, + mockHandlers + ); + + expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalled(); + expect( mockHandlers.editRecord ).toHaveBeenCalled(); + } ); + + it( 'establishes connections with connection creators', async () => { + const mockDestroy = jest.fn(); + const mockConnectionCreator = jest.fn( () => + Promise.resolve( { destroy: mockDestroy } ) + ); + + syncProvider = new TestSyncProvider( [ mockConnectionCreator ] ); + + await syncProvider.bootstrap( + mockSyncConfig, + mockRawRecord, + mockHandlers + ); + + expect( mockConnectionCreator ).toHaveBeenCalled(); + } ); + + it( 'handles multiple entities simultaneously', async () => { + const record1 = { id: 1, title: 'Post 1' }; + const record2 = { id: 2, title: 'Post 2' }; + + await syncProvider.bootstrap( mockSyncConfig, record1, { + ...mockHandlers, + getEditedRecord: jest.fn( async () => record1 ), + } ); + + await syncProvider.bootstrap( mockSyncConfig, record2, { + ...mockHandlers, + getEditedRecord: jest.fn( async () => record2 ), + } ); + + expect( + syncProvider.testEntityStates.get( 'post_1' ) + ).toBeDefined(); + expect( + syncProvider.testEntityStates.get( 'post_2' ) + ).toBeDefined(); + } ); + + it( 'maintains separate CRDT docs for different entities', async () => { + const record1 = { id: 1, title: 'Post 1' }; + const record2 = { id: 2, title: 'Post 2' }; + + await syncProvider.bootstrap( mockSyncConfig, record1, { + ...mockHandlers, + getEditedRecord: jest.fn( async () => record1 ), + } ); + + await syncProvider.bootstrap( mockSyncConfig, record2, { + ...mockHandlers, + getEditedRecord: jest.fn( async () => record2 ), + } ); + + const state1 = syncProvider.testEntityStates.get( 'post_1' ); + const state2 = syncProvider.testEntityStates.get( 'post_2' ); + + expect( state1?.ydoc ).toBeDefined(); + expect( state2?.ydoc ).toBeDefined(); + } ); + + it( 'provides access to CRDT document maps', async () => { + await syncProvider.bootstrap( + mockSyncConfig, + mockRawRecord, + mockHandlers + ); + + const entityState = syncProvider.testEntityStates.get( 'post_1' ); + expect( entityState ).toBeDefined(); + + const recordMap = entityState?.ydoc.getMap( CRDT_RECORD_MAP_KEY ); + expect( recordMap ).toBeDefined(); + expect( mockYDoc.getMap ).toHaveBeenCalledWith( + CRDT_RECORD_MAP_KEY + ); + } ); + + it( 'sets up observers for document updates', async () => { + await syncProvider.bootstrap( + mockSyncConfig, + mockRawRecord, + mockHandlers + ); + + expect( mockYMap.observeDeep ).toHaveBeenCalled(); + expect( mockYMap.observe ).toHaveBeenCalled(); + } ); + } ); + + describe( 'discard', () => { + it( 'removes entity state and cleans up resources', async () => { + await syncProvider.bootstrap( + mockSyncConfig, + mockRawRecord, + mockHandlers + ); + + const entityId = 'post_1'; + expect( + syncProvider.testEntityStates.get( entityId ) + ).toBeDefined(); + + syncProvider.discard( 'post', '1' ); + + expect( + syncProvider.testEntityStates.get( entityId ) + ).toBeUndefined(); + } ); + + it( 'handles discarding non-existent entity gracefully', () => { + expect( () => { + syncProvider.discard( 'post', '999' ); + } ).not.toThrow(); + } ); + } ); + + describe( 'updateCRDTDoc', () => { + it( 'updates CRDT document with changes from local store', async () => { + await syncProvider.bootstrap( + mockSyncConfig, + mockRawRecord, + mockHandlers + ); + + const changes = { title: 'Updated Title' }; + + syncProvider.updateCRDTDoc( + mockSyncConfig, + mockRawRecord, + changes, + 'gutenberg' + ); + + expect( mockSyncConfig.applyChangesToCRDTDoc ).toHaveBeenCalledWith( + expect.any( Object ), + changes, + mockRawRecord, + 'gutenberg' + ); + } ); + + it( 'does nothing when entity does not exist', () => { + const changes = { title: 'Updated Title' }; + + expect( () => { + syncProvider.updateCRDTDoc( + mockSyncConfig, + mockRawRecord, + changes, + 'gutenberg' + ); + } ).not.toThrow(); + } ); + } ); + + describe( 'updateLastPersistedDate', () => { + it( 'updates the persisted timestamp in state map', async () => { + await syncProvider.bootstrap( + mockSyncConfig, + mockRawRecord, + mockHandlers + ); + + const beforeTimestamp = Date.now(); + + syncProvider.updateLastPersistedDate( + mockSyncConfig, + mockRawRecord + ); + + const entityId = 'post_1'; + const entityState = syncProvider.testEntityStates.get( entityId ); + const stateMap = entityState?.ydoc.getMap( CRDT_STATE_MAP_KEY ); + + const persistedAt = stateMap?.get( CRDT_STATE_PERSISTED_AT_KEY ); + const persistedBy = stateMap?.get( CRDT_STATE_PERSISTED_BY_KEY ); + + expect( persistedAt ).toBeGreaterThanOrEqual( beforeTimestamp ); + expect( persistedBy ).toBe( entityState?.ydoc.clientID ); + } ); + + it( 'does nothing when entity does not exist', () => { + expect( () => { + syncProvider.updateLastPersistedDate( + mockSyncConfig, + mockRawRecord + ); + } ).not.toThrow(); + } ); + } ); + + describe( 'getUndoManager', () => { + it( 'returns the undo manager instance', () => { + const undoManager = syncProvider.getUndoManager(); + expect( undoManager ).toBeDefined(); + } ); + } ); + + describe( 'createEntityMeta', () => { + it( 'returns empty meta by default', async () => { + const meta = await syncProvider.createEntityMeta( + mockSyncConfig, + mockRawRecord + ); + + expect( meta ).toEqual( {} ); + } ); + } ); + + describe( 'state synchronization', () => { + it( 'does not trigger refetch for local persisted date changes', async () => { + await syncProvider.bootstrap( + mockSyncConfig, + mockRawRecord, + mockHandlers + ); + + const refetchCallsBefore = ( + mockHandlers.refetchPersistedRecord as jest.Mock + ).mock.calls.length; + + syncProvider.updateLastPersistedDate( + mockSyncConfig, + mockRawRecord + ); + + expect( + ( mockHandlers.refetchPersistedRecord as jest.Mock ).mock.calls + .length + ).toBe( refetchCallsBefore ); + } ); + } ); +} ); diff --git a/packages/sync/src/test/undo-manager.ts b/packages/sync/src/test/undo-manager.ts new file mode 100644 index 00000000000000..104191275ead60 --- /dev/null +++ b/packages/sync/src/test/undo-manager.ts @@ -0,0 +1,243 @@ +/** + * External dependencies + */ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; + +const mockYDoc = { + clientID: 12345, + meta: new Map(), + getMap: jest.fn(), + transact: jest.fn( ( fn: () => void ) => fn() ), + destroy: jest.fn(), +}; + +jest.mock( 'yjs', () => ( { + Doc: jest.fn().mockImplementation( () => mockYDoc ), +} ) ); + +/** + * Internal dependencies + */ +import { UndoManager } from '../undo-manager'; + +const mockYMultiDocUndoManager = { + addToScope: jest.fn(), + undo: jest.fn(), + redo: jest.fn(), + canUndo: jest.fn(), + canRedo: jest.fn(), +}; + +jest.mock( '../y-utilities/y-multidoc-undomanager', () => { + return { + YMultiDocUndoManager: jest + .fn() + .mockImplementation( () => mockYMultiDocUndoManager ), + }; +} ); + +describe( 'UndoManager', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + describe( 'create', () => { + it( 'creates a singleton instance and initializes with correct properties', () => { + const { YMultiDocUndoManager } = jest.requireMock( + '../y-utilities/y-multidoc-undomanager' + ) as { + YMultiDocUndoManager: jest.Mock; + }; + + const instance1 = UndoManager.create(); + const instance2 = UndoManager.create(); + + expect( instance1 ).toBe( instance2 ); + expect( instance1 ).toBeInstanceOf( UndoManager ); + + expect( YMultiDocUndoManager ).toHaveBeenCalledTimes( 1 ); + + const callArgs = YMultiDocUndoManager.mock.calls[ 0 ]; + const options = callArgs[ 1 ] as { + captureTimeout: number; + trackedOrigins: Set< string >; + }; + + expect( callArgs[ 0 ] ).toEqual( [] ); + expect( options.captureTimeout ).toBe( 200 ); + expect( options.trackedOrigins ).toBeInstanceOf( Set ); + expect( options.trackedOrigins.size ).toBe( 1 ); + expect( options.trackedOrigins.has( 'gutenberg' ) ).toBe( true ); + } ); + } ); + + describe( 'addRecord', () => { + it( 'is a no-op as Yjs automatically tracks changes', () => { + const undoManager = UndoManager.create(); + + expect( () => { + undoManager.addRecord(); + } ).not.toThrow(); + + expect( () => { + undoManager.addRecord( undefined, false ); + } ).not.toThrow(); + + expect( () => { + undoManager.addRecord( undefined, true ); + } ).not.toThrow(); + } ); + } ); + + describe( 'addToScope', () => { + it( 'adds a Yjs map to the undo manager scope', () => { + const undoManager = UndoManager.create(); + const mockYMap = { test: 'map' }; + + undoManager.addToScope( mockYMap as any ); + + expect( mockYMultiDocUndoManager.addToScope ).toHaveBeenCalledWith( + mockYMap + ); + } ); + + it( 'can add multiple maps to scope', () => { + const undoManager = UndoManager.create(); + const mockYMap1 = { test: 'map1' }; + const mockYMap2 = { test: 'map2' }; + + undoManager.addToScope( mockYMap1 as any ); + undoManager.addToScope( mockYMap2 as any ); + + expect( mockYMultiDocUndoManager.addToScope ).toHaveBeenCalledTimes( + 2 + ); + expect( mockYMultiDocUndoManager.addToScope ).toHaveBeenCalledWith( + mockYMap1 + ); + expect( mockYMultiDocUndoManager.addToScope ).toHaveBeenCalledWith( + mockYMap2 + ); + } ); + } ); + + describe( 'undo', () => { + it( 'returns undefined when there is nothing to undo', () => { + const undoManager = UndoManager.create(); + mockYMultiDocUndoManager.canUndo.mockReturnValue( false ); + + const result = undoManager.undo(); + + expect( result ).toBeUndefined(); + expect( mockYMultiDocUndoManager.undo ).not.toHaveBeenCalled(); + } ); + + it( 'performs undo and returns empty array when undo is available', () => { + const undoManager = UndoManager.create(); + mockYMultiDocUndoManager.canUndo.mockReturnValue( true ); + + const result = undoManager.undo(); + + expect( mockYMultiDocUndoManager.undo ).toHaveBeenCalled(); + expect( result ).toEqual( [] ); + } ); + } ); + + describe( 'redo', () => { + it( 'returns undefined when there is nothing to redo', () => { + const undoManager = UndoManager.create(); + mockYMultiDocUndoManager.canRedo.mockReturnValue( false ); + + const result = undoManager.redo(); + + expect( result ).toBeUndefined(); + expect( mockYMultiDocUndoManager.redo ).not.toHaveBeenCalled(); + } ); + + it( 'performs redo and returns empty array when redo is available', () => { + const undoManager = UndoManager.create(); + mockYMultiDocUndoManager.canRedo.mockReturnValue( true ); + + const result = undoManager.redo(); + + expect( mockYMultiDocUndoManager.redo ).toHaveBeenCalled(); + expect( result ).toEqual( [] ); + } ); + } ); + + describe( 'hasUndo', () => { + it( 'returns false when no undo is available', () => { + const undoManager = UndoManager.create(); + mockYMultiDocUndoManager.canUndo.mockReturnValue( false ); + + expect( undoManager.hasUndo() ).toBe( false ); + } ); + + it( 'returns true when undo is available', () => { + const undoManager = UndoManager.create(); + mockYMultiDocUndoManager.canUndo.mockReturnValue( true ); + + expect( undoManager.hasUndo() ).toBe( true ); + } ); + } ); + + describe( 'hasRedo', () => { + it( 'returns false when no redo is available', () => { + const undoManager = UndoManager.create(); + mockYMultiDocUndoManager.canRedo.mockReturnValue( false ); + + expect( undoManager.hasRedo() ).toBe( false ); + } ); + + it( 'returns true when redo is available', () => { + const undoManager = UndoManager.create(); + mockYMultiDocUndoManager.canRedo.mockReturnValue( true ); + + expect( undoManager.hasRedo() ).toBe( true ); + } ); + } ); + + describe( 'integration workflow', () => { + it( 'follows typical undo/redo workflow', () => { + const undoManager = UndoManager.create(); + + mockYMultiDocUndoManager.canUndo.mockReturnValue( false ); + mockYMultiDocUndoManager.canRedo.mockReturnValue( false ); + + expect( undoManager.hasUndo() ).toBe( false ); + expect( undoManager.hasRedo() ).toBe( false ); + + mockYMultiDocUndoManager.canUndo.mockReturnValue( true ); + expect( undoManager.hasUndo() ).toBe( true ); + + undoManager.undo(); + mockYMultiDocUndoManager.canUndo.mockReturnValue( false ); + mockYMultiDocUndoManager.canRedo.mockReturnValue( true ); + + expect( undoManager.hasUndo() ).toBe( false ); + expect( undoManager.hasRedo() ).toBe( true ); + + undoManager.redo(); + mockYMultiDocUndoManager.canUndo.mockReturnValue( true ); + mockYMultiDocUndoManager.canRedo.mockReturnValue( false ); + + expect( undoManager.hasUndo() ).toBe( true ); + expect( undoManager.hasRedo() ).toBe( false ); + } ); + + it( 'handles multiple scopes', () => { + const undoManager = UndoManager.create(); + const mockYMap1 = { test: 'map1' }; + const mockYMap2 = { test: 'map2' }; + + undoManager.addToScope( mockYMap1 as any ); + undoManager.addToScope( mockYMap2 as any ); + + mockYMultiDocUndoManager.canUndo.mockReturnValue( true ); + + undoManager.undo(); + + expect( mockYMultiDocUndoManager.undo ).toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/packages/sync/src/test/utils.ts b/packages/sync/src/test/utils.ts new file mode 100644 index 00000000000000..fb8aa37d0914de --- /dev/null +++ b/packages/sync/src/test/utils.ts @@ -0,0 +1,77 @@ +/** + * External dependencies + */ +import { describe, expect, it, jest, beforeEach } from '@jest/globals'; + +const mockYMapData = new Map(); +const mockYMap = { + get: jest.fn( ( key: string ) => mockYMapData.get( key ) ), + set: jest.fn( ( key: string, value: any ) => + mockYMapData.set( key, value ) + ), +}; + +const mockYDoc = { + clientID: 12345, + meta: new Map(), + getMap: jest.fn( () => mockYMap ), + transact: jest.fn( ( fn: () => void ) => fn() ), + destroy: jest.fn(), +}; + +jest.mock( 'yjs', () => ( { + Doc: jest.fn().mockImplementation( () => mockYDoc ), +} ) ); + +/** + * Internal dependencies + */ +import { createYjsDoc } from '../utils'; +import { + CRDT_DOC_VERSION, + CRDT_STATE_MAP_KEY, + CRDT_STATE_PERSISTED_AT_KEY, + CRDT_STATE_RESTORED_AT_KEY, + CRDT_STATE_VERSION_KEY, +} from '../config'; + +describe( 'utils', () => { + beforeEach( () => { + mockYDoc.meta = new Map(); + mockYMapData.clear(); + mockYMap.get.mockClear(); + mockYMap.set.mockClear(); + mockYDoc.getMap.mockClear(); + } ); + + describe( 'createYjsDoc', () => { + it( 'initializes state map with default values', () => { + const ydoc = createYjsDoc( { objectType: 'post' } ); + const stateMap = ydoc.getMap( CRDT_STATE_MAP_KEY ); + + expect( ydoc ).toBeDefined(); + expect( stateMap.get( CRDT_STATE_PERSISTED_AT_KEY ) ).toBe( 0 ); + expect( stateMap.get( CRDT_STATE_RESTORED_AT_KEY ) ).toBe( 0 ); + expect( stateMap.get( CRDT_STATE_VERSION_KEY ) ).toBe( + CRDT_DOC_VERSION + ); + expect( mockYDoc.meta?.size ).toBe( 0 ); + } ); + + it( 'sets document meta from provided metadata', () => { + const documentMeta = { + objectType: 'post', + objectId: 123, + author: 'test-user', + }; + + mockYDoc.meta = new Map( Object.entries( documentMeta ) ); + + createYjsDoc( documentMeta ); + + expect( mockYDoc.meta?.get( 'objectType' ) ).toBe( 'post' ); + expect( mockYDoc.meta?.get( 'objectId' ) ).toBe( 123 ); + expect( mockYDoc.meta?.get( 'author' ) ).toBe( 'test-user' ); + } ); + } ); +} ); diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 03439ecf280319..2691d0fb87a4d5 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -1,27 +1,50 @@ +/** + * External dependencies + */ +import type * as Y from 'yjs'; +import type { Awareness } from 'y-protocols/awareness'; + +export type CRDTDoc = Y.Doc; +export type EntityID = string; export type ObjectID = string; export type ObjectType = string; -export type ObjectData = any; -export type CRDTDoc = any; -export type ObjectConfig = { - fetch: ( id: ObjectID ) => Promise< ObjectData >; - applyChangesToDoc: ( doc: CRDTDoc, data: any ) => void; - fromCRDTDoc: ( doc: CRDTDoc ) => any; -}; +// Object data represents any entity record, post, term, user, site, etc. There +// are not many expectations that can hold on its shape. +export interface ObjectData extends Record< string, unknown > {} + +export interface ConnectDocResult { + destroy: () => void; +} export type ConnectDoc = ( id: ObjectID, type: ObjectType, - doc: CRDTDoc -) => Promise< () => void >; + ydoc: Y.Doc, + awareness?: Awareness +) => Promise< ConnectDocResult >; + +export interface RecordHandlers { + editRecord: ( data: Partial< ObjectData > ) => void; + getEditedRecord: () => Promise< ObjectData >; + refetchPersistedRecord: () => 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 >; -}; +export interface SyncConfig { + applyChangesToCRDTDoc: ( + ydoc: Y.Doc, + changes: Partial< ObjectData >, + rawRecord: ObjectData, + origin: string + ) => void; + getChangesFromCRDTDoc: ( ydoc: Y.Doc, record: ObjectData ) => ObjectData; + getInitialObjectData: ( record: ObjectData ) => ObjectData; + getObjectId: ( data: ObjectData ) => ObjectID; + objectType: ObjectType; + supports?: { + awareness?: boolean; + crdtPersistence?: boolean; + undo?: boolean; + }; + syncedProperties: Set< string >; +} diff --git a/packages/sync/src/undo-manager.ts b/packages/sync/src/undo-manager.ts new file mode 100644 index 00000000000000..30bb19d5878c46 --- /dev/null +++ b/packages/sync/src/undo-manager.ts @@ -0,0 +1,124 @@ +/** + * External dependencies + */ +import type * as Y from 'yjs'; + +/** + * WordPress dependencies + */ +import type { + HistoryRecord, + UndoManager as WPUndoManager, +} from '@wordpress/undo-manager'; + +/** + * Internal dependencies + */ +import { LOCAL_EDITOR_ORIGIN } from './config'; +import { YMultiDocUndoManager } from './y-utilities/y-multidoc-undomanager'; +import type { ObjectData } from './types'; + +/** + * Wrapper class that implements the WordPress UndoManager interface while using + * YMultiDocUndoManager internally. This allows undo/redo operations to be + * transacted against multiple CRDT documents (one per entity) and giving each + * peer their own undo/redo stack without conflicts. + */ +export class UndoManager implements WPUndoManager< ObjectData > { + private static instance: UndoManager; + private undoManager: YMultiDocUndoManager; + + private constructor() { + this.undoManager = new YMultiDocUndoManager( [], { + // Throttle undo/redo captures. (default: 500ms) + captureTimeout: 200, + // Ensure that we only scope the undo/redo to the current editor. + // The yjs document's clientID is added once it's available. + trackedOrigins: new Set( [ LOCAL_EDITOR_ORIGIN ] ), + } ); + } + + public static create(): UndoManager { + if ( ! UndoManager.instance ) { + UndoManager.instance = new UndoManager(); + } + + return UndoManager.instance; + } + + /** + * 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. + */ + 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. + } + + /** + * Add a Yjs map to the scope of the undo manager. + * + * @param {Y.Map< any >} ymap The Yjs map to add to the scope. + */ + public addToScope( ymap: Y.Map< any > ): void { + this.undoManager.addToScope( ymap ); + } + + /** + * Undo the last recorded changes. + * + */ + public undo(): HistoryRecord< ObjectData > | undefined { + if ( ! this.hasUndo() ) { + return; + } + + // Perform the undo operation + this.undoManager.undo(); + + // Intentionally return an empty array, because the SyncProvider will update + // the entity record based on the Yjs document changes. + return []; + } + + /** + * Redo the last undone changes. + */ + public redo(): HistoryRecord< ObjectData > | undefined { + if ( ! this.hasRedo() ) { + return; + } + + // Perform the redo operation + this.undoManager.redo(); + + // Intentionally return an empty array, because the SyncProvider will update + // the entity record based on the Yjs document changes. + return []; + } + + /** + * Check if there are changes that can be undone. + * + * @return Whether there are changes to undo. + */ + public hasUndo(): boolean { + return this.undoManager.canUndo(); + } + + /** + * Check if there are changes that can be redone. + * + * @return Whether there are changes to redo. + */ + public hasRedo(): boolean { + return this.undoManager.canRedo(); + } +} diff --git a/packages/sync/src/utils.ts b/packages/sync/src/utils.ts new file mode 100644 index 00000000000000..1ffe33bed894d5 --- /dev/null +++ b/packages/sync/src/utils.ts @@ -0,0 +1,31 @@ +/** + * External dependencies + */ +import * as Y from 'yjs'; + +/** + * Internal dependencies + */ +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. + const metaMap = new Map< string, unknown >( + Object.entries( documentMeta ) + ); + + const ydoc = new Y.Doc( { meta: metaMap } ); + const stateMap = ydoc.getMap( CRDT_STATE_MAP_KEY ); + + 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; +} diff --git a/packages/sync/src/y-utilities/LICENSE b/packages/sync/src/y-utilities/LICENSE new file mode 100644 index 00000000000000..2988b7d57a2dc8 --- /dev/null +++ b/packages/sync/src/y-utilities/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 Kevin Jahns . + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/sync/src/y-utilities/y-multidoc-undomanager.js b/packages/sync/src/y-utilities/y-multidoc-undomanager.js new file mode 100644 index 00000000000000..ce1db65ad6318c --- /dev/null +++ b/packages/sync/src/y-utilities/y-multidoc-undomanager.js @@ -0,0 +1,190 @@ +// File copied as is from the y-utilities package. +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ +// @ts-nocheck +/* eslint-env browser */ + +import * as array from 'lib0/array' +import * as map from 'lib0/map' +import { Observable } from 'lib0/observable' +import * as Y from 'yjs' + +/** + * @param {YMultiDocUndoManager} mum + * @param {'undo' | 'redo'} type + */ +const popStackItem = (mum, type) => { + const stack = type === 'undo' ? mum.undoStack : mum.redoStack + while (stack.length > 0) { + const um = /** @type {Y.UndoManager} */ (stack.pop()) + const prevUmStack = type === 'undo' ? um.undoStack : um.redoStack + const stackItem = /** @type {any} */ (prevUmStack.pop()) + let actionPerformed = false + if (type === 'undo') { + um.undoStack = [stackItem] + actionPerformed = um.undo() !== null + um.undoStack = prevUmStack + } else { + um.redoStack = [stackItem] + actionPerformed = um.redo() !== null + um.redoStack = prevUmStack + } + if (actionPerformed) { + return stackItem + } + } + return null +} + +/** + * @extends Observable + */ +export class YMultiDocUndoManager extends Observable { + /** + * @param {Y.AbstractType|Array>} typeScope Accepts either a single type, or an array of types + * @param {ConstructorParameters[1]} opts + */ + constructor (typeScope = [], opts = {}) { + super() + /** + * @type {Map} + */ + this.docs = new Map() + this.trackedOrigins = opts.trackedOrigins || new Set([null]) + opts.trackedOrigins = this.trackedOrigins + this._defaultOpts = opts + /** + * @type {Array} + */ + this.undoStack = [] + /** + * @type {Array} + */ + this.redoStack = [] + this.addToScope(typeScope) + } + + /** + * @param {Array> | Y.AbstractType} ytypes + */ + addToScope (ytypes) { + ytypes = array.isArray(ytypes) ? ytypes : [ytypes] + ytypes.forEach(ytype => { + const ydoc = /** @type {Y.Doc} */ (ytype.doc) + const um = map.setIfUndefined(this.docs, ydoc, () => { + const um = new Y.UndoManager([ytype], this._defaultOpts) + um.on('stack-cleared', /** @param {any} opts */ ({ undoStackCleared, redoStackCleared }) => { + this.clear(undoStackCleared, redoStackCleared) + }) + ydoc.on('destroy', () => { + this.docs.delete(ydoc) + this.undoStack = this.undoStack.filter(um => um.doc !== ydoc) + this.redoStack = this.redoStack.filter(um => um.doc !== ydoc) + }) + um.on('stack-item-added', /** @param {any} change */ change => { + const stack = change.type === 'undo' ? this.undoStack : this.redoStack + stack.push(um) + this.emit('stack-item-added', [{ ...change, ydoc: ydoc }, this]) + }) + um.on('stack-item-updated', /** @param {any} change */ change => { + this.emit('stack-item-updated', [{ ...change, ydoc }, this]) + }) + um.on('stack-item-popped', /** @param {any} change */ change => { + this.emit('stack-item-popped', [{ ...change, ydoc }, this]) + }) + // if doc is destroyed + // emit events from um to multium + return um + }) + /* c8 ignore next 4 */ + if (um.scope.every(yt => yt !== ytype)) { + um.scope.push(ytype) + } + }) + } + + /** + * @param {any} origin + */ + /* c8 ignore next 3 */ + addTrackedOrigin (origin) { + this.trackedOrigins.add(origin) + } + + /** + * @param {any} origin + */ + /* c8 ignore next 3 */ + removeTrackedOrigin (origin) { + this.trackedOrigins.delete(origin) + } + + /** + * Undo last changes on type. + * + * @return {any?} Returns StackItem if a change was applied + */ + undo () { + return popStackItem(this, 'undo') + } + + /** + * Redo last undo operation. + * + * @return {any?} Returns StackItem if a change was applied + */ + redo () { + return popStackItem(this, 'redo') + } + + clear (clearUndoStack = true, clearRedoStack = true) { + /* c8 ignore next */ + if ((clearUndoStack && this.canUndo()) || (clearRedoStack && this.canRedo())) { + this.docs.forEach(um => { + /* c8 ignore next */ + clearUndoStack && (this.undoStack = []) + /* c8 ignore next */ + clearRedoStack && (this.redoStack = []) + um.clear(clearUndoStack, clearRedoStack) + }) + this.emit('stack-cleared', [{ undoStackCleared: clearUndoStack, redoStackCleared: clearRedoStack }]) + } + } + + /* c8 ignore next 5 */ + stopCapturing () { + this.docs.forEach(um => { + um.stopCapturing() + }) + } + + /** + * Are undo steps available? + * + * @return {boolean} `true` if undo is possible + */ + canUndo () { + return this.undoStack.length > 0 + } + + /** + * Are redo steps available? + * + * @return {boolean} `true` if redo is possible + */ + canRedo () { + return this.redoStack.length > 0 + } + + destroy () { + this.docs.forEach(um => um.destroy()) + super.destroy() + } +} + +/** + * @todo remove + * @deprecated Use YMultiDocUndoManager instead + */ +export const MultiDocUndoManager = YMultiDocUndoManager 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" } ] } diff --git a/packages/token-list/CHANGELOG.md b/packages/token-list/CHANGELOG.md index 1cd0280fb8eb76..8d466346846b02 100644 --- a/packages/token-list/CHANGELOG.md +++ b/packages/token-list/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 3.31.0 (2025-09-17) + ## 3.30.0 (2025-09-03) ## 3.29.0 (2025-08-20) diff --git a/packages/token-list/package.json b/packages/token-list/package.json index 08c72c00ed4b12..2d7de0b9b8f99d 100644 --- a/packages/token-list/package.json +++ b/packages/token-list/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/token-list", - "version": "3.30.0", + "version": "3.31.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/CHANGELOG.md b/packages/undo-manager/CHANGELOG.md index b17b990dbb57a7..0df8428d05a299 100644 --- a/packages/undo-manager/CHANGELOG.md +++ b/packages/undo-manager/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 1.31.0 (2025-09-17) + ## 1.30.0 (2025-09-03) ## 1.29.0 (2025-08-20) diff --git a/packages/undo-manager/package.json b/packages/undo-manager/package.json index 8d1ddba4cc402f..848ef2b38beeb7 100644 --- a/packages/undo-manager/package.json +++ b/packages/undo-manager/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/undo-manager", - "version": "1.30.0", + "version": "1.31.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/CHANGELOG.md b/packages/upload-media/CHANGELOG.md index 631a8e30fb28e4..e793ec513e2d0b 100644 --- a/packages/upload-media/CHANGELOG.md +++ b/packages/upload-media/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.16.0 (2025-09-17) + ## 0.15.0 (2025-09-03) ## 0.14.0 (2025-08-20) diff --git a/packages/upload-media/package.json b/packages/upload-media/package.json index 35c47f1ace0eda..5e8c8fdf10012c 100644 --- a/packages/upload-media/package.json +++ b/packages/upload-media/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/upload-media", - "version": "0.15.0", + "version": "0.16.0", "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 167402b341b10e..640be5eb2c0c07 100644 --- a/packages/url/CHANGELOG.md +++ b/packages/url/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.31.0 (2025-09-17) + ## 4.30.0 (2025-09-03) ## 4.29.0 (2025-08-20) diff --git a/packages/url/package.json b/packages/url/package.json index 0340fbedef4c91..3bb56d30905924 100644 --- a/packages/url/package.json +++ b/packages/url/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/url", - "version": "4.30.0", + "version": "4.31.0", "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 3614047659689b..05b518787a2327 100644 --- a/packages/viewport/CHANGELOG.md +++ b/packages/viewport/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 6.31.0 (2025-09-17) + ## 6.30.0 (2025-09-03) ## 6.29.0 (2025-08-20) diff --git a/packages/viewport/package.json b/packages/viewport/package.json index e7560db3ccf547..4613725e326f74 100644 --- a/packages/viewport/package.json +++ b/packages/viewport/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/viewport", - "version": "6.30.0", + "version": "6.31.0", "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 85ac11cce69f29..04b740675d7c8d 100644 --- a/packages/warning/CHANGELOG.md +++ b/packages/warning/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 3.31.0 (2025-09-17) + ## 3.30.0 (2025-09-03) ## 3.29.0 (2025-08-20) diff --git a/packages/warning/package.json b/packages/warning/package.json index 3236a6737a9b4d..2b92b65613ce29 100644 --- a/packages/warning/package.json +++ b/packages/warning/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/warning", - "version": "3.30.0", + "version": "3.31.0", "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 63fcf2ba4b9f1b..3b30d4f26c5844 100644 --- a/packages/widgets/CHANGELOG.md +++ b/packages/widgets/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.31.0 (2025-09-17) + ## 4.30.0 (2025-09-03) ## 4.29.0 (2025-08-20) diff --git a/packages/widgets/package.json b/packages/widgets/package.json index eb666380c6c7d7..14d0320fc8f37a 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/widgets", - "version": "4.30.0", + "version": "4.31.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/CHANGELOG.md b/packages/wordcount/CHANGELOG.md index 2d1e4ac547c923..613aade3fc4da9 100644 --- a/packages/wordcount/CHANGELOG.md +++ b/packages/wordcount/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 4.31.0 (2025-09-17) + ## 4.30.0 (2025-09-03) ## 4.29.0 (2025-08-20) diff --git a/packages/wordcount/package.json b/packages/wordcount/package.json index 7218f99a143cb9..a40f006272339c 100644 --- a/packages/wordcount/package.json +++ b/packages/wordcount/package.json @@ -1,6 +1,6 @@ { "name": "@wordpress/wordcount", - "version": "4.30.0", + "version": "4.31.0", "description": "WordPress word count utility.", "author": "The WordPress Contributors", "license": "GPL-2.0-or-later", diff --git a/phpunit/script-dependencies-test.php b/phpunit/script-dependencies-test.php index 75064dd49cef89..e0f7f628d715c3 100644 --- a/phpunit/script-dependencies-test.php +++ b/phpunit/script-dependencies-test.php @@ -42,9 +42,9 @@ public function test_polyfill_dependents() { 'wp-block-library', 'wp-blocks', 'wp-edit-site', - 'wp-core-data', 'wp-editor', 'wp-router', + 'wp-sync', 'wp-url', 'wp-widgets', ); diff --git a/test/e2e/specs/editor/blocks/image.spec.js b/test/e2e/specs/editor/blocks/image.spec.js index 31795981357da1..585f1fce0ad056 100644 --- a/test/e2e/specs/editor/blocks/image.spec.js +++ b/test/e2e/specs/editor/blocks/image.spec.js @@ -1046,7 +1046,7 @@ test.describe( 'Image - Site editor', () => { test.beforeEach( async ( { admin } ) => { await admin.visitSiteEditor( { postId: 'emptytheme//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); } ); diff --git a/test/e2e/specs/editor/blocks/post-comments-form.spec.js b/test/e2e/specs/editor/blocks/post-comments-form.spec.js index 96da063ba382a7..db75771dc09154 100644 --- a/test/e2e/specs/editor/blocks/post-comments-form.spec.js +++ b/test/e2e/specs/editor/blocks/post-comments-form.spec.js @@ -33,7 +33,7 @@ test.describe( 'Comments Form', () => { // Navigate to "Singular" post template await admin.visitSiteEditor( { postId: 'emptytheme//singular', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); diff --git a/test/e2e/specs/editor/various/block-bindings/post-meta.spec.js b/test/e2e/specs/editor/various/block-bindings/post-meta.spec.js index 0794a8fe5d34fe..318707e22f098d 100644 --- a/test/e2e/specs/editor/various/block-bindings/post-meta.spec.js +++ b/test/e2e/specs/editor/various/block-bindings/post-meta.spec.js @@ -25,7 +25,7 @@ test.describe( 'Post Meta source', () => { test.beforeEach( async ( { admin, editor } ) => { await admin.visitSiteEditor( { postId: 'gutenberg-test-themes/block-bindings//single-movie', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); await editor.openDocumentSettingsSidebar(); @@ -283,7 +283,7 @@ test.describe( 'Post Meta source', () => { test.beforeEach( async ( { admin, editor } ) => { await admin.visitSiteEditor( { postId: 'gutenberg-test-themes/block-bindings//custom-template', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); await editor.openDocumentSettingsSidebar(); diff --git a/test/e2e/specs/editor/various/pattern-overrides.spec.js b/test/e2e/specs/editor/various/pattern-overrides.spec.js index 925a9434f0950f..2aa3bfe5014900 100644 --- a/test/e2e/specs/editor/various/pattern-overrides.spec.js +++ b/test/e2e/specs/editor/various/pattern-overrides.spec.js @@ -12,6 +12,9 @@ test.describe( 'Pattern Overrides', () => { test.beforeAll( async ( { requestUtils } ) => { await Promise.all( [ requestUtils.activateTheme( 'emptytheme' ), + await requestUtils.activatePlugin( + 'gutenberg-test-block-bindings' + ), requestUtils.deleteAllBlocks(), ] ); } ); @@ -24,6 +27,7 @@ test.describe( 'Pattern Overrides', () => { test.afterAll( async ( { requestUtils } ) => { await Promise.all( [ requestUtils.activateTheme( 'twentytwentyone' ), + requestUtils.deactivatePlugin( 'gutenberg-test-block-bindings' ), ] ); } ); @@ -253,7 +257,7 @@ test.describe( 'Pattern Overrides', () => { await admin.visitSiteEditor( { postId: 'emptytheme//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); @@ -789,6 +793,96 @@ test.describe( 'Pattern Overrides', () => { await expect( buttonLink ).toHaveAttribute( 'rel', /^\s*nofollow\s*$/ ); } ); + test( 'should disable editing for pattern blocks without overrides enabled, even when mixed with bound attributes', async ( { + page, + admin, + requestUtils, + editor, + } ) => { + const { id } = await requestUtils.createBlock( { + title: 'Pattern', + content: ` +

Edit me

+ + + +`, + status: 'publish', + } ); + + await admin.createNewPost(); + await editor.insertBlock( { + name: 'core/block', + attributes: { ref: id }, + } ); + + const patternBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Pattern', + } ); + + await expect( patternBlock.getByText( 'Edit me' ) ).toBeVisible(); + await expect( + patternBlock.getByText( 'Read Only Button Text' ) + ).toBeVisible(); + + const editableParagraph = patternBlock + .getByRole( 'document', { + name: 'Block: Paragraph', + includeHidden: true, + } ) + .filter( { hasText: 'Edit me' } ); + const nonEditableButton = patternBlock + .getByRole( 'document', { + name: 'Block: Button', + exact: true, + includeHidden: true, + } ) + .filter( { hasText: 'Read Only Button Text' } ); + + await editableParagraph.click(); + await editableParagraph.focus(); + await page.keyboard.type( ' - Edited' ); + await expect( editableParagraph ).toHaveText( 'Edit me - Edited' ); + + // Button with only URL binding (no pattern overrides) should not have editable text. + await nonEditableButton.click(); + const initialText = await nonEditableButton.textContent(); + await page.keyboard.type( 'Edited' ); + const finalText = await nonEditableButton.textContent(); + expect( initialText ).toBe( finalText ); + + await nonEditableButton.click(); + await editor.showBlockToolbar(); + + // Open the link control + const linkButton = page.getByRole( 'button', { + name: 'Link', + exact: true, + } ); + await linkButton.click(); + + const urlInput = page.getByPlaceholder( 'Search or type URL' ); + await urlInput.fill( '#test' ); + + // Save the link + const saveLinkButton = page.locator( + '.block-editor-link-control__search-submit' + ); + await saveLinkButton.click(); + + // Publish the post + const postId = await editor.publishPost(); + + // Check on the frontend that the URL was updated + await page.goto( `/?p=${ postId }` ); + const frontendButton = page.getByRole( 'link', { + name: 'Button Text', + } ); + await expect( frontendButton ).toHaveAttribute( 'href', '#test' ); + } ); + test( 'resets overrides after clicking the reset button', async ( { page, admin, diff --git a/test/e2e/specs/editor/various/write-design-mode.spec.js b/test/e2e/specs/editor/various/write-design-mode.spec.js index d18033962ed4b9..1135207583b152 100644 --- a/test/e2e/specs/editor/various/write-design-mode.spec.js +++ b/test/e2e/specs/editor/various/write-design-mode.spec.js @@ -13,7 +13,7 @@ test.describe( 'Write/Design mode', () => { } ); await admin.visitSiteEditor( { postId: 'emptytheme//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); } ); diff --git a/test/e2e/specs/site-editor/block-removal.spec.js b/test/e2e/specs/site-editor/block-removal.spec.js index 8dca99849d70f2..7fc547c19e59e3 100644 --- a/test/e2e/specs/site-editor/block-removal.spec.js +++ b/test/e2e/specs/site-editor/block-removal.spec.js @@ -15,7 +15,7 @@ test.describe( 'Site editor block removal prompt', () => { test.beforeEach( async ( { admin } ) => { await admin.visitSiteEditor( { postId: 'emptytheme//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); } ); diff --git a/test/e2e/specs/site-editor/browser-history.spec.js b/test/e2e/specs/site-editor/browser-history.spec.js index f5b73f110f43b1..c3eb2ac5e3a2fa 100644 --- a/test/e2e/specs/site-editor/browser-history.spec.js +++ b/test/e2e/specs/site-editor/browser-history.spec.js @@ -23,7 +23,7 @@ test.describe( 'Site editor browser history', () => { .locator( '.fields-field__title', { hasText: 'Index' } ) .click(); await expect( page ).toHaveURL( - '/wp-admin/site-editor.php?p=%2Fwp_registered_template%2Femptytheme%2F%2Findex&canvas=edit' + '/wp-admin/site-editor.php?p=%2Fwp_template%2Femptytheme%2F%2Findex&canvas=edit' ); // Navigate back to the template list diff --git a/test/e2e/specs/site-editor/font-library.spec.js b/test/e2e/specs/site-editor/font-library.spec.js index 63dc5d8e509d46..1824257df12fd3 100644 --- a/test/e2e/specs/site-editor/font-library.spec.js +++ b/test/e2e/specs/site-editor/font-library.spec.js @@ -12,7 +12,7 @@ test.describe( 'Font Library', () => { test.beforeEach( async ( { admin } ) => { await admin.visitSiteEditor( { postId: 'emptytheme//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); } ); @@ -57,7 +57,7 @@ test.describe( 'Font Library', () => { test.beforeEach( async ( { admin } ) => { await admin.visitSiteEditor( { postId: 'twentytwentythree//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); } ); @@ -137,7 +137,7 @@ test.describe( 'Font Library', () => { test.beforeEach( async ( { admin } ) => { await admin.visitSiteEditor( { postId: 'emptytheme//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); } ); @@ -219,7 +219,7 @@ test.describe( 'Font Library', () => { test.beforeEach( async ( { admin } ) => { await admin.visitSiteEditor( { postId: 'twentytwentyfour//home', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); } ); diff --git a/test/e2e/specs/site-editor/global-styles-sidebar.spec.js b/test/e2e/specs/site-editor/global-styles-sidebar.spec.js index 754e71813129a9..7f1b818df4ce0a 100644 --- a/test/e2e/specs/site-editor/global-styles-sidebar.spec.js +++ b/test/e2e/specs/site-editor/global-styles-sidebar.spec.js @@ -15,7 +15,7 @@ test.describe( 'Global styles sidebar', () => { test.beforeEach( async ( { admin } ) => { await admin.visitSiteEditor( { postId: 'emptytheme//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); } ); diff --git a/test/e2e/specs/site-editor/iframe-rendering.spec.js b/test/e2e/specs/site-editor/iframe-rendering.spec.js index 9306b3e27ca1d8..9c25ef504637e4 100644 --- a/test/e2e/specs/site-editor/iframe-rendering.spec.js +++ b/test/e2e/specs/site-editor/iframe-rendering.spec.js @@ -18,7 +18,7 @@ test.describe( 'Site editor iframe rendering mode', () => { } ) => { await admin.visitSiteEditor( { postId: 'emptytheme//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); const compatMode = await editor.canvas diff --git a/test/e2e/specs/site-editor/multi-entity-saving.spec.js b/test/e2e/specs/site-editor/multi-entity-saving.spec.js index 3f427c00d31241..cbc3bfde457a14 100644 --- a/test/e2e/specs/site-editor/multi-entity-saving.spec.js +++ b/test/e2e/specs/site-editor/multi-entity-saving.spec.js @@ -21,7 +21,7 @@ test.describe( 'Site Editor - Multi-entity save flow', () => { test.beforeEach( async ( { admin } ) => { await admin.visitSiteEditor( { postId: 'emptytheme//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); } ); @@ -45,7 +45,9 @@ test.describe( 'Site Editor - Multi-entity save flow', () => { .getByRole( 'button', { name: 'Open save panel' } ) ).toBeVisible(); - await editor.saveSiteEditorEntities(); + await editor.saveSiteEditorEntities( { + isOnlyCurrentEntityDirty: true, + } ); const saveButton = page .getByRole( 'region', { name: 'Editor top bar' } ) .getByRole( 'button', { name: 'Save' } ); @@ -74,7 +76,9 @@ test.describe( 'Site Editor - Multi-entity save flow', () => { // Change font size. await fontSizePicker.getByRole( 'radio', { name: 'Small' } ).click(); - await editor.saveSiteEditorEntities(); + await editor.saveSiteEditorEntities( { + isOnlyCurrentEntityDirty: true, + } ); // Change font size again. await fontSizePicker.getByRole( 'radio', { name: 'Medium' } ).click(); diff --git a/test/e2e/specs/site-editor/new-templates-list.spec.js b/test/e2e/specs/site-editor/new-templates-list.spec.js index 58f6eff154e416..d26306a6c8e3b5 100644 --- a/test/e2e/specs/site-editor/new-templates-list.spec.js +++ b/test/e2e/specs/site-editor/new-templates-list.spec.js @@ -40,14 +40,11 @@ test.describe( 'Templates', () => { } ); test( 'Filtering', async ( { requestUtils, admin, page } ) => { - const template = await requestUtils.createTemplate( 'wp_template', { + await requestUtils.createTemplate( 'wp_template', { slug: 'date', title: 'Date Archives', content: 'hi', } ); - await requestUtils.updateSiteSettings( { - active_templates: { date: template.wp_id }, - } ); await admin.visitSiteEditor( { postType: 'wp_template' } ); // Global search. await page.getByRole( 'searchbox', { name: 'Search' } ).fill( 'tag' ); @@ -57,7 +54,7 @@ test.describe( 'Templates', () => { await page .getByRole( 'button', { name: 'Reset search', exact: true } ) .click(); - await expect( titles ).toHaveCount( 5 ); + await expect( titles ).toHaveCount( 6 ); // Filter by author. await page.getByRole( 'button', { name: 'Add filter' } ).click(); diff --git a/test/e2e/specs/site-editor/pages.spec.js b/test/e2e/specs/site-editor/pages.spec.js index 84e25cdd9a0b94..7f70bc6da58973 100644 --- a/test/e2e/specs/site-editor/pages.spec.js +++ b/test/e2e/specs/site-editor/pages.spec.js @@ -265,7 +265,9 @@ test.describe( 'Pages', () => { await page .locator( '.block-editor-block-patterns-list__list-item' ) .click(); - await editor.saveSiteEditorEntities(); + await editor.saveSiteEditorEntities( { + isOnlyCurrentEntityDirty: true, + } ); await admin.visitSiteEditor(); // Create new page that has the default template so as to swap it. diff --git a/test/e2e/specs/site-editor/preload.spec.js b/test/e2e/specs/site-editor/preload.spec.js index de96395c7ed6c0..b6f9f49aedeb7c 100644 --- a/test/e2e/specs/site-editor/preload.spec.js +++ b/test/e2e/specs/site-editor/preload.spec.js @@ -31,9 +31,7 @@ test.describe( 'Preload', () => { const urlObject = new URL( url ); const restRoute = urlObject.searchParams.get( 'rest_route' ); if ( restRoute ) { - urlObject.searchParams.delete( 'rest_route' ); - urlObject.searchParams.delete( '_locale' ); - requests.push( restRoute + urlObject.search ); + requests.push( restRoute ); } else { requests.push( url ); } @@ -46,15 +44,8 @@ test.describe( 'Preload', () => { // To do: these should all be removed or preloaded. expect( requests ).toEqual( [ - // I'm not quite sure why this is needed, because it is preloaded. - // It might be that there's a request that invalides the resolver - // and then triggers a new request. - '/wp/v2/templates/lookup?slug=front-page', // Seems to be coming from `enableComplementaryArea`. '/wp/v2/users/me', - '/wp/v2/wp_template', - // This is the auto-draft template. - expect.stringMatching( /\/wp\/v2\/wp_template\/\d+\?context=edit/ ), // There are two separate settings OPTIONS requests. We should fix // so the one for canUser and getEntityRecord are reused. '/wp/v2/settings', diff --git a/test/e2e/specs/site-editor/push-to-global-styles.spec.js b/test/e2e/specs/site-editor/push-to-global-styles.spec.js index 778829c17b99cd..3e30f764811b1f 100644 --- a/test/e2e/specs/site-editor/push-to-global-styles.spec.js +++ b/test/e2e/specs/site-editor/push-to-global-styles.spec.js @@ -15,7 +15,7 @@ test.describe( 'Push to Global Styles button', () => { test.beforeEach( async ( { admin } ) => { await admin.visitSiteEditor( { postId: 'emptytheme//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); } ); diff --git a/test/e2e/specs/site-editor/settings-sidebar.spec.js b/test/e2e/specs/site-editor/settings-sidebar.spec.js index 099222c131077b..87e9023401109d 100644 --- a/test/e2e/specs/site-editor/settings-sidebar.spec.js +++ b/test/e2e/specs/site-editor/settings-sidebar.spec.js @@ -15,7 +15,7 @@ test.describe( 'Settings sidebar', () => { test.beforeEach( async ( { admin } ) => { await admin.visitSiteEditor( { postId: 'emptytheme//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); } ); @@ -63,7 +63,7 @@ test.describe( 'Settings sidebar', () => { await admin.visitSiteEditor( { postId: 'emptytheme//singular', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); diff --git a/test/e2e/specs/site-editor/site-editor-export.spec.js b/test/e2e/specs/site-editor/site-editor-export.spec.js index aa6a472204a6db..a0a56c18089cc2 100644 --- a/test/e2e/specs/site-editor/site-editor-export.spec.js +++ b/test/e2e/specs/site-editor/site-editor-export.spec.js @@ -22,7 +22,7 @@ test.describe( 'Site Editor Templates Export', () => { } ) => { await admin.visitSiteEditor( { postId: 'emptytheme//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); await page diff --git a/test/e2e/specs/site-editor/site-editor-url-navigation.spec.js b/test/e2e/specs/site-editor/site-editor-url-navigation.spec.js index df6408ce24e75f..580b1060227156 100644 --- a/test/e2e/specs/site-editor/site-editor-url-navigation.spec.js +++ b/test/e2e/specs/site-editor/site-editor-url-navigation.spec.js @@ -44,7 +44,7 @@ test.describe( 'Site editor url navigation', () => { .click(); await page.getByRole( 'option', { name: 'Demo' } ).click(); await expect( page ).toHaveURL( - /wp-admin\/site-editor\.php\?p=%2Fwp_template%2F\d+&canvas=edit/ + '/wp-admin/site-editor.php?p=%2Fwp_template%2Femptytheme%2F%2Fsingle-post-demo&canvas=edit' ); } ); diff --git a/test/e2e/specs/site-editor/style-variations.spec.js b/test/e2e/specs/site-editor/style-variations.spec.js index 9a718119fb6e94..53de08717226a4 100644 --- a/test/e2e/specs/site-editor/style-variations.spec.js +++ b/test/e2e/specs/site-editor/style-variations.spec.js @@ -36,7 +36,7 @@ test.describe( 'Global styles variations', () => { } ) => { await admin.visitSiteEditor( { postId: 'gutenberg-test-themes/style-variations//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); @@ -72,7 +72,7 @@ test.describe( 'Global styles variations', () => { } ) => { await admin.visitSiteEditor( { postId: 'gutenberg-test-themes/style-variations//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); await siteEditorStyleVariations.browseStyles(); @@ -108,7 +108,7 @@ test.describe( 'Global styles variations', () => { } ) => { await admin.visitSiteEditor( { postId: 'gutenberg-test-themes/style-variations//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); await siteEditorStyleVariations.browseStyles(); @@ -144,7 +144,7 @@ test.describe( 'Global styles variations', () => { } ) => { await admin.visitSiteEditor( { postId: 'gutenberg-test-themes/style-variations//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); await siteEditorStyleVariations.browseStyles(); @@ -174,7 +174,7 @@ test.describe( 'Global styles variations', () => { } ) => { await admin.visitSiteEditor( { postId: 'gutenberg-test-themes/style-variations//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); await siteEditorStyleVariations.browseStyles(); diff --git a/test/e2e/specs/site-editor/template-activate.spec.js b/test/e2e/specs/site-editor/template-activate.spec.js deleted file mode 100644 index 0f33d8a7fce248..00000000000000 --- a/test/e2e/specs/site-editor/template-activate.spec.js +++ /dev/null @@ -1,129 +0,0 @@ -/** - * WordPress dependencies - */ -const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); - -test.describe( 'Template Activate', () => { - test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.activateTheme( 'emptytheme' ); - await requestUtils.deleteAllTemplates( 'wp_template' ); - await requestUtils.deleteAllTemplates( 'wp_template_part' ); - } ); - test.afterAll( async ( { requestUtils } ) => { - await requestUtils.deleteAllTemplates( 'wp_template' ); - await requestUtils.deleteAllTemplates( 'wp_template_part' ); - await requestUtils.activateTheme( 'twentytwentyone' ); - } ); - test.beforeEach( async ( { admin, requestUtils } ) => { - await requestUtils.deleteAllTemplates( 'wp_template' ); - await admin.visitSiteEditor( { postType: 'wp_template' } ); - } ); - - test( 'should duplicate and activate', async ( { - page, - admin, - editor, - } ) => { - // Inside the grid cell, find the button with the text "Actions" - const index = page.locator( - '.dataviews-view-grid__card:has-text("Index")' - ); - let actionsButton = index.getByRole( 'button', { name: 'Actions' } ); - await actionsButton.click(); - - const duplicateButton = page.getByRole( 'menuitem', { - name: 'Duplicate', - } ); - await duplicateButton.click(); - - await page.keyboard.press( 'Enter' ); - - // Wait for the snackbar message. - await page.waitForSelector( '.components-snackbar__content' ); - - await admin.visitSiteEditor( { - postType: 'wp_template', - activeView: 'user', - } ); - - const indexCopy = page.locator( - '.dataviews-view-grid__card:has-text("Index (Copy)")' - ); - - expect( await indexCopy.textContent() ).toContain( 'Inactive' ); - - actionsButton = indexCopy.getByRole( 'button', { - name: 'Actions', - } ); - await actionsButton.click(); - - const activateButton = page.getByRole( 'menuitem', { - name: 'Activate', - } ); - await activateButton.click(); - - await page.waitForSelector( - '.dataviews-view-grid__field-value .is-success' - ); - - await page - .getByRole( 'button', { name: 'Index (Copy)', exact: true } ) - .first() - .click(); - - await expect( editor.canvas.getByText( 'gutenberg' ) ).toBeVisible(); - - await editor.insertBlock( { - name: 'core/paragraph', - attributes: { content: 'Copied from Index.' }, - } ); - - await editor.saveSiteEditorEntities( { - isOnlyCurrentEntityDirty: true, - } ); - - await page - .getByRole( 'button', { - name: 'View', - exact: true, - } ) - .click(); - - const [ previewPage ] = await Promise.all( [ - page.context().waitForEvent( 'page' ), - page - .getByRole( 'menuitem', { - name: 'View site', - } ) - .click(), - ] ); - - await expect( previewPage.locator( 'body' ) ).toContainText( - 'Copied from Index.' - ); - - await page.bringToFront(); - - await page.getByRole( 'button', { name: 'Open Navigation' } ).click(); - - await actionsButton.click(); - - const deactivateButton = page.getByRole( 'menuitem', { - name: 'Deactivate', - } ); - await deactivateButton.click(); - - await expect( - page.locator( - '.dataviews-view-grid__card:has-text("Index (Copy)") .is-success' - ) - ).toBeHidden(); - - await previewPage.bringToFront(); - await previewPage.reload(); - - await expect( previewPage.locator( 'body' ) ).not.toContainText( - 'Copied from Index.' - ); - } ); -} ); diff --git a/test/e2e/specs/site-editor/template-registration.spec.js b/test/e2e/specs/site-editor/template-registration.spec.js index 30ded7e834f8e3..4f35d81737ae29 100644 --- a/test/e2e/specs/site-editor/template-registration.spec.js +++ b/test/e2e/specs/site-editor/template-registration.spec.js @@ -41,7 +41,6 @@ test.describe( 'Block template registration', () => { // Verify template is listed in the Site Editor. await admin.visitSiteEditor( { postType: 'wp_template', - activeView: 'Gutenberg', } ); await blockTemplateRegistrationUtils.searchForTemplate( 'Plugin Template' @@ -50,6 +49,7 @@ test.describe( 'Block template registration', () => { await expect( page.getByText( 'A template registered by a plugin.' ) ).toBeVisible(); + await expect( page.getByText( 'AuthorGutenberg' ) ).toBeVisible(); // Verify the template contents are rendered in the editor. await page.getByText( 'Plugin Template' ).click(); @@ -62,18 +62,19 @@ test.describe( 'Block template registration', () => { name: 'core/paragraph', attributes: { content: 'User-edited template' }, } ); - await editor.saveSiteEditorEntities(); + await editor.saveSiteEditorEntities( { + isOnlyCurrentEntityDirty: true, + } ); await page.goto( '/?cat=1' ); await expect( page.getByText( 'User-edited template' ) ).toBeVisible(); // Verify template can be reset. await admin.visitSiteEditor( { postType: 'wp_template', - activeView: 'user', } ); const resetNotice = page .getByLabel( 'Dismiss this notice' ) - .getByText( `"Plugin Template" moved to the trash.` ); + .getByText( `"Plugin Template" reset.` ); const savedButton = page.getByRole( 'button', { name: 'Saved', } ); @@ -82,8 +83,8 @@ test.describe( 'Block template registration', () => { ); const searchResults = page.getByLabel( 'Actions' ); await searchResults.first().click(); - await page.getByRole( 'menuitem', { name: 'Move to trash' } ).click(); - await page.getByRole( 'button', { name: 'Trash' } ).click(); + await page.getByRole( 'menuitem', { name: 'Reset' } ).click(); + await page.getByRole( 'button', { name: 'Reset' } ).click(); await expect( resetNotice ).toBeVisible(); await expect( savedButton ).toBeVisible(); @@ -153,7 +154,6 @@ test.describe( 'Block template registration', () => { // Verify the plugin-registered template doesn't appear in the Site Editor. await admin.visitSiteEditor( { postType: 'wp_template', - activeView: 'Emptytheme', } ); await blockTemplateRegistrationUtils.searchForTemplate( 'Custom' ); await expect( @@ -165,6 +165,8 @@ test.describe( 'Block template registration', () => { 'A custom template registered by a plugin and overridden by a theme.' ) ).toBeVisible(); + // Verify the theme template shows the theme name as the author. + await expect( page.getByText( 'AuthorEmptytheme' ) ).toBeVisible(); } ); test( 'templates can be deleted if the registered plugin is deactivated', async ( { @@ -177,7 +179,6 @@ test.describe( 'Block template registration', () => { // Make an edit to the template. await admin.visitSiteEditor( { postType: 'wp_template', - activeView: 'Gutenberg', } ); await blockTemplateRegistrationUtils.searchForTemplate( 'Plugin Template' @@ -190,7 +191,9 @@ test.describe( 'Block template registration', () => { name: 'core/paragraph', attributes: { content: 'User-customized template' }, } ); - await editor.saveSiteEditorEntities(); + await editor.saveSiteEditorEntities( { + isOnlyCurrentEntityDirty: true, + } ); // Deactivate plugin. await requestUtils.deactivatePlugin( @@ -200,11 +203,10 @@ test.describe( 'Block template registration', () => { // Verify template can be deleted. await admin.visitSiteEditor( { postType: 'wp_template', - activeView: 'user', } ); const deletedNotice = page .getByLabel( 'Dismiss this notice' ) - .getByText( `"Plugin Template" moved to the trash.` ); + .getByText( `"Plugin Template" deleted.` ); const savedButton = page.getByRole( 'button', { name: 'Saved', } ); @@ -213,8 +215,8 @@ test.describe( 'Block template registration', () => { ); const searchResults = page.getByLabel( 'Actions' ); await searchResults.first().click(); - await page.getByRole( 'menuitem', { name: 'Move to trash' } ).click(); - await page.getByRole( 'button', { name: 'Trash' } ).click(); + await page.getByRole( 'menuitem', { name: 'Delete' } ).click(); + await page.getByRole( 'button', { name: 'Delete' } ).click(); await expect( deletedNotice ).toBeVisible(); await expect( savedButton ).toBeVisible(); @@ -285,7 +287,9 @@ test.describe( 'Block template registration', () => { name: 'core/paragraph', attributes: { content: 'Author template customized by the user.' }, } ); - await editor.saveSiteEditorEntities(); + await editor.saveSiteEditorEntities( { + isOnlyCurrentEntityDirty: true, + } ); await requestUtils.activatePlugin( 'gutenberg-test-block-template-registration' @@ -309,26 +313,32 @@ test.describe( 'Block template registration', () => { ); await expect( page.getByText( 'Plugin Author Template' ) ).toBeHidden(); - await admin.visitSiteEditor( { - postType: 'wp_template', - activeView: 'user', - } ); - // Reset the user-modified template. const resetNotice = page .getByLabel( 'Dismiss this notice' ) - .getByText( `"Author: Admin" moved to the trash.` ); + .getByText( `"Author: Admin" reset.` ); await page.getByPlaceholder( 'Search' ).fill( 'Author: admin' ); await page .locator( '.fields-field__title', { hasText: 'Author: Admin' } ) .click(); const actions = page.getByLabel( 'Actions' ); await actions.first().click(); - await page.getByRole( 'menuitem', { name: 'Move to trash' } ).click(); - await page.getByRole( 'button', { name: 'Trash' } ).click(); + await page.getByRole( 'menuitem', { name: 'Reset' } ).click(); + await page.getByRole( 'button', { name: 'Reset' } ).click(); await expect( resetNotice ).toBeVisible(); + // Verify the template registered by the plugin is applied in the editor... + await expect( + editor.canvas.getByText( 'Author template customized by the user.' ) + ).toBeHidden(); + await expect( + editor.canvas.getByText( + 'This is a plugin-registered author template.' + ) + ).toBeVisible(); + + // ... and the frontend. await page.goto( '?author=1' ); await expect( page.getByText( 'Author template customized by the user.' ) @@ -353,6 +363,6 @@ class BlockTemplateRegistrationUtils { await this.page.getByPlaceholder( 'Search' ).fill( searchTerm ); await expect .poll( async () => await searchResults.count() ) - .toBeLessThanOrEqual( initialSearchResultsCount ); + .toBeLessThan( initialSearchResultsCount ); } } diff --git a/test/e2e/specs/site-editor/template-revert.spec.js b/test/e2e/specs/site-editor/template-revert.spec.js new file mode 100644 index 00000000000000..50a5598f400ebd --- /dev/null +++ b/test/e2e/specs/site-editor/template-revert.spec.js @@ -0,0 +1,257 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +test.use( { + templateRevertUtils: async ( { editor, page }, use ) => { + await use( new TemplateRevertUtils( { editor, page } ) ); + }, +} ); + +test.describe( 'Template Revert', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'emptytheme' ); + await requestUtils.deleteAllTemplates( 'wp_template' ); + await requestUtils.deleteAllTemplates( 'wp_template_part' ); + } ); + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deleteAllTemplates( 'wp_template' ); + await requestUtils.deleteAllTemplates( 'wp_template_part' ); + await requestUtils.activateTheme( 'twentytwentyone' ); + } ); + test.beforeEach( async ( { admin, requestUtils } ) => { + await requestUtils.deleteAllTemplates( 'wp_template' ); + await admin.visitSiteEditor( { canvas: 'edit' } ); + } ); + + test( 'should delete the template after saving the reverted template', async ( { + editor, + page, + templateRevertUtils, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Test' }, + } ); + await editor.saveSiteEditorEntities( { + isOnlyCurrentEntityDirty: true, + } ); + await templateRevertUtils.revertTemplate(); + + const isTemplateTabVisible = await page + .locator( + 'role=region[name="Editor settings"i] >> role=button[name="Template"i]' + ) + .isVisible(); + if ( isTemplateTabVisible ) { + await page.click( + 'role=region[name="Editor settings"i] >> role=button[name="Template"i]' + ); + } + + // The revert button isn't visible anymore. + await expect( + page.locator( + 'role=region[name="Editor settings"i] >> role=button[name="Actions"i]' + ) + ).toBeDisabled(); + } ); + + test( 'should show the original content after revert', async ( { + editor, + templateRevertUtils, + } ) => { + const contentBefore = + await templateRevertUtils.getCurrentSiteEditorContent(); + + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Test' }, + } ); + await editor.saveSiteEditorEntities( { + isOnlyCurrentEntityDirty: true, + } ); + await templateRevertUtils.revertTemplate(); + + const contentAfter = + await templateRevertUtils.getCurrentSiteEditorContent(); + expect( contentAfter ).toEqual( contentBefore ); + } ); + + test( 'should show the original content after revert and page reload', async ( { + admin, + editor, + templateRevertUtils, + } ) => { + const contentBefore = + await templateRevertUtils.getCurrentSiteEditorContent(); + + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Test' }, + } ); + await editor.saveSiteEditorEntities( { + isOnlyCurrentEntityDirty: true, + } ); + await templateRevertUtils.revertTemplate(); + await admin.visitSiteEditor(); + + const contentAfter = + await templateRevertUtils.getCurrentSiteEditorContent(); + expect( contentAfter ).toEqual( contentBefore ); + } ); + + test( 'should show the edited content after revert and clicking undo in the header toolbar', async ( { + editor, + page, + templateRevertUtils, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Test' }, + } ); + await editor.saveSiteEditorEntities( { + isOnlyCurrentEntityDirty: true, + } ); + const contentBefore = + await templateRevertUtils.getCurrentSiteEditorContent(); + + // Revert template and check state. + await templateRevertUtils.revertTemplate(); + const contentAfterSave = + await templateRevertUtils.getCurrentSiteEditorContent(); + expect( contentAfterSave ).not.toEqual( contentBefore ); + + // Undo revert by clicking header button and check state again. + await page.click( + 'role=region[name="Editor top bar"i] >> role=button[name="Undo"i]' + ); + const contentAfterUndo = + await templateRevertUtils.getCurrentSiteEditorContent(); + expect( contentAfterUndo ).toEqual( contentBefore ); + } ); + + test( 'should show the original content after revert, clicking undo then redo in the header toolbar', async ( { + editor, + page, + templateRevertUtils, + } ) => { + const contentBefore = + await templateRevertUtils.getCurrentSiteEditorContent(); + + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Test' }, + } ); + await editor.saveSiteEditorEntities( { + isOnlyCurrentEntityDirty: true, + } ); + await templateRevertUtils.revertTemplate(); + await page.click( + 'role=region[name="Editor top bar"i] >> role=button[name="Undo"i]' + ); + + const contentAfterUndo = + await templateRevertUtils.getCurrentSiteEditorContent(); + expect( contentAfterUndo ).not.toEqual( contentBefore ); + + await page.click( + 'role=region[name="Editor top bar"i] >> role=button[name="Redo"i]' + ); + + const contentAfterRedo = + await templateRevertUtils.getCurrentSiteEditorContent(); + expect( contentAfterRedo ).toEqual( contentBefore ); + } ); + + test( 'should show the edited content after revert, clicking undo in the header toolbar, save and reload', async ( { + admin, + editor, + page, + templateRevertUtils, + } ) => { + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { content: 'Test' }, + } ); + await editor.saveSiteEditorEntities( { + isOnlyCurrentEntityDirty: true, + } ); + await page + .getByRole( 'button', { name: 'Dismiss this notice' } ) + .getByText( /(updated|published)\./ ) + .click(); + const contentBefore = + await templateRevertUtils.getCurrentSiteEditorContent(); + + await templateRevertUtils.revertTemplate(); + + await page.click( + 'role=region[name="Editor top bar"i] >> role=button[name="Undo"i]' + ); + await editor.saveSiteEditorEntities( { + isOnlyCurrentEntityDirty: true, + } ); + await admin.visitSiteEditor(); + + const contentAfter = + await templateRevertUtils.getCurrentSiteEditorContent(); + expect( contentAfter ).toEqual( contentBefore ); + } ); +} ); + +class TemplateRevertUtils { + constructor( { editor, page } ) { + this.editor = editor; + this.page = page; + } + + async revertTemplate() { + await this.editor.openDocumentSettingsSidebar(); + const isTemplateTabVisible = await this.page + .locator( + 'role=region[name="Editor settings"i] >> role=tab[name="Template"i]' + ) + .isVisible(); + if ( isTemplateTabVisible ) { + await this.page.click( + 'role=region[name="Editor settings"i] >> role=tab[name="Template"i]' + ); + } + await this.page.click( + 'role=region[name="Editor settings"i] >> role=button[name="Actions"i]' + ); + await this.page.click( 'role=menuitem[name=/Reset/i]' ); + await this.page.getByRole( 'button', { name: 'Reset' } ).click(); + await this.page.waitForSelector( + 'role=button[name="Dismiss this notice"i] >> text=/ reset./' + ); + } + + async getCurrentSiteEditorContent() { + return this.page.evaluate( () => { + const postId = window.wp.data + .select( 'core/editor' ) + .getCurrentPostId(); + const postType = window.wp.data + .select( 'core/editor' ) + .getCurrentPostType(); + const record = window.wp.data + .select( 'core' ) + .getEditedEntityRecord( 'postType', postType, postId ); + if ( record ) { + if ( typeof record.content === 'function' ) { + return record.content( record ); + } else if ( record.blocks ) { + return window.wp.blocks.__unstableSerializeAndClean( + record.blocks + ); + } else if ( record.content ) { + return record.content; + } + } + return ''; + } ); + } +} diff --git a/test/e2e/specs/site-editor/title.spec.js b/test/e2e/specs/site-editor/title.spec.js index 61ce0b20570a3d..8f6c5252c9f41b 100644 --- a/test/e2e/specs/site-editor/title.spec.js +++ b/test/e2e/specs/site-editor/title.spec.js @@ -19,7 +19,7 @@ test.describe( 'Site editor title', () => { // Navigate to a template. await admin.visitSiteEditor( { postId: 'emptytheme//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); const title = page diff --git a/test/e2e/specs/site-editor/zoom-out.spec.js b/test/e2e/specs/site-editor/zoom-out.spec.js index 28ccba2004a33c..8986215e0b222b 100644 --- a/test/e2e/specs/site-editor/zoom-out.spec.js +++ b/test/e2e/specs/site-editor/zoom-out.spec.js @@ -90,7 +90,7 @@ test.describe( 'Zoom Out', () => { test.beforeEach( async ( { admin } ) => { await admin.visitSiteEditor( { postId: 'twentytwentyfour//index', - postType: 'wp_registered_template', + postType: 'wp_template', canvas: 'edit', } ); } ); diff --git a/test/performance/specs/site-editor.spec.js b/test/performance/specs/site-editor.spec.js index cd462ec2c636cd..2c070acb69d3a8 100644 --- a/test/performance/specs/site-editor.spec.js +++ b/test/performance/specs/site-editor.spec.js @@ -238,10 +238,7 @@ test.describe( 'Site Editor Performance', () => { await metrics.startTracing(); await page - .getByRole( 'button', { - name: 'Single Posts', - exact: true, - } ) + .getByText( 'Single Posts', { exact: true } ) .click( { force: true } ); await metrics.stopTracing(); @@ -292,7 +289,11 @@ test.describe( 'Site Editor Performance', () => { for ( let i = 1; i <= samples; i++ ) { // We want to start from a fresh state each time, without // queries or patterns already cached. - await admin.visitSiteEditor( { canvas: 'edit' } ); + await admin.visitSiteEditor( { + postId: 'twentytwentyfour//home', + postType: 'wp_template', + canvas: 'edit', + } ); await editor.openDocumentSettingsSidebar(); /* 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',