diff --git a/docs/designers-developers/developers/data/data-core-block-editor.md b/docs/designers-developers/developers/data/data-core-block-editor.md
index 0632f870731fef..82e01275c5387a 100644
--- a/docs/designers-developers/developers/data/data-core-block-editor.md
+++ b/docs/designers-developers/developers/data/data-core-block-editor.md
@@ -6,6 +6,19 @@ Namespace: `core/block-editor`.
+# **areInnerBlocksControlled**
+
+Checks if a given block has controlled inner blocks.
+
+_Parameters_
+
+- _state_ `Object`: Global application state.
+- _clientId_ `string`: The block to check.
+
+_Returns_
+
+- `boolean`: True if the block has controlled inner blocks.
+
# **canInsertBlockType**
Determines if the given block type is allowed to be inserted into the block list.
@@ -56,6 +69,16 @@ containing its `blockName`, `clientId`, and current `attributes` state. This
is not the block's registration settings, which must be retrieved from the
blocks module registration store.
+getBlock recurses through its inner blocks until all its children blocks have
+been retrieved. Note that getBlock will not return the child inner blocks of
+an inner block controller. This is because an inner block controller syncs
+itself with its own entity, and should therefore not be included with the
+blocks of a different entity. For example, say you call `getBlocks( TP )` to
+get the blocks of a template part. If another template part is a child of TP,
+then the nested template part's child blocks will not be returned. This way,
+the template block itself is considered part of the parent, but the children
+are not.
+
_Parameters_
- _state_ `Object`: Editor state.
@@ -238,10 +261,14 @@ _Returns_
# **getBlocks**
Returns all block objects for the current post being edited as an array in
-the order they appear in the post.
+the order they appear in the post. Note that this will exclude child blocks
+of nested inner block controllers.
Note: It's important to memoize this selector to avoid return a new instance
-on each call
+on each call. We use the block cache state for each top-level block of the
+given clientID. This way, the selector only refreshes on changes to blocks
+associated with the given entity, and does not refresh when changes are made
+to blocks which are part of different inner block controllers.
_Parameters_
@@ -1219,6 +1246,15 @@ _Parameters_
- _clientId_ `string`: Block client ID.
+# **setHasControlledInnerBlocks**
+
+Returns an action object that sets whether the block has controlled innerblocks.
+
+_Parameters_
+
+- _clientId_ `string`: The block's clientId.
+- _hasControlledInnerBlocks_ `boolean`: True if the block's inner blocks are controlled.
+
# **setNavigationMode**
Generators that triggers an action used to enable or disable the navigation mode.
diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md
index a7002b25cb7144..27311c857ea90b 100644
--- a/packages/block-editor/CHANGELOG.md
+++ b/packages/block-editor/CHANGELOG.md
@@ -2,63 +2,67 @@
## Unreleased
+### Breaking Changes
+
+- The block control value for `InnerBlocks` has been changed from `__experimentalBlocks` to `value` and is now considered a stable API.
+
## 3.7.0 (2020-02-10)
### New Features
-- Add new `__experimentalEditorSkeleton` component. This has been moved over from the `@wordpress/edit-post` package, where it was an internal component called `EditorRegions`. Its class names have thus been renamed from `edit-post-editor-regions` to `block-editor-editor-skeleton`.
+- Add new `__experimentalEditorSkeleton` component. This has been moved over from the `@wordpress/edit-post` package, where it was an internal component called `EditorRegions`. Its class names have thus been renamed from `edit-post-editor-regions` to `block-editor-editor-skeleton`.
## 3.3.0 (2019-11-14)
### New Features
-- Added a `label` prop to `URLInput`. This allows the label to be set without needing to wrap the `URLInput` in a `BaseControl`.
+- Added a `label` prop to `URLInput`. This allows the label to be set without needing to wrap the `URLInput` in a `BaseControl`.
### Deprecation
-- `dropZoneUIOnly` prop in `MediaPlaceholder` component has been deprecated in favor of `disableMediaButtons` prop.
+- `dropZoneUIOnly` prop in `MediaPlaceholder` component has been deprecated in favor of `disableMediaButtons` prop.
## 3.0.0 (2019-08-05)
### New Features
-- Added a new `allowedFormats` prop to `RichText` to fine tune allowed formats. Deprecated the `formattingControls` prop in favour of this. Also added a `withoutInteractiveFormatting` to specifically disable format types that would insert interactive elements, which can not be nested.
+- Added a new `allowedFormats` prop to `RichText` to fine tune allowed formats. Deprecated the `formattingControls` prop in favour of this. Also added a `withoutInteractiveFormatting` to specifically disable format types that would insert interactive elements, which can not be nested.
### Breaking Changes
-- `BlockEditorProvider` no longer renders a wrapping `SlotFillProvider` or `DropZoneProvider` (from `@wordpress/components`). For custom block editors, you should render your own as wrapping the `BlockEditorProvider`. A future release will include a new `BlockEditor` component for simple, standard usage. `BlockEditorProvider` will serve the simple purpose of establishing its own context for block editors.
+- `BlockEditorProvider` no longer renders a wrapping `SlotFillProvider` or `DropZoneProvider` (from `@wordpress/components`). For custom block editors, you should render your own as wrapping the `BlockEditorProvider`. A future release will include a new `BlockEditor` component for simple, standard usage. `BlockEditorProvider` will serve the simple purpose of establishing its own context for block editors.
## 2.2.0 (2019-06-12)
### Internal
-- Refactored `BlockSettingsMenu` to use `DropdownMenu` from `@wordpress/components`.
+- Refactored `BlockSettingsMenu` to use `DropdownMenu` from `@wordpress/components`.
## 2.0.0 (2019-04-16)
### New Features
-- Added the `addToGallery` property to the `MediaUpload` interface. The property allows users to open the media modal in the `gallery-library`instead of `gallery-edit` state.
-- Added the `addToGallery` property to the `MediaPlaceholder` component. The component passes the property to the `MediaUpload` component used inside the placeholder.
-- Added the `isAppender` property to the `MediaPlaceholder` component. The property changes the look of the placeholder to be adequate to scenarios where new files are added to an already existing set of files, e.g., adding files to a gallery.
-- Added the `dropZoneUIOnly` property to the `MediaPlaceholder` component. The property makes the `MediaPlaceholder` only render a dropzone without any other additional UI.
-- Added a cancel link to the list of buttons in the `MediaPlaceholder` component which appears if an `onCancel` handler exists.
-- Added the usage of `mediaPreview` for the `Placeholder` component to the `MediaPlaceholder` component.
-- Added a an `onDoubleClick` event handler to the `MediaPlaceholder` component.
-- Added a way to pass special `ref` property to the `PlainText` component.
-- The `URLPopover` component now passes through all unhandled props to the underlying Popover component.
+- Added the `addToGallery` property to the `MediaUpload` interface. The property allows users to open the media modal in the `gallery-library`instead of `gallery-edit` state.
+- Added the `addToGallery` property to the `MediaPlaceholder` component. The component passes the property to the `MediaUpload` component used inside the placeholder.
+- Added the `isAppender` property to the `MediaPlaceholder` component. The property changes the look of the placeholder to be adequate to scenarios where new files are added to an already existing set of files, e.g., adding files to a gallery.
+- Added the `dropZoneUIOnly` property to the `MediaPlaceholder` component. The property makes the `MediaPlaceholder` only render a dropzone without any other additional UI.
+- Added a cancel link to the list of buttons in the `MediaPlaceholder` component which appears if an `onCancel` handler exists.
+- Added the usage of `mediaPreview` for the `Placeholder` component to the `MediaPlaceholder` component.
+- Added a an `onDoubleClick` event handler to the `MediaPlaceholder` component.
+- Added a way to pass special `ref` property to the `PlainText` component.
+- The `URLPopover` component now passes through all unhandled props to the underlying Popover component.
### Breaking Changes
-- `CopyHandler` will now only catch cut/copy events coming from its `props.children`, instead of from anywhere in the `document`.
+- `CopyHandler` will now only catch cut/copy events coming from its `props.children`, instead of from anywhere in the `document`.
### Internal
-- Improved handling of blocks state references for unchanging states.
-- Updated handling of blocks state to effectively ignored programmatically-received blocks data (e.g. reusable blocks received from editor).
+- Improved handling of blocks state references for unchanging states.
+- Updated handling of blocks state to effectively ignored programmatically-received blocks data (e.g. reusable blocks received from editor).
## 1.0.0 (2019-03-06)
### New Features
-- Initial version.
+- Initial version.
diff --git a/packages/block-editor/src/components/inner-blocks/get-block-context.js b/packages/block-editor/src/components/inner-blocks/get-block-context.js
new file mode 100644
index 00000000000000..295ef380d3808d
--- /dev/null
+++ b/packages/block-editor/src/components/inner-blocks/get-block-context.js
@@ -0,0 +1,39 @@
+/**
+ * External dependencies
+ */
+import { mapValues } from 'lodash';
+
+/**
+ * Block context cache, implemented as a WeakMap mapping block types to a
+ * WeakMap mapping attributes object to context value.
+ *
+ * @type {WeakMap>}
+ */
+const BLOCK_CONTEXT_CACHE = new WeakMap();
+
+/**
+ * Returns a cached context object value for a given set of attributes for the
+ * block type.
+ *
+ * @param {Record} attributes Block attributes object.
+ * @param {WPBlockType} blockType Block type settings.
+ *
+ * @return {Record} Context value.
+ */
+export default function getBlockContext( attributes, blockType ) {
+ if ( ! BLOCK_CONTEXT_CACHE.has( blockType ) ) {
+ BLOCK_CONTEXT_CACHE.set( blockType, new WeakMap() );
+ }
+
+ const blockTypeCache = BLOCK_CONTEXT_CACHE.get( blockType );
+ if ( ! blockTypeCache.has( attributes ) ) {
+ const context = mapValues(
+ blockType.providesContext,
+ ( attributeName ) => attributes[ attributeName ]
+ );
+
+ blockTypeCache.set( attributes, context );
+ }
+
+ return blockTypeCache.get( attributes );
+}
diff --git a/packages/block-editor/src/components/inner-blocks/index.js b/packages/block-editor/src/components/inner-blocks/index.js
index 64b958d7f8bf10..82449b48cc36cd 100644
--- a/packages/block-editor/src/components/inner-blocks/index.js
+++ b/packages/block-editor/src/components/inner-blocks/index.js
@@ -1,304 +1,159 @@
/**
* External dependencies
*/
-import { mapValues, pick, isEqual } from 'lodash';
import classnames from 'classnames';
/**
* WordPress dependencies
*/
-import { withViewportMatch } from '@wordpress/viewport';
-import { Component, forwardRef, useRef } from '@wordpress/element';
-import { withSelect, withDispatch } from '@wordpress/data';
-import {
- getBlockType,
- synchronizeBlocksWithTemplate,
- withBlockContentContext,
-} from '@wordpress/blocks';
-import isShallowEqual from '@wordpress/is-shallow-equal';
-import { compose } from '@wordpress/compose';
+import { useViewportMatch } from '@wordpress/compose';
+import { forwardRef, useRef } from '@wordpress/element';
+import { useSelect } from '@wordpress/data';
+import { getBlockType, withBlockContentContext } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import ButtonBlockAppender from './button-block-appender';
import DefaultBlockAppender from './default-block-appender';
+import useNestedSettingsUpdate from './use-nested-settings-update';
+import useInnerBlockTemplateSync from './use-inner-block-template-sync';
+import getBlockContext from './get-block-context';
/**
* Internal dependencies
*/
import BlockList from '../block-list';
import { BlockContextProvider } from '../block-context';
-import { withBlockEditContext } from '../block-edit/context';
+import { useBlockEditContext } from '../block-edit/context';
+import useBlockSync from '../provider/use-block-sync';
/**
- * Block context cache, implemented as a WeakMap mapping block types to a
- * WeakMap mapping attributes object to context value.
+ * InnerBlocks is a component which allows a single block to have multiple blocks
+ * as children. The UncontrolledInnerBlocks component is used whenever the inner
+ * blocks are not controlled by another entity. In other words, it is normally
+ * used for inner blocks in the post editor
*
- * @type {WeakMap>}
+ * @param {Object} props The component props.
*/
-const BLOCK_CONTEXT_CACHE = new WeakMap();
-
-/**
- * Returns a cached context object value for a given set of attributes for the
- * block type.
- *
- * @param {Record} attributes Block attributes object.
- * @param {WPBlockType} blockType Block type settings.
- *
- * @return {Record} Context value.
- */
-function getBlockContext( attributes, blockType ) {
- if ( ! BLOCK_CONTEXT_CACHE.has( blockType ) ) {
- BLOCK_CONTEXT_CACHE.set( blockType, new WeakMap() );
- }
-
- const blockTypeCache = BLOCK_CONTEXT_CACHE.get( blockType );
- if ( ! blockTypeCache.has( attributes ) ) {
- const context = mapValues(
- blockType.providesContext,
- ( attributeName ) => attributes[ attributeName ]
- );
-
- blockTypeCache.set( attributes, context );
- }
-
- return blockTypeCache.get( attributes );
-}
-
-class InnerBlocks extends Component {
- constructor() {
- super( ...arguments );
- this.state = {
- templateInProcess: !! this.props.template,
- };
- this.updateNestedSettings();
- }
-
- componentDidMount() {
- const {
- block,
- templateLock,
- __experimentalBlocks,
- replaceInnerBlocks,
- __unstableMarkNextChangeAsNotPersistent,
- } = this.props;
- const { innerBlocks } = block;
- // Only synchronize innerBlocks with template if innerBlocks are empty or a locking all exists directly on the block.
- if ( innerBlocks.length === 0 || templateLock === 'all' ) {
- this.synchronizeBlocksWithTemplate();
- }
-
- if ( this.state.templateInProcess ) {
- this.setState( {
- templateInProcess: false,
- } );
- }
-
- // Set controlled blocks value from parent, if any.
- if ( __experimentalBlocks ) {
- __unstableMarkNextChangeAsNotPersistent();
- replaceInnerBlocks( __experimentalBlocks, false );
- }
- }
-
- componentDidUpdate( prevProps ) {
- const {
- block,
- templateLock,
- template,
- isLastBlockChangePersistent,
- onInput,
- onChange,
- } = this.props;
- const { innerBlocks } = block;
-
- this.updateNestedSettings();
- // Only synchronize innerBlocks with template if innerBlocks are empty or a locking all exists directly on the block.
- if ( innerBlocks.length === 0 || templateLock === 'all' ) {
- const hasTemplateChanged = ! isEqual(
- template,
- prevProps.template
- );
- if ( hasTemplateChanged ) {
- this.synchronizeBlocksWithTemplate();
- }
- }
-
- // Sync with controlled blocks value from parent, if possible.
- if ( prevProps.block.innerBlocks !== innerBlocks ) {
- const resetFunc = isLastBlockChangePersistent ? onChange : onInput;
- if ( resetFunc ) {
- resetFunc( innerBlocks );
- }
- }
- }
-
- /**
- * Called on mount or when a mismatch exists between the templates and
- * inner blocks, synchronizes inner blocks with the template, replacing
- * current blocks.
- */
- synchronizeBlocksWithTemplate() {
- const { template, block, replaceInnerBlocks } = this.props;
- const { innerBlocks } = block;
-
- // Synchronize with templates. If the next set differs, replace.
- const nextBlocks = synchronizeBlocksWithTemplate(
- innerBlocks,
- template
- );
- if ( ! isEqual( nextBlocks, innerBlocks ) ) {
- replaceInnerBlocks( nextBlocks );
- }
- }
-
- updateNestedSettings() {
- const {
- blockListSettings,
- allowedBlocks,
- updateNestedSettings,
- templateLock,
- parentLock,
- __experimentalCaptureToolbars,
- __experimentalMoverDirection,
- } = this.props;
-
- const newSettings = {
- allowedBlocks,
- templateLock:
- templateLock === undefined ? parentLock : templateLock,
- __experimentalCaptureToolbars:
- __experimentalCaptureToolbars || false,
- __experimentalMoverDirection,
- };
-
- if ( ! isShallowEqual( blockListSettings, newSettings ) ) {
- updateNestedSettings( newSettings );
- }
- }
-
- render() {
- const {
- enableClickThrough,
- clientId,
- hasOverlay,
- __experimentalCaptureToolbars: captureToolbars,
- forwardedRef,
- block,
- ...props
- } = this.props;
- const { templateInProcess } = this.state;
-
- if ( templateInProcess ) {
- return null;
- }
-
- const classes = classnames( {
- 'has-overlay': enableClickThrough && hasOverlay,
- 'is-capturing-toolbar': captureToolbars,
- } );
-
- let blockList = (
-
- );
-
- // Wrap context provider if (and only if) block has context to provide.
- const blockType = getBlockType( block.name );
- if ( blockType && blockType.providesContext ) {
- const context = getBlockContext( block.attributes, blockType );
-
- blockList = (
-
- { blockList }
-
- );
- }
-
- if ( props.__experimentalTagName ) {
- return blockList;
- }
-
- return (
-
- { blockList }
-
- );
- }
-}
-
-const ComposedInnerBlocks = compose( [
- withViewportMatch( { isSmallScreen: '< medium' } ),
- withBlockEditContext( ( context ) => pick( context, [ 'clientId' ] ) ),
- withSelect( ( select, ownProps ) => {
+function UncontrolledInnerBlocks( props ) {
+ const {
+ clientId,
+ allowedBlocks,
+ template,
+ templateLock,
+ forwardedRef,
+ templateInsertUpdatesSelection,
+ __experimentalCaptureToolbars: captureToolbars,
+ __experimentalMoverDirection,
+ } = props;
+
+ const isSmallScreen = useViewportMatch( 'medium', '<' );
+
+ const { hasOverlay, block, enableClickThrough } = useSelect( ( select ) => {
const {
+ getBlock,
isBlockSelected,
hasSelectedInnerBlock,
- getBlock,
- getBlockListSettings,
- getBlockRootClientId,
- getTemplateLock,
isNavigationMode,
- isLastBlockChangePersistent,
} = select( 'core/block-editor' );
- const { clientId, isSmallScreen } = ownProps;
- const block = getBlock( clientId );
- const rootClientId = getBlockRootClientId( clientId );
-
+ const theBlock = getBlock( clientId );
return {
- block,
- blockListSettings: getBlockListSettings( clientId ),
+ block: theBlock,
hasOverlay:
- block.name !== 'core/template' &&
+ theBlock.name !== 'core/template' &&
! isBlockSelected( clientId ) &&
! hasSelectedInnerBlock( clientId, true ),
- parentLock: getTemplateLock( rootClientId ),
enableClickThrough: isNavigationMode() || isSmallScreen,
- isLastBlockChangePersistent: isLastBlockChangePersistent(),
};
- } ),
- withDispatch( ( dispatch, ownProps ) => {
- const {
- replaceInnerBlocks,
- __unstableMarkNextChangeAsNotPersistent,
- updateBlockListSettings,
- } = dispatch( 'core/block-editor' );
- const {
- block,
- clientId,
- templateInsertUpdatesSelection = true,
- } = ownProps;
+ } );
+
+ useNestedSettingsUpdate(
+ clientId,
+ allowedBlocks,
+ templateLock,
+ captureToolbars,
+ __experimentalMoverDirection
+ );
- return {
- replaceInnerBlocks( blocks, forceUpdateSelection ) {
- replaceInnerBlocks(
- clientId,
- blocks,
- forceUpdateSelection !== undefined
- ? forceUpdateSelection
- : block.innerBlocks.length === 0 &&
- templateInsertUpdatesSelection &&
- blocks.length !== 0
- );
- },
- __unstableMarkNextChangeAsNotPersistent,
- updateNestedSettings( settings ) {
- dispatch( updateBlockListSettings( clientId, settings ) );
- },
- };
- } ),
-] )( InnerBlocks );
+ useInnerBlockTemplateSync(
+ clientId,
+ template,
+ templateLock,
+ templateInsertUpdatesSelection
+ );
+
+ const classes = classnames( {
+ 'has-overlay': enableClickThrough && hasOverlay,
+ 'is-capturing-toolbar': captureToolbars,
+ } );
+
+ let blockList = (
+
+ );
+
+ // Wrap context provider if (and only if) block has context to provide.
+ const blockType = getBlockType( block.name );
+ if ( blockType && blockType.providesContext ) {
+ const context = getBlockContext( block.attributes, blockType );
+
+ blockList = (
+
+ { blockList }
+
+ );
+ }
+
+ if ( props.__experimentalTagName ) {
+ return blockList;
+ }
-const ForwardedInnerBlocks = forwardRef( ( props, ref ) => {
- const fallbackRef = useRef();
return (
-
+
+ { blockList }
+
);
+}
+
+/**
+ * The controlled inner blocks component wraps the uncontrolled inner blocks
+ * component with the blockSync hook. This keeps the innerBlocks of the block in
+ * the block-editor store in sync with the blocks of the controlling entity. An
+ * example of an inner block controller is a template part block, which provides
+ * its own blocks from the template part entity data source.
+ *
+ * @param {Object} props The component props.
+ */
+function ControlledInnerBlocks( props ) {
+ useBlockSync( props );
+ return ;
+}
+
+/**
+ * Wrapped InnerBlocks component which detects whether to use the controlled or
+ * uncontrolled variations of the InnerBlocks component. This is the component
+ * which should be used throughout the application.
+ */
+const ForwardedInnerBlocks = forwardRef( ( props, ref ) => {
+ const { clientId } = useBlockEditContext();
+ const fallbackRef = useRef();
+
+ const allProps = {
+ clientId,
+ forwardedRef: ref || fallbackRef,
+ ...props,
+ };
+
+ // Detects if the InnerBlocks should be controlled by an incoming value.
+ if ( props.value && props.onChange ) {
+ return ;
+ }
+ return ;
} );
// Expose default appender placeholders as components.
diff --git a/packages/block-editor/src/components/inner-blocks/index.native.js b/packages/block-editor/src/components/inner-blocks/index.native.js
index 3f078de90dff97..db06f0369abaa3 100644
--- a/packages/block-editor/src/components/inner-blocks/index.native.js
+++ b/packages/block-editor/src/components/inner-blocks/index.native.js
@@ -1,204 +1,138 @@
-/**
- * External dependencies
- */
-import { pick, isEqual } from 'lodash';
-
/**
* WordPress dependencies
*/
-import { Component } from '@wordpress/element';
-import { withSelect, withDispatch } from '@wordpress/data';
-import {
- synchronizeBlocksWithTemplate,
- withBlockContentContext,
-} from '@wordpress/blocks';
-import isShallowEqual from '@wordpress/is-shallow-equal';
-import { compose } from '@wordpress/compose';
+import { useSelect } from '@wordpress/data';
+import { getBlockType, withBlockContentContext } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import ButtonBlockAppender from './button-block-appender';
import DefaultBlockAppender from './default-block-appender';
-import BlockList from '../block-list';
-import { withBlockEditContext } from '../block-edit/context';
-
-class InnerBlocks extends Component {
- constructor() {
- super( ...arguments );
- this.state = {
- templateInProcess: !! this.props.template,
- };
- this.updateNestedSettings();
- }
-
- getTemplateLock() {
- const { templateLock, parentLock } = this.props;
- return templateLock === undefined ? parentLock : templateLock;
- }
+import useNestedSettingsUpdate from './use-nested-settings-update';
+import useInnerBlockTemplateSync from './use-inner-block-template-sync';
+import getBlockContext from './get-block-context';
- componentDidMount() {
- const { innerBlocks } = this.props.block;
- // only synchronize innerBlocks with template if innerBlocks are empty or a locking all exists
- if ( innerBlocks.length === 0 || this.getTemplateLock() === 'all' ) {
- this.synchronizeBlocksWithTemplate();
- }
-
- if ( this.state.templateInProcess ) {
- this.setState( {
- templateInProcess: false,
- } );
- }
- }
-
- componentDidUpdate( prevProps ) {
- const { template, block } = this.props;
- const { innerBlocks } = block;
-
- this.updateNestedSettings();
- // only synchronize innerBlocks with template if innerBlocks are empty or a locking all exists
- if ( innerBlocks.length === 0 || this.getTemplateLock() === 'all' ) {
- const hasTemplateChanged = ! isEqual(
- template,
- prevProps.template
- );
- if ( hasTemplateChanged ) {
- this.synchronizeBlocksWithTemplate();
- }
- }
- }
+/**
+ * Internal dependencies
+ */
+import BlockList from '../block-list';
+import { useBlockEditContext } from '../block-edit/context';
+import useBlockSync from '../provider/use-block-sync';
+import { BlockContextProvider } from '../block-context';
- /**
- * Called on mount or when a mismatch exists between the templates and
- * inner blocks, synchronizes inner blocks with the template, replacing
- * current blocks.
- */
- synchronizeBlocksWithTemplate() {
- const { template, block, replaceInnerBlocks } = this.props;
- const { innerBlocks } = block;
-
- // Synchronize with templates. If the next set differs, replace.
- const nextBlocks = synchronizeBlocksWithTemplate(
- innerBlocks,
- template
+/**
+ * InnerBlocks is a component which allows a single block to have multiple blocks
+ * as children. The UncontrolledInnerBlocks component is used whenever the inner
+ * blocks are not controlled by another entity. In other words, it is normally
+ * used for inner blocks in the post editor
+ *
+ * @param {Object} props The component props.
+ */
+function UncontrolledInnerBlocks( props ) {
+ const {
+ clientId,
+ allowedBlocks,
+ template,
+ templateLock,
+ templateInsertUpdatesSelection,
+ __experimentalMoverDirection,
+ renderAppender,
+ renderFooterAppender,
+ parentWidth,
+ horizontal,
+ contentResizeMode,
+ contentStyle,
+ onAddBlock,
+ onDeleteBlock,
+ marginVertical,
+ marginHorizontal,
+ horizontalAlignment,
+ } = props;
+
+ const block = useSelect( ( select ) =>
+ select( 'core/block-editor' ).getBlock( clientId )
+ );
+
+ useNestedSettingsUpdate( clientId, allowedBlocks, templateLock );
+
+ useInnerBlockTemplateSync(
+ clientId,
+ template,
+ templateLock,
+ templateInsertUpdatesSelection
+ );
+
+ let blockList = (
+
+ );
+
+ // Wrap context provider if (and only if) block has context to provide.
+ const blockType = getBlockType( block.name );
+ if ( blockType && blockType.providesContext ) {
+ const context = getBlockContext( block.attributes, blockType );
+
+ blockList = (
+
+ { blockList }
+
);
- if ( ! isEqual( nextBlocks, innerBlocks ) ) {
- replaceInnerBlocks( nextBlocks );
- }
}
- updateNestedSettings() {
- const {
- blockListSettings,
- allowedBlocks,
- updateNestedSettings,
- } = this.props;
-
- const newSettings = {
- allowedBlocks,
- templateLock: this.getTemplateLock(),
- };
-
- if ( ! isShallowEqual( blockListSettings, newSettings ) ) {
- updateNestedSettings( newSettings );
- }
- }
+ return blockList;
+}
- render() {
- const {
- clientId,
- renderAppender,
- renderFooterAppender,
- __experimentalMoverDirection,
- parentWidth,
- horizontal,
- contentResizeMode,
- contentStyle,
- onAddBlock,
- onDeleteBlock,
- marginVertical,
- marginHorizontal,
- horizontalAlignment,
- } = this.props;
- const { templateInProcess } = this.state;
-
- return (
- <>
- { ! templateInProcess && (
-
- ) }
- >
- );
- }
+/**
+ * The controlled inner blocks component wraps the uncontrolled inner blocks
+ * component with the blockSync hook. This keeps the innerBlocks of the block in
+ * the block-editor store in sync with the blocks of the controlling entity. An
+ * example of an inner block controller is a template part block, which provides
+ * its own blocks from the template part entity data source.
+ *
+ * @param {Object} props The component props.
+ */
+function ControlledInnerBlocks( props ) {
+ useBlockSync( props );
+ return ;
}
-InnerBlocks = compose( [
- withBlockEditContext( ( context ) => pick( context, [ 'clientId' ] ) ),
- withSelect( ( select, ownProps ) => {
- const {
- isBlockSelected,
- hasSelectedInnerBlock,
- getBlock,
- getBlockListSettings,
- getBlockRootClientId,
- getTemplateLock,
- } = select( 'core/block-editor' );
- const { clientId } = ownProps;
- const block = getBlock( clientId );
- const rootClientId = getBlockRootClientId( clientId );
-
- return {
- block,
- blockListSettings: getBlockListSettings( clientId ),
- hasOverlay:
- block.name !== 'core/template' &&
- ! isBlockSelected( clientId ) &&
- ! hasSelectedInnerBlock( clientId, true ),
- parentLock: getTemplateLock( rootClientId ),
- };
- } ),
- withDispatch( ( dispatch, ownProps ) => {
- const { replaceInnerBlocks, updateBlockListSettings } = dispatch(
- 'core/block-editor'
- );
- const {
- block,
- clientId,
- templateInsertUpdatesSelection = true,
- } = ownProps;
-
- return {
- replaceInnerBlocks( blocks ) {
- replaceInnerBlocks(
- clientId,
- blocks,
- block.innerBlocks.length === 0 &&
- templateInsertUpdatesSelection
- );
- },
- updateNestedSettings( settings ) {
- dispatch( updateBlockListSettings( clientId, settings ) );
- },
- };
- } ),
-] )( InnerBlocks );
+/**
+ * Wrapped InnerBlocks component which detects whether to use the controlled or
+ * uncontrolled variations of the InnerBlocks component. This is the component
+ * which should be used throughout the application.
+ *
+ * @param {Object} props The component props.
+ */
+const InnerBlocks = ( props ) => {
+ const { clientId } = useBlockEditContext();
+
+ const allProps = {
+ clientId,
+ ...props,
+ };
+
+ // Detects if the InnerBlocks should be controlled by an incoming value.
+ return props.value && props.onChange ? (
+
+ ) : (
+
+ );
+};
// Expose default appender placeholders as components.
InnerBlocks.DefaultBlockAppender = DefaultBlockAppender;
diff --git a/packages/block-editor/src/components/inner-blocks/use-inner-block-template-sync.js b/packages/block-editor/src/components/inner-blocks/use-inner-block-template-sync.js
new file mode 100644
index 00000000000000..f663aa9f4554f7
--- /dev/null
+++ b/packages/block-editor/src/components/inner-blocks/use-inner-block-template-sync.js
@@ -0,0 +1,73 @@
+/**
+ * External dependencies
+ */
+import { isEqual } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { useRef, useEffect } from '@wordpress/element';
+import { useSelect, useDispatch } from '@wordpress/data';
+import { synchronizeBlocksWithTemplate } from '@wordpress/blocks';
+
+/**
+ * This hook makes sure that a block's inner blocks stay in sync with the given
+ * block "template". The template is a block hierarchy to which inner blocks must
+ * conform. If the blocks get "out of sync" with the template and the template
+ * is meant to be locked (e.g. templateLock = "all"), then we replace the inner
+ * blocks with the correct value after synchronizing it with the template.
+ *
+ * @param {string} clientId The block client ID.
+ * @param {Object} template The template to match.
+ * @param {string} templateLock The template lock state for the inner blocks. For
+ * example, if the template lock is set to "all",
+ * then the inner blocks will stay in sync with the
+ * template. If not defined or set to false, then
+ * the inner blocks will not be synchronized with
+ * the given template.
+ * @param {boolean} templateInsertUpdatesSelection Whether or not to update the
+ * block-editor selection state when inner blocks
+ * are replaced after template synchronization.
+ */
+export default function useInnerBlockTemplateSync(
+ clientId,
+ template,
+ templateLock,
+ templateInsertUpdatesSelection
+) {
+ const { replaceInnerBlocks } = useDispatch( 'core/block-editor' );
+
+ const innerBlocks = useSelect(
+ ( select ) => select( 'core/block-editor' ).getBlocks( clientId ),
+ [ clientId ]
+ );
+
+ // Maintain a reference to the previous value so we can do a deep equality check.
+ const existingTemplate = useRef( null );
+ useEffect( () => {
+ // Only synchronize innerBlocks with template if innerBlocks are empty or
+ // a locking all exists directly on the block.
+ if ( innerBlocks.length === 0 || templateLock === 'all' ) {
+ const hasTemplateChanged = ! isEqual(
+ template,
+ existingTemplate.current
+ );
+ if ( hasTemplateChanged ) {
+ existingTemplate.current = template;
+ const nextBlocks = synchronizeBlocksWithTemplate(
+ innerBlocks,
+ template
+ );
+ if ( ! isEqual( nextBlocks, innerBlocks ) ) {
+ replaceInnerBlocks(
+ clientId,
+ nextBlocks,
+ innerBlocks.length === 0 &&
+ templateInsertUpdatesSelection &&
+ nextBlocks.length !== 0
+ );
+ }
+ }
+ }
+ }, [ innerBlocks, templateLock, clientId ] );
+}
diff --git a/packages/block-editor/src/components/inner-blocks/use-nested-settings-update.js b/packages/block-editor/src/components/inner-blocks/use-nested-settings-update.js
new file mode 100644
index 00000000000000..d894d46f6234c2
--- /dev/null
+++ b/packages/block-editor/src/components/inner-blocks/use-nested-settings-update.js
@@ -0,0 +1,82 @@
+/**
+ * WordPress dependencies
+ */
+import { useEffect } from '@wordpress/element';
+import { useSelect, useDispatch } from '@wordpress/data';
+import isShallowEqual from '@wordpress/is-shallow-equal';
+
+/**
+ * This hook is a side efect which updates the block-editor store when changes
+ * happen to inner block settings. The given props are transformed into a
+ * settings object, and if that is different from the current settings object in
+ * the block-ediotr store, then the store is updated with the new settings which
+ * came from props.
+ *
+ * @param {string} clientId The client ID of the block to update.
+ * @param {string[]} allowedBlocks An array of block names which are permitted
+ * in inner blocks.
+ * @param {string} [templateLock] The template lock specified for the inner
+ * blocks component. (e.g. "all")
+ * @param {boolean} captureToolbars Whether or children toolbars should be shown
+ * in the inner blocks component rather than on
+ * the child block.
+ * @param {string} __experimentalMoverDirection The direction in which the block
+ * should face.
+ */
+export default function useNestedSettingsUpdate(
+ clientId,
+ allowedBlocks,
+ templateLock,
+ captureToolbars,
+ __experimentalMoverDirection
+) {
+ const { updateBlockListSettings } = useDispatch( 'core/block-editor' );
+
+ const { blockListSettings, parentLock } = useSelect(
+ ( select ) => {
+ const rootClientId = select(
+ 'core/block-editor'
+ ).getBlockRootClientId( clientId );
+ return {
+ blockListSettings: select(
+ 'core/block-editor'
+ ).getBlockListSettings( clientId ),
+ parentLock: select( 'core/block-editor' ).getTemplateLock(
+ rootClientId
+ ),
+ };
+ },
+ [ clientId ]
+ );
+
+ useEffect( () => {
+ const newSettings = {
+ allowedBlocks,
+ templateLock:
+ templateLock === undefined ? parentLock : templateLock,
+ };
+
+ // These values are not defined for RN, so only include them if they
+ // are defined.
+ if ( captureToolbars !== undefined ) {
+ newSettings.__experimentalCaptureToolbars = captureToolbars;
+ }
+
+ if ( __experimentalMoverDirection !== undefined ) {
+ newSettings.__experimentalMoverDirection = __experimentalMoverDirection;
+ }
+
+ if ( ! isShallowEqual( blockListSettings, newSettings ) ) {
+ updateBlockListSettings( clientId, newSettings );
+ }
+ }, [
+ clientId,
+ blockListSettings,
+ allowedBlocks,
+ templateLock,
+ parentLock,
+ captureToolbars,
+ __experimentalMoverDirection,
+ updateBlockListSettings,
+ ] );
+}
diff --git a/packages/block-editor/src/components/provider/index.js b/packages/block-editor/src/components/provider/index.js
index 57413c693f1fa6..543a3ee487874f 100644
--- a/packages/block-editor/src/components/provider/index.js
+++ b/packages/block-editor/src/components/provider/index.js
@@ -1,170 +1,29 @@
-/**
- * External dependencies
- */
-import { last, noop } from 'lodash';
-
/**
* WordPress dependencies
*/
-import { Component } from '@wordpress/element';
-import { withDispatch } from '@wordpress/data';
-import { compose } from '@wordpress/compose';
+import { useDispatch } from '@wordpress/data';
+import { useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
import withRegistryProvider from './with-registry-provider';
+import useBlockSync from './use-block-sync';
/** @typedef {import('@wordpress/data').WPDataRegistry} WPDataRegistry */
-class BlockEditorProvider extends Component {
- componentDidMount() {
- this.props.updateSettings( this.props.settings );
- this.props.resetBlocks( this.props.value );
- this.attachChangeObserver( this.props.registry );
- this.isSyncingOutcomingValue = [];
- }
-
- componentDidUpdate( prevProps ) {
- const {
- settings,
- updateSettings,
- value,
- resetBlocks,
- selectionStart,
- selectionEnd,
- resetSelection,
- registry,
- } = this.props;
-
- if ( settings !== prevProps.settings ) {
- updateSettings( settings );
- }
-
- if ( registry !== prevProps.registry ) {
- this.attachChangeObserver( registry );
- }
-
- if ( this.isSyncingOutcomingValue.includes( value ) ) {
- // Skip block reset if the value matches expected outbound sync
- // triggered by this component by a preceding change detection.
- // Only skip if the value matches expectation, since a reset should
- // still occur if the value is modified (not equal by reference),
- // to allow that the consumer may apply modifications to reflect
- // back on the editor.
- if ( last( this.isSyncingOutcomingValue ) === value ) {
- this.isSyncingOutcomingValue = [];
- }
- } else if ( value !== prevProps.value ) {
- // Reset changing value in all other cases than the sync described
- // above. Since this can be reached in an update following an out-
- // bound sync, unset the outbound value to avoid considering it in
- // subsequent renders.
- this.isSyncingOutcomingValue = [];
- this.isSyncingIncomingValue = value;
- resetBlocks( value );
-
- if ( selectionStart && selectionEnd ) {
- resetSelection( selectionStart, selectionEnd );
- }
- }
- }
-
- componentWillUnmount() {
- if ( this.unsubscribe ) {
- this.unsubscribe();
- }
- }
-
- /**
- * Given a registry object, overrides the default dispatch behavior for the
- * `core/block-editor` store to interpret a state change and decide whether
- * we should call `onChange` or `onInput` depending on whether the change
- * is persistent or not.
- *
- * This needs to be done synchronously after state changes (instead of using
- * `componentDidUpdate`) in order to avoid batching these changes.
- *
- * @param {WPDataRegistry} registry Registry from which block editor
- * dispatch is to be overridden.
- */
- attachChangeObserver( registry ) {
- if ( this.unsubscribe ) {
- this.unsubscribe();
- }
+function BlockEditorProvider( props ) {
+ const { children, settings } = props;
- const {
- getBlocks,
- getSelectionStart,
- getSelectionEnd,
- isLastBlockChangePersistent,
- __unstableIsLastBlockChangeIgnored,
- } = registry.select( 'core/block-editor' );
+ const { updateSettings } = useDispatch( 'core/block-editor' );
+ useEffect( () => {
+ updateSettings( settings );
+ }, [ settings ] );
- let blocks = getBlocks();
- let isPersistent = isLastBlockChangePersistent();
+ // Syncs the entity provider with changes in the block-editor store.
+ useBlockSync( props );
- this.unsubscribe = registry.subscribe( () => {
- const { onChange = noop, onInput = noop } = this.props;
-
- const newBlocks = getBlocks();
- const newIsPersistent = isLastBlockChangePersistent();
-
- if (
- newBlocks !== blocks &&
- ( this.isSyncingIncomingValue ||
- __unstableIsLastBlockChangeIgnored() )
- ) {
- this.isSyncingIncomingValue = null;
- blocks = newBlocks;
- isPersistent = newIsPersistent;
- return;
- }
-
- if (
- newBlocks !== blocks ||
- // This happens when a previous input is explicitely marked as persistent.
- ( newIsPersistent && ! isPersistent )
- ) {
- // When knowing the blocks value is changing, assign instance
- // value to skip reset in subsequent `componentDidUpdate`.
- if ( newBlocks !== blocks ) {
- this.isSyncingOutcomingValue.push( newBlocks );
- }
-
- blocks = newBlocks;
- isPersistent = newIsPersistent;
-
- const selectionStart = getSelectionStart();
- const selectionEnd = getSelectionEnd();
-
- if ( isPersistent ) {
- onChange( blocks, { selectionStart, selectionEnd } );
- } else {
- onInput( blocks, { selectionStart, selectionEnd } );
- }
- }
- } );
- }
-
- render() {
- const { children } = this.props;
-
- return children;
- }
+ return children;
}
-export default compose( [
- withRegistryProvider,
- withDispatch( ( dispatch ) => {
- const { updateSettings, resetBlocks, resetSelection } = dispatch(
- 'core/block-editor'
- );
-
- return {
- updateSettings,
- resetBlocks,
- resetSelection,
- };
- } ),
-] )( BlockEditorProvider );
+export default withRegistryProvider( BlockEditorProvider );
diff --git a/packages/block-editor/src/components/provider/index.native.js b/packages/block-editor/src/components/provider/index.native.js
index 57413c693f1fa6..543a3ee487874f 100644
--- a/packages/block-editor/src/components/provider/index.native.js
+++ b/packages/block-editor/src/components/provider/index.native.js
@@ -1,170 +1,29 @@
-/**
- * External dependencies
- */
-import { last, noop } from 'lodash';
-
/**
* WordPress dependencies
*/
-import { Component } from '@wordpress/element';
-import { withDispatch } from '@wordpress/data';
-import { compose } from '@wordpress/compose';
+import { useDispatch } from '@wordpress/data';
+import { useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
import withRegistryProvider from './with-registry-provider';
+import useBlockSync from './use-block-sync';
/** @typedef {import('@wordpress/data').WPDataRegistry} WPDataRegistry */
-class BlockEditorProvider extends Component {
- componentDidMount() {
- this.props.updateSettings( this.props.settings );
- this.props.resetBlocks( this.props.value );
- this.attachChangeObserver( this.props.registry );
- this.isSyncingOutcomingValue = [];
- }
-
- componentDidUpdate( prevProps ) {
- const {
- settings,
- updateSettings,
- value,
- resetBlocks,
- selectionStart,
- selectionEnd,
- resetSelection,
- registry,
- } = this.props;
-
- if ( settings !== prevProps.settings ) {
- updateSettings( settings );
- }
-
- if ( registry !== prevProps.registry ) {
- this.attachChangeObserver( registry );
- }
-
- if ( this.isSyncingOutcomingValue.includes( value ) ) {
- // Skip block reset if the value matches expected outbound sync
- // triggered by this component by a preceding change detection.
- // Only skip if the value matches expectation, since a reset should
- // still occur if the value is modified (not equal by reference),
- // to allow that the consumer may apply modifications to reflect
- // back on the editor.
- if ( last( this.isSyncingOutcomingValue ) === value ) {
- this.isSyncingOutcomingValue = [];
- }
- } else if ( value !== prevProps.value ) {
- // Reset changing value in all other cases than the sync described
- // above. Since this can be reached in an update following an out-
- // bound sync, unset the outbound value to avoid considering it in
- // subsequent renders.
- this.isSyncingOutcomingValue = [];
- this.isSyncingIncomingValue = value;
- resetBlocks( value );
-
- if ( selectionStart && selectionEnd ) {
- resetSelection( selectionStart, selectionEnd );
- }
- }
- }
-
- componentWillUnmount() {
- if ( this.unsubscribe ) {
- this.unsubscribe();
- }
- }
-
- /**
- * Given a registry object, overrides the default dispatch behavior for the
- * `core/block-editor` store to interpret a state change and decide whether
- * we should call `onChange` or `onInput` depending on whether the change
- * is persistent or not.
- *
- * This needs to be done synchronously after state changes (instead of using
- * `componentDidUpdate`) in order to avoid batching these changes.
- *
- * @param {WPDataRegistry} registry Registry from which block editor
- * dispatch is to be overridden.
- */
- attachChangeObserver( registry ) {
- if ( this.unsubscribe ) {
- this.unsubscribe();
- }
+function BlockEditorProvider( props ) {
+ const { children, settings } = props;
- const {
- getBlocks,
- getSelectionStart,
- getSelectionEnd,
- isLastBlockChangePersistent,
- __unstableIsLastBlockChangeIgnored,
- } = registry.select( 'core/block-editor' );
+ const { updateSettings } = useDispatch( 'core/block-editor' );
+ useEffect( () => {
+ updateSettings( settings );
+ }, [ settings ] );
- let blocks = getBlocks();
- let isPersistent = isLastBlockChangePersistent();
+ // Syncs the entity provider with changes in the block-editor store.
+ useBlockSync( props );
- this.unsubscribe = registry.subscribe( () => {
- const { onChange = noop, onInput = noop } = this.props;
-
- const newBlocks = getBlocks();
- const newIsPersistent = isLastBlockChangePersistent();
-
- if (
- newBlocks !== blocks &&
- ( this.isSyncingIncomingValue ||
- __unstableIsLastBlockChangeIgnored() )
- ) {
- this.isSyncingIncomingValue = null;
- blocks = newBlocks;
- isPersistent = newIsPersistent;
- return;
- }
-
- if (
- newBlocks !== blocks ||
- // This happens when a previous input is explicitely marked as persistent.
- ( newIsPersistent && ! isPersistent )
- ) {
- // When knowing the blocks value is changing, assign instance
- // value to skip reset in subsequent `componentDidUpdate`.
- if ( newBlocks !== blocks ) {
- this.isSyncingOutcomingValue.push( newBlocks );
- }
-
- blocks = newBlocks;
- isPersistent = newIsPersistent;
-
- const selectionStart = getSelectionStart();
- const selectionEnd = getSelectionEnd();
-
- if ( isPersistent ) {
- onChange( blocks, { selectionStart, selectionEnd } );
- } else {
- onInput( blocks, { selectionStart, selectionEnd } );
- }
- }
- } );
- }
-
- render() {
- const { children } = this.props;
-
- return children;
- }
+ return children;
}
-export default compose( [
- withRegistryProvider,
- withDispatch( ( dispatch ) => {
- const { updateSettings, resetBlocks, resetSelection } = dispatch(
- 'core/block-editor'
- );
-
- return {
- updateSettings,
- resetBlocks,
- resetSelection,
- };
- } ),
-] )( BlockEditorProvider );
+export default withRegistryProvider( BlockEditorProvider );
diff --git a/packages/block-editor/src/components/provider/test/use-block-sync.js b/packages/block-editor/src/components/provider/test/use-block-sync.js
new file mode 100644
index 00000000000000..29b6b15ed3f1d0
--- /dev/null
+++ b/packages/block-editor/src/components/provider/test/use-block-sync.js
@@ -0,0 +1,368 @@
+/**
+ * External dependencies
+ */
+import { create, act } from 'react-test-renderer';
+
+/**
+ * Internal dependencies
+ */
+import useBlockSync from '../use-block-sync';
+import withRegistryProvider from '../with-registry-provider';
+import * as blockEditorActions from '../../../store/actions';
+
+const TestWrapper = withRegistryProvider( ( props ) => {
+ if ( props.setRegistry ) {
+ props.setRegistry( props.registry );
+ }
+ useBlockSync( props );
+ return Test.
;
+} );
+
+describe( 'useBlockSync hook', () => {
+ afterEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'resets the block-editor blocks when the controll value changes', async () => {
+ const fakeBlocks = [];
+ const resetBlocks = jest.spyOn( blockEditorActions, 'resetBlocks' );
+ const replaceInnerBlocks = jest.spyOn(
+ blockEditorActions,
+ 'replaceInnerBlocks'
+ );
+ const onChange = jest.fn();
+ const onInput = jest.fn();
+
+ let root;
+ await act( async () => {
+ root = create(
+
+ );
+ } );
+
+ // Reset blocks should be called on mount.
+ expect( onChange ).not.toHaveBeenCalled();
+ expect( onInput ).not.toHaveBeenCalled();
+ expect( replaceInnerBlocks ).not.toHaveBeenCalled();
+ expect( resetBlocks ).toHaveBeenCalledWith( fakeBlocks );
+
+ const testBlocks = [
+ { clientId: 'a', innerBlocks: [], attributes: { foo: 1 } },
+ ];
+ await act( async () => {
+ root.update(
+
+ );
+ } );
+
+ // Reset blocks should be called when the incoming value changes.
+ expect( onChange ).not.toHaveBeenCalled();
+ expect( onInput ).not.toHaveBeenCalled();
+ expect( replaceInnerBlocks ).not.toHaveBeenCalled();
+ expect( resetBlocks ).toHaveBeenCalledWith( testBlocks );
+ } );
+
+ it( 'replaces the inner blocks of a block when the control value changes if a clientId is passed', async () => {
+ const fakeBlocks = [];
+ const replaceInnerBlocks = jest.spyOn(
+ blockEditorActions,
+ 'replaceInnerBlocks'
+ );
+ const resetBlocks = jest.spyOn( blockEditorActions, 'resetBlocks' );
+ const onChange = jest.fn();
+ const onInput = jest.fn();
+
+ let root;
+ await act( async () => {
+ root = create(
+
+ );
+ } );
+
+ expect( resetBlocks ).not.toHaveBeenCalled();
+ expect( onChange ).not.toHaveBeenCalled();
+ expect( onInput ).not.toHaveBeenCalled();
+ expect( replaceInnerBlocks ).toHaveBeenCalledWith(
+ 'test', // It should use the given client ID.
+ fakeBlocks, // It should use the controlled blocks value.
+ false // It shoudl not update the selection state.
+ );
+
+ const testBlocks = [
+ { clientId: 'a', innerBlocks: [], attributes: { foo: 1 } },
+ ];
+ await act( async () => {
+ root.update(
+
+ );
+ } );
+
+ // Reset blocks should be called when the incoming value changes.
+ expect( onChange ).not.toHaveBeenCalled();
+ expect( onInput ).not.toHaveBeenCalled();
+ expect( resetBlocks ).not.toHaveBeenCalled();
+ expect( replaceInnerBlocks ).toHaveBeenCalledWith(
+ 'test',
+ testBlocks,
+ false
+ );
+ } );
+
+ it( 'does not add the controlled blocks to the block-editor store if the store already contains them', async () => {
+ const replaceInnerBlocks = jest.spyOn(
+ blockEditorActions,
+ 'replaceInnerBlocks'
+ );
+ const onChange = jest.fn();
+ const onInput = jest.fn();
+
+ const value1 = [
+ { clientId: 'a', innerBlocks: [], attributes: { foo: 1 } },
+ ];
+ let root;
+ let registry;
+ const setRegistry = ( reg ) => {
+ registry = reg;
+ };
+ await act( async () => {
+ root = create(
+
+ );
+ } );
+
+ registry
+ .dispatch( 'core/block-editor' )
+ .updateBlockAttributes( 'a', { foo: 2 } );
+
+ const newBlockValue = registry
+ .select( 'core/block-editor' )
+ .getBlocks( 'test' );
+ replaceInnerBlocks.mockClear();
+
+ // Assert that the reference has changed so that the side effect will be
+ // triggered once more.
+ expect( newBlockValue ).not.toBe( value1 );
+
+ await act( async () => {
+ root.update(
+
+ );
+ } );
+
+ // replaceInnerBlocks should not be called when the controlling
+ // block value is the same as what already exists in the store.
+ expect( replaceInnerBlocks ).not.toHaveBeenCalled();
+ } );
+
+ it( 'sets a block as an inner block controller if a clientId is provided', async () => {
+ const setAsController = jest.spyOn(
+ blockEditorActions,
+ 'setHasControlledInnerBlocks'
+ );
+
+ await act( async () => {
+ create(
+
+ );
+ } );
+ expect( setAsController ).toHaveBeenCalledWith( 'test', true );
+ } );
+
+ it( 'calls onInput when a non-persistent block change occurs', async () => {
+ const onChange = jest.fn();
+ const onInput = jest.fn();
+
+ const value1 = [
+ { clientId: 'a', innerBlocks: [], attributes: { foo: 1 } },
+ ];
+ let registry;
+ const setRegistry = ( reg ) => {
+ registry = reg;
+ };
+ await act( async () => {
+ create(
+
+ );
+ } );
+ onChange.mockClear();
+ onInput.mockClear();
+
+ // Create a non-persistent change.
+ registry
+ .dispatch( 'core/block-editor' )
+ .__unstableMarkNextChangeAsNotPersistent();
+ registry
+ .dispatch( 'core/block-editor' )
+ .updateBlockAttributes( 'a', { foo: 2 } );
+
+ expect( onInput ).toHaveBeenCalledWith(
+ [ { clientId: 'a', innerBlocks: [], attributes: { foo: 2 } } ],
+ { selectionEnd: {}, selectionStart: {} }
+ );
+ expect( onChange ).not.toHaveBeenCalled();
+ } );
+
+ it( 'calls onChange if a persistent change occurs', async () => {
+ const onChange = jest.fn();
+ const onInput = jest.fn();
+
+ const value1 = [
+ { clientId: 'a', innerBlocks: [], attributes: { foo: 1 } },
+ ];
+ let registry;
+ const setRegistry = ( reg ) => {
+ registry = reg;
+ };
+ await act( async () => {
+ create(
+
+ );
+ } );
+ onChange.mockClear();
+ onInput.mockClear();
+
+ // Create a persistent change.
+ registry
+ .dispatch( 'core/block-editor' )
+ .updateBlockAttributes( 'a', { foo: 2 } );
+
+ expect( onChange ).toHaveBeenCalledWith(
+ [ { clientId: 'a', innerBlocks: [], attributes: { foo: 2 } } ],
+ { selectionEnd: {}, selectionStart: {} }
+ );
+ expect( onInput ).not.toHaveBeenCalled();
+ } );
+
+ it( 'avoids updating the parent if there is a pending incoming change', async () => {
+ const replaceInnerBlocks = jest.spyOn(
+ blockEditorActions,
+ 'replaceInnerBlocks'
+ );
+
+ const onChange = jest.fn();
+ const onInput = jest.fn();
+
+ const value1 = [
+ { clientId: 'a', innerBlocks: [], attributes: { foo: 1 } },
+ ];
+
+ await act( async () => {
+ create(
+
+ );
+ } );
+ onChange.mockClear();
+ onInput.mockClear();
+ replaceInnerBlocks.mockClear();
+
+ await act( async () => {
+ create(
+
+ );
+ } );
+
+ expect( replaceInnerBlocks ).toHaveBeenCalledWith( 'test', [], false );
+ expect( onChange ).not.toHaveBeenCalled();
+ expect( onInput ).not.toHaveBeenCalled();
+ } );
+
+ it( 'avoids updating the block-editor store if there is a pending outgoint change', async () => {
+ const replaceInnerBlocks = jest.spyOn(
+ blockEditorActions,
+ 'replaceInnerBlocks'
+ );
+
+ const onChange = jest.fn();
+ const onInput = jest.fn();
+
+ const value1 = [
+ { clientId: 'a', innerBlocks: [], attributes: { foo: 1 } },
+ ];
+
+ let registry;
+ const setRegistry = ( reg ) => {
+ registry = reg;
+ };
+ await act( async () => {
+ create(
+
+ );
+ } );
+ onChange.mockClear();
+ onInput.mockClear();
+ replaceInnerBlocks.mockClear();
+
+ registry
+ .dispatch( 'core/block-editor' )
+ .updateBlockAttributes( 'a', { foo: 2 } );
+
+ expect( replaceInnerBlocks ).not.toHaveBeenCalled();
+ expect( onChange ).toHaveBeenCalledWith(
+ [ { clientId: 'a', innerBlocks: [], attributes: { foo: 2 } } ],
+ { selectionEnd: {}, selectionStart: {} }
+ );
+ expect( onInput ).not.toHaveBeenCalled();
+ } );
+} );
diff --git a/packages/block-editor/src/components/provider/use-block-sync.js b/packages/block-editor/src/components/provider/use-block-sync.js
new file mode 100644
index 00000000000000..8ba4ebf14405fc
--- /dev/null
+++ b/packages/block-editor/src/components/provider/use-block-sync.js
@@ -0,0 +1,199 @@
+/**
+ * External dependencies
+ */
+import { last, noop } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { useEffect, useRef } from '@wordpress/element';
+import { useRegistry } from '@wordpress/data';
+
+/**
+ * A function to call when the block value has been updated in the block-editor
+ * store.
+ *
+ * @callback onBlockUpdate
+ * @param {Object[]} blocks The updated blocks.
+ * @param {Object} options The updated block options, such as selectionStart
+ * and selectionEnd.
+ */
+
+/**
+ * useBlockSync is a side effect which handles bidirectional sync between the
+ * block-editor store and a controlling data source which provides blocks. This
+ * is most commonly used by the BlockEditorProvider to synchronize the contents
+ * of the block-editor store with the root entity, like a post.
+ *
+ * Another example would be the template part block, which provides blocks from
+ * a separate entity data source than a root entity. This hook syncs edits to
+ * the template part in the block editor back to the entity and vice-versa.
+ *
+ * Here are some of its basic functions:
+ * - Initalizes the block-editor store for the given clientID to the blocks
+ * given via props.
+ * - Adds incoming changes (like undo) to the block-editor store.
+ * - Adds outgoing changes (like editing content) to the controlling entity,
+ * determining if a change should be considered persistent or not.
+ * - Handles edge cases and race conditions which occur in those operations.
+ * - Ignores changes which happen to other entities (like nested inner block
+ * controllers.
+ * - Passes selection state from the block-editor store to the controlling entity.
+ *
+ * @param {Object} props Props for the block sync hook
+ * @param {string} props.clientId The client ID of the inner block controller.
+ * If none is passed, then it is assumed to be a
+ * root controller rather than an inner block
+ * controller.
+ * @param {Object[]} props.value The control value for the blocks. This value
+ * is used to initalize the block-editor store
+ * and for resetting the blocks to incoming
+ * changes like undo.
+ * @param {Object} props.selectionStart The selection start vlaue from the
+ * controlling component.
+ * @param {Object} props.selectionEnd The selection end vlaue from the
+ * controlling component.
+ * @param {onBlockUpdate} props.onChange Function to call when a persistent
+ * change has been made in the block-editor blocks
+ * for the given clientId. For example, after
+ * this function is called, an entity is marked
+ * dirty because it has changes to save.
+ * @param {onBlockUpdate} props.onInput Function to call when a non-persistent
+ * change has been made in the block-editor blocks
+ * for the given clientId. When this is called,
+ * controlling sources do not become dirty.
+ */
+export default function useBlockSync( {
+ clientId = null,
+ value: controlledBlocks,
+ selectionStart: controlledSelectionStart,
+ selectionEnd: controlledSelectionEnd,
+ onChange = noop,
+ onInput = noop,
+} ) {
+ const registry = useRegistry();
+
+ const {
+ resetBlocks,
+ resetSelection,
+ replaceInnerBlocks,
+ setHasControlledInnerBlocks,
+ __unstableMarkNextChangeAsNotPersistent,
+ } = registry.dispatch( 'core/block-editor' );
+ const { getBlocks } = registry.select( 'core/block-editor' );
+
+ const pendingChanges = useRef( { incoming: null, outgoing: [] } );
+
+ const setControlledBlocks = () => {
+ if ( ! controlledBlocks ) {
+ return;
+ }
+
+ // We don't need to persist this change because we only replace
+ // controlled inner blocks when the change was caused by an entity,
+ // and so it would already be persisted.
+ __unstableMarkNextChangeAsNotPersistent();
+ if ( clientId ) {
+ setHasControlledInnerBlocks( clientId, true );
+ __unstableMarkNextChangeAsNotPersistent();
+ replaceInnerBlocks( clientId, controlledBlocks, false );
+ } else {
+ resetBlocks( controlledBlocks );
+ }
+ };
+
+ // Add a subscription to the block-editor registry to detect when changes
+ // have been made. This lets us inform the data source of changes. This
+ // is an effect so that the subscriber can run synchronously without
+ // waiting for React renders for changes.
+ useEffect( () => {
+ const {
+ getSelectionStart,
+ getSelectionEnd,
+ isLastBlockChangePersistent,
+ __unstableIsLastBlockChangeIgnored,
+ } = registry.select( 'core/block-editor' );
+
+ let blocks;
+ let isPersistent = isLastBlockChangePersistent();
+ let previousAreBlocksDifferent = false;
+
+ const unsubscribe = registry.subscribe( () => {
+ const newIsPersistent = isLastBlockChangePersistent();
+
+ const newBlocks = getBlocks( clientId );
+ const areBlocksDifferent = newBlocks !== blocks;
+ blocks = newBlocks;
+
+ if (
+ areBlocksDifferent &&
+ ( pendingChanges.current.incoming ||
+ __unstableIsLastBlockChangeIgnored() )
+ ) {
+ pendingChanges.current.incoming = null;
+ isPersistent = newIsPersistent;
+ return;
+ }
+
+ // Since we often dispatch an action to mark the previous action as
+ // persistent, we need to make sure that the blocks changed on the
+ // previous action before committing the change.
+ const didPersistenceChange =
+ previousAreBlocksDifferent &&
+ ! areBlocksDifferent &&
+ newIsPersistent &&
+ ! isPersistent;
+
+ if ( areBlocksDifferent || didPersistenceChange ) {
+ isPersistent = newIsPersistent;
+ // We know that onChange/onInput will update controlledBlocks.
+ // We need to be aware that it was caused by an outgoing change
+ // so that we do not treat it as an incoming change later on,
+ // which would cause a block reset.
+ pendingChanges.current.outgoing.push( blocks );
+
+ // Inform the controlling entity that changes have been made to
+ // the block-editor store they should be aware about.
+ const updateParent = isPersistent ? onChange : onInput;
+ updateParent( blocks, {
+ selectionStart: getSelectionStart(),
+ selectionEnd: getSelectionEnd(),
+ } );
+ }
+ previousAreBlocksDifferent = areBlocksDifferent;
+ } );
+ return () => unsubscribe();
+ }, [ registry, onChange, onInput, clientId ] );
+
+ // Determine if blocks need to be reset when they change.
+ useEffect( () => {
+ if ( pendingChanges.current.outgoing.includes( controlledBlocks ) ) {
+ // Skip block reset if the value matches expected outbound sync
+ // triggered by this component by a preceding change detection.
+ // Only skip if the value matches expectation, since a reset should
+ // still occur if the value is modified (not equal by reference),
+ // to allow that the consumer may apply modifications to reflect
+ // back on the editor.
+ if (
+ last( pendingChanges.current.outgoing ) === controlledBlocks
+ ) {
+ pendingChanges.current.outgoing = [];
+ }
+ } else if ( getBlocks( clientId ) !== controlledBlocks ) {
+ // Reset changing value in all other cases than the sync described
+ // above. Since this can be reached in an update following an out-
+ // bound sync, unset the outbound value to avoid considering it in
+ // subsequent renders.
+ pendingChanges.current.outgoing = [];
+ pendingChanges.current.incoming = controlledBlocks;
+ setControlledBlocks();
+
+ if ( controlledSelectionStart && controlledSelectionEnd ) {
+ resetSelection(
+ controlledSelectionStart,
+ controlledSelectionEnd
+ );
+ }
+ }
+ }, [ controlledBlocks, clientId ] );
+}
diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js
index a4991797255b02..bd1d275b673aa7 100644
--- a/packages/block-editor/src/store/actions.js
+++ b/packages/block-editor/src/store/actions.js
@@ -1010,3 +1010,20 @@ export function toggleBlockHighlight( clientId, isHighlighted ) {
isHighlighted,
};
}
+
+/**
+ * Returns an action object that sets whether the block has controlled innerblocks.
+ *
+ * @param {string} clientId The block's clientId.
+ * @param {boolean} hasControlledInnerBlocks True if the block's inner blocks are controlled.
+ */
+export function setHasControlledInnerBlocks(
+ clientId,
+ hasControlledInnerBlocks
+) {
+ return {
+ type: 'SET_HAS_CONTROLLED_INNER_BLOCKS',
+ hasControlledInnerBlocks,
+ clientId,
+ };
+}
diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js
index c4d51f58ee1911..223fe269508841 100644
--- a/packages/block-editor/src/store/reducer.js
+++ b/packages/block-editor/src/store/reducer.js
@@ -16,6 +16,7 @@ import {
identity,
difference,
omitBy,
+ pickBy,
} from 'lodash';
/**
@@ -129,22 +130,38 @@ function getFlattenedBlockAttributes( blocks ) {
*
* Calling this with `rootClientId` set to `''` results in a list of client IDs
* that are in the post. That is, it excludes blocks like fetched reusable
- * blocks which are stored into state but not visible.
+ * blocks which are stored into state but not visible. It also excludes
+ * InnerBlocks controllers, like template parts.
+ *
+ * It is important to exclude the full inner block controller and not just the
+ * inner blocks because in many cases, we need to persist the previous value of
+ * an inner block controller. To do so, it must be excluded from the list of
+ * client IDs which are considered to be part of the top-level entity.
*
* @param {Object} blocksOrder Object that maps block client IDs to a list of
* nested block client IDs.
* @param {?string} rootClientId The root client ID to search. Defaults to ''.
+ * @param {?Object} controlledInnerBlocks The InnerBlocks controller state.
*
* @return {Array} List of descendant client IDs.
*/
-function getNestedBlockClientIds( blocksOrder, rootClientId = '' ) {
+function getNestedBlockClientIds(
+ blocksOrder,
+ rootClientId = '',
+ controlledInnerBlocks = {}
+) {
return reduce(
blocksOrder[ rootClientId ],
- ( result, clientId ) => [
- ...result,
- clientId,
- ...getNestedBlockClientIds( blocksOrder, clientId ),
- ],
+ ( result, clientId ) => {
+ if ( !! controlledInnerBlocks[ clientId ] ) {
+ return result;
+ }
+ return [
+ ...result,
+ clientId,
+ ...getNestedBlockClientIds( blocksOrder, clientId ),
+ ];
+ },
[]
);
}
@@ -246,7 +263,7 @@ const withBlockCache = ( reducer ) => ( state = {}, action ) => {
do {
result.push( current );
current = state.parents[ current ];
- } while ( current );
+ } while ( current && ! state.controlledInnerBlocks[ current ] );
return result;
}, [] );
};
@@ -261,7 +278,10 @@ const withBlockCache = ( reducer ) => ( state = {}, action ) => {
case 'RECEIVE_BLOCKS':
case 'INSERT_BLOCKS': {
const updatedBlockUids = keys( flattenBlocks( action.blocks ) );
- if ( action.rootClientId ) {
+ if (
+ action.rootClientId &&
+ ! state.controlledInnerBlocks[ action.rootClientId ]
+ ) {
updatedBlockUids.push( action.rootClientId );
}
newState.cache = {
@@ -455,10 +475,15 @@ function withIgnoredBlockChange( reducer ) {
* @return {Function} Enhanced reducer function.
*/
const withInnerBlocksRemoveCascade = ( reducer ) => ( state, action ) => {
+ // Gets all children which need to be removed.
const getAllChildren = ( clientIds ) => {
let result = clientIds;
for ( let i = 0; i < result.length; i++ ) {
- if ( ! state.order[ result[ i ] ] ) {
+ if (
+ ! state.order[ result[ i ] ] ||
+ ( action.keepControlledInnerBlocks &&
+ action.keepControlledInnerBlocks[ result[ i ] ] )
+ ) {
continue;
}
@@ -497,7 +522,7 @@ const withInnerBlocksRemoveCascade = ( reducer ) => ( state, action ) => {
* Higher-order reducer which targets the combined blocks reducer and handles
* the `RESET_BLOCKS` action. When dispatched, this action will replace all
* blocks that exist in the post, leaving blocks that exist only in state (e.g.
- * reusable blocks) alone.
+ * reusable blocks and blocks controlled by inner blocks controllers) alone.
*
* @param {Function} reducer Original reducer function.
*
@@ -505,7 +530,43 @@ const withInnerBlocksRemoveCascade = ( reducer ) => ( state, action ) => {
*/
const withBlockReset = ( reducer ) => ( state, action ) => {
if ( state && action.type === 'RESET_BLOCKS' ) {
- const visibleClientIds = getNestedBlockClientIds( state.order );
+ /**
+ * A list of client IDs associated with the top level entity (like a
+ * post or template). It excludes the client IDs of blocks associated
+ * with other entities, like inner block controllers or reusable blocks.
+ */
+ const visibleClientIds = getNestedBlockClientIds(
+ state.order,
+ '',
+ state.controlledInnerBlocks
+ );
+
+ // pickBy returns only the truthy values from controlledInnerBlocks
+ const controlledInnerBlocks = Object.keys(
+ pickBy( state.controlledInnerBlocks )
+ );
+
+ /**
+ * Each update operation consists of a few parts:
+ * 1. First, the client IDs associated with the top level entity are
+ * removed from the existing state key, leaving in place controlled
+ * blocks (like reusable blocks and inner block controllers).
+ * 2. Second, the blocks from the reset action are used to calculate the
+ * individual state keys. This will re-populate the clientIDs which
+ * were removed in step 1.
+ * 3. In some cases, we remove the recalculated inner block controllers,
+ * letting their old values persist. We need to do this because the
+ * reset block action from a top-level entity is not aware of any
+ * inner blocks inside InnerBlock controllers. So if the new values
+ * were used, it would not take into account the existing InnerBlocks
+ * which already exist in the state for inner block controllers. For
+ * example, `attributes` uses the newly computed value for controllers
+ * since attributes are stored in the top-level entity. But `order`
+ * uses the previous value for the controllers since the new value
+ * does not include the order of controlled inner blocks. So if the
+ * new value was used, template parts would disappear from the editor
+ * whenever you try to undo a change in the top level entity.
+ */
return {
...state,
byClientId: {
@@ -518,7 +579,10 @@ const withBlockReset = ( reducer ) => ( state, action ) => {
},
order: {
...omit( state.order, visibleClientIds ),
- ...mapBlockOrder( action.blocks ),
+ ...omit(
+ mapBlockOrder( action.blocks ),
+ controlledInnerBlocks
+ ),
},
parents: {
...omit( state.parents, visibleClientIds ),
@@ -526,7 +590,10 @@ const withBlockReset = ( reducer ) => ( state, action ) => {
},
cache: {
...omit( state.cache, visibleClientIds ),
- ...mapValues( flattenBlocks( action.blocks ), () => ( {} ) ),
+ ...omit(
+ mapValues( flattenBlocks( action.blocks ), () => ( {} ) ),
+ controlledInnerBlocks
+ ),
},
};
}
@@ -536,9 +603,10 @@ const withBlockReset = ( reducer ) => ( state, action ) => {
/**
* Higher-order reducer which targets the combined blocks reducer and handles
- * the `REPLACE_INNER_BLOCKS` action. When dispatched, this action the state should become equivalent
- * to the execution of a `REMOVE_BLOCKS` action containing all the child's of the root block followed by
- * the execution of `INSERT_BLOCKS` with the new blocks.
+ * the `REPLACE_INNER_BLOCKS` action. When dispatched, this action the state
+ * should become equivalent to the execution of a `REMOVE_BLOCKS` action
+ * containing all the child's of the root block followed by the execution of
+ * `INSERT_BLOCKS` with the new blocks.
*
* @param {Function} reducer Original reducer function.
*
@@ -548,10 +616,33 @@ const withReplaceInnerBlocks = ( reducer ) => ( state, action ) => {
if ( action.type !== 'REPLACE_INNER_BLOCKS' ) {
return reducer( state, action );
}
+
+ // Finds every nested inner block controller. We must check the action blocks
+ // and not just the block parent state because some inner block controllers
+ // should be deleted if specified, whereas others should not be deleted. If
+ // a controlled should not be deleted, then we need to avoid deleting its
+ // inner blocks from the block state because its inner blocks will not be
+ // attached to the block in the action.
+ const nestedControllers = {};
+ if ( Object.keys( state.controlledInnerBlocks ).length ) {
+ const stack = [ ...action.blocks ];
+ while ( stack.length ) {
+ const { innerBlocks, ...block } = stack.shift();
+ stack.push( ...innerBlocks );
+ if ( !! state.controlledInnerBlocks[ block.clientId ] ) {
+ nestedControllers[ block.clientId ] = true;
+ }
+ }
+ }
+
+ // The `keepControlledInnerBlocks` prop will keep the inner blocks of the
+ // marked block in the block state so that they can be reattached to the
+ // marked block when we re-insert everything a few lines below.
let stateAfterBlocksRemoval = state;
if ( state.order[ action.rootClientId ] ) {
stateAfterBlocksRemoval = reducer( stateAfterBlocksRemoval, {
type: 'REMOVE_BLOCKS',
+ keepControlledInnerBlocks: nestedControllers,
clientIds: state.order[ action.rootClientId ],
} );
}
@@ -562,6 +653,23 @@ const withReplaceInnerBlocks = ( reducer ) => ( state, action ) => {
type: 'INSERT_BLOCKS',
index: 0,
} );
+
+ // We need to re-attach the block order of the controlled inner blocks.
+ // Otherwise, an inner block controller's blocks will be deleted entirely
+ // from its entity..
+ stateAfterInsert.order = {
+ ...stateAfterInsert.order,
+ ...reduce(
+ nestedControllers,
+ ( result, value, key ) => {
+ if ( state.order[ key ] ) {
+ result[ key ] = state.order[ key ];
+ }
+ return result;
+ },
+ {}
+ ),
+ };
}
return stateAfterInsert;
};
@@ -971,6 +1079,19 @@ export const blocks = flow(
return state;
},
+
+ controlledInnerBlocks(
+ state = {},
+ { type, clientId, hasControlledInnerBlocks }
+ ) {
+ if ( type === 'SET_HAS_CONTROLLED_INNER_BLOCKS' ) {
+ return {
+ ...state,
+ [ clientId ]: hasControlledInnerBlocks,
+ };
+ }
+ return state;
+ },
} );
/**
@@ -1059,7 +1180,8 @@ function selection( state = {}, action ) {
return { clientId: action.clientId };
case 'REPLACE_INNER_BLOCKS': // REPLACE_INNER_BLOCKS and INSERT_BLOCKS should follow the same logic.
case 'INSERT_BLOCKS': {
- if ( ! action.updateSelection ) {
+ // REPLACE_INNER_BLOCKS can be called with an empty array.
+ if ( ! action.updateSelection || ! action.blocks.length ) {
return state;
}
diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js
index fc9dd38979294b..096b3dd9b2b963 100644
--- a/packages/block-editor/src/store/selectors.js
+++ b/packages/block-editor/src/store/selectors.js
@@ -137,6 +137,16 @@ export function getBlockAttributes( state, clientId ) {
* is not the block's registration settings, which must be retrieved from the
* blocks module registration store.
*
+ * getBlock recurses through its inner blocks until all its children blocks have
+ * been retrieved. Note that getBlock will not return the child inner blocks of
+ * an inner block controller. This is because an inner block controller syncs
+ * itself with its own entity, and should therefore not be included with the
+ * blocks of a different entity. For example, say you call `getBlocks( TP )` to
+ * get the blocks of a template part. If another template part is a child of TP,
+ * then the nested template part's child blocks will not be returned. This way,
+ * the template block itself is considered part of the parent, but the children
+ * are not.
+ *
* @param {Object} state Editor state.
* @param {string} clientId Block client ID.
*
@@ -152,7 +162,9 @@ export const getBlock = createSelector(
return {
...block,
attributes: getBlockAttributes( state, clientId ),
- innerBlocks: getBlocks( state, clientId ),
+ innerBlocks: areInnerBlocksControlled( state, clientId )
+ ? EMPTY_ARRAY
+ : getBlocks( state, clientId ),
};
},
( state, clientId ) => [
@@ -185,10 +197,14 @@ export const __unstableGetBlockWithoutInnerBlocks = createSelector(
/**
* Returns all block objects for the current post being edited as an array in
- * the order they appear in the post.
+ * the order they appear in the post. Note that this will exclude child blocks
+ * of nested inner block controllers.
*
* Note: It's important to memoize this selector to avoid return a new instance
- * on each call
+ * on each call. We use the block cache state for each top-level block of the
+ * given clientID. This way, the selector only refreshes on changes to blocks
+ * associated with the given entity, and does not refresh when changes are made
+ * to blocks which are part of different inner block controllers.
*
* @param {Object} state Editor state.
* @param {?string} rootClientId Optional root client ID of block list.
@@ -201,10 +217,11 @@ export const getBlocks = createSelector(
getBlock( state, clientId )
);
},
- ( state ) => [
- state.blocks.byClientId,
- state.blocks.order,
- state.blocks.attributes,
+ ( state, rootClientId ) => [
+ ...map(
+ state.blocks.order[ rootClientId || '' ],
+ ( id ) => state.blocks.cache[ id ]
+ ),
]
);
@@ -1633,3 +1650,15 @@ export function didAutomaticChange( state ) {
export function isBlockHighlighted( state, clientId ) {
return state.highlightedBlock === clientId;
}
+
+/**
+ * Checks if a given block has controlled inner blocks.
+ *
+ * @param {Object} state Global application state.
+ * @param {string} clientId The block to check.
+ *
+ * @return {boolean} True if the block has controlled inner blocks.
+ */
+export function areInnerBlocksControlled( state, clientId ) {
+ return !! state.blocks.controlledInnerBlocks[ clientId ];
+}
diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js
index 8610a1b8a3f31c..fadf7b3f34ba9a 100644
--- a/packages/block-editor/src/store/test/reducer.js
+++ b/packages/block-editor/src/store/test/reducer.js
@@ -239,6 +239,7 @@ describe( 'state', () => {
chicken: {},
'chicken-child': {},
},
+ controlledInnerBlocks: {},
} );
const newChildBlock = createBlock( 'core/test-child-block', {
@@ -291,6 +292,7 @@ describe( 'state', () => {
chicken: {},
[ newChildBlockId ]: {},
},
+ controlledInnerBlocks: {},
} );
expect( state.cache.chicken ).not.toBe(
existingState.cache.chicken
@@ -319,6 +321,7 @@ describe( 'state', () => {
cache: {
chicken: {},
},
+ controlledInnerBlocks: {},
} );
const newChildBlock = createBlock( 'core/test-child-block', {
@@ -371,6 +374,7 @@ describe( 'state', () => {
chicken: {},
[ newChildBlockId ]: {},
},
+ controlledInnerBlocks: {},
} );
expect( state.cache.chicken ).not.toBe(
existingState.cache.chicken
@@ -421,6 +425,7 @@ describe( 'state', () => {
'chicken-child': {},
'chicken-child-2': {},
},
+ controlledInnerBlocks: {},
} );
const newChildBlock1 = createBlock( 'core/test-child-block', {
@@ -511,6 +516,7 @@ describe( 'state', () => {
[ newChildBlockId2 ]: {},
[ newChildBlockId3 ]: {},
},
+ controlledInnerBlocks: {},
} );
} );
@@ -554,6 +560,7 @@ describe( 'state', () => {
'chicken-child': {},
'chicken-grand-child': {},
},
+ controlledInnerBlocks: {},
} );
const newChildBlock = createBlock( 'core/test-block' );
@@ -600,6 +607,7 @@ describe( 'state', () => {
chicken: {},
[ newChildBlockId ]: {},
},
+ controlledInnerBlocks: {},
} );
// the cache key of the parent should be updated
@@ -620,6 +628,7 @@ describe( 'state', () => {
isPersistentChange: true,
isIgnoredChange: false,
cache: {},
+ controlledInnerBlocks: {},
} );
} );
diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js
index 3cff15b7c922bc..e03b4ccd2a2596 100644
--- a/packages/block-editor/src/store/test/selectors.js
+++ b/packages/block-editor/src/store/test/selectors.js
@@ -220,6 +220,7 @@ describe( 'selectors', () => {
cache: {
123: {},
},
+ controlledInnerBlocks: {},
},
};
@@ -239,6 +240,7 @@ describe( 'selectors', () => {
order: {},
parents: {},
cache: {},
+ controlledInnerBlocks: {},
},
};
@@ -269,6 +271,7 @@ describe( 'selectors', () => {
123: {},
456: {},
},
+ controlledInnerBlocks: {},
},
};
@@ -311,6 +314,7 @@ describe( 'selectors', () => {
123: {},
23: {},
},
+ controlledInnerBlocks: {},
},
};
@@ -365,6 +369,7 @@ describe( 'selectors', () => {
'client-id-03': {},
'client-id-04': {},
},
+ controlledInnerBlocks: {},
},
};
@@ -418,6 +423,7 @@ describe( 'selectors', () => {
'client-id-04': {},
'client-id-05': {},
},
+ controlledInnerBlocks: {},
},
};
@@ -534,6 +540,7 @@ describe( 'selectors', () => {
'uuid-28': 'uuid-24',
'uuid-30': 'uuid-28',
},
+ controlledInnerBlocks: {},
},
};
expect(
@@ -907,6 +914,7 @@ describe( 'selectors', () => {
cache: {
23: {},
},
+ controlledInnerBlocks: {},
},
selectionStart: { clientId: 23 },
selectionEnd: { clientId: 23 },
@@ -2335,6 +2343,7 @@ describe( 'selectors', () => {
block3: {},
block4: {},
},
+ controlledInnerBlocks: {},
},
settings: {
__experimentalReusableBlocks: [
@@ -2418,6 +2427,7 @@ describe( 'selectors', () => {
cache: {
block1: {},
},
+ controlledInnerBlocks: {},
},
preferences: {
insertUsage: {},
@@ -2499,6 +2509,7 @@ describe( 'selectors', () => {
cache: {
block1: {},
},
+ controlledInnerBlocks: {},
},
preferences: {
insertUsage: {},
diff --git a/packages/block-library/src/template-part/edit/inner-blocks.js b/packages/block-library/src/template-part/edit/inner-blocks.js
index f6eb61341ca692..cb2292583e39a4 100644
--- a/packages/block-library/src/template-part/edit/inner-blocks.js
+++ b/packages/block-library/src/template-part/edit/inner-blocks.js
@@ -14,7 +14,7 @@ export default function TemplatePartInnerBlocks() {
);
return (
diff --git a/packages/e2e-test-utils/README.md b/packages/e2e-test-utils/README.md
index 1e84e815f74dc3..d2a08ea8068805 100644
--- a/packages/e2e-test-utils/README.md
+++ b/packages/e2e-test-utils/README.md
@@ -282,7 +282,7 @@ _Returns_
# **insertBlock**
Opens the inserter, searches for the given term, then selects the first
-result that appears.
+result that appears. It then waits briefly for the block list to update.
_Parameters_
diff --git a/packages/e2e-test-utils/src/insert-block.js b/packages/e2e-test-utils/src/insert-block.js
index a3a31994165969..2f097521254f60 100644
--- a/packages/e2e-test-utils/src/insert-block.js
+++ b/packages/e2e-test-utils/src/insert-block.js
@@ -2,17 +2,39 @@
* Internal dependencies
*/
import { searchForBlock } from './search-for-block';
+import { getAllBlocks } from './get-all-blocks';
/**
* Opens the inserter, searches for the given term, then selects the first
- * result that appears.
+ * result that appears. It then waits briefly for the block list to update.
*
* @param {string} searchTerm The text to search the inserter for.
*/
export async function insertBlock( searchTerm ) {
+ const oldBlocks = getAllBlocks();
+
await searchForBlock( searchTerm );
const insertButton = (
await page.$x( `//button//span[contains(text(), '${ searchTerm }')]` )
)[ 0 ];
await insertButton.click();
+
+ const waitForBlocksToChange = ( delay = 20 ) =>
+ new Promise( ( resolve, reject ) => {
+ let elapsedTime = 0;
+ const pendingBlockList = setInterval( () => {
+ const blocks = getAllBlocks();
+ // Reference will change when the selector updates.
+ if ( blocks !== oldBlocks ) {
+ clearInterval( pendingBlockList );
+ resolve();
+ }
+ elapsedTime += delay;
+ if ( elapsedTime > 600 ) {
+ clearInterval( pendingBlockList );
+ reject( `Block ${ searchTerm } was never inserted.` );
+ }
+ }, delay );
+ } );
+ await waitForBlocksToChange();
}
diff --git a/packages/e2e-tests/specs/experiments/multi-entity-editing.test.js b/packages/e2e-tests/specs/experiments/multi-entity-editing.test.js
new file mode 100644
index 00000000000000..4d6e6fe1ea5623
--- /dev/null
+++ b/packages/e2e-tests/specs/experiments/multi-entity-editing.test.js
@@ -0,0 +1,278 @@
+/**
+ * External dependencies
+ */
+import { kebabCase } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { insertBlock, visitAdminPage } from '@wordpress/e2e-test-utils';
+import { addQueryArgs } from '@wordpress/url';
+
+/**
+ * Internal dependencies
+ */
+import {
+ enableExperimentalFeatures,
+ disableExperimentalFeatures,
+} from '../../experimental-features';
+import { trashExistingPosts } from '../../config/setup-test-framework';
+
+const visitSiteEditor = async () => {
+ const query = addQueryArgs( '', {
+ page: 'gutenberg-edit-site',
+ } ).slice( 1 );
+ await visitAdminPage( 'admin.php', query );
+ // Waits for the template part to load...
+ await page.waitForSelector(
+ '.wp-block[data-type="core/template-part"] .block-editor-inner-blocks'
+ );
+};
+
+const openTemplateDropdown = async () => {
+ // Open the dropdown menu.
+ const templateDropdown =
+ 'button.components-dropdown-menu__toggle[aria-label="Switch Template"]';
+ await page.click( templateDropdown );
+ await page.waitForSelector( '.edit-site-template-switcher__popover' );
+};
+
+const getTemplateDropdownElement = async ( itemName ) => {
+ await openTemplateDropdown();
+ const [ item ] = await page.$x(
+ `//div[contains(@class, "edit-site-template-switcher__popover")]//button[contains(., "${ itemName }")]`
+ );
+ return item;
+};
+
+const createTemplate = async ( templateName = 'Test Template' ) => {
+ // Click the "new template" button.
+ const createNewTemplateButton = await getTemplateDropdownElement( 'New' );
+ await createNewTemplateButton.click();
+ await page.waitForSelector( '.components-modal__frame' );
+
+ // Create a new template with the given name.
+ await page.keyboard.press( 'Tab' );
+ await page.keyboard.press( 'Tab' );
+ await page.keyboard.type( templateName );
+ const [ addTemplateButton ] = await page.$x(
+ '//div[contains(@class, "components-modal__frame")]//button[contains(., "Add")]'
+ );
+ await addTemplateButton.click();
+
+ // Wait for the site editor to load the new template.
+ await page.waitForXPath(
+ `//button[contains(@class, "components-dropdown-menu__toggle")][contains(text(), "${ kebabCase(
+ templateName
+ ) }")]`,
+ { timeout: 3000 }
+ );
+};
+
+const createTemplatePart = async (
+ templatePartName = 'test-template-part',
+ themeName = 'test-theme',
+ isNested = false
+) => {
+ // Create new template part.
+ await insertBlock( 'Template Part' );
+ await page.keyboard.type( templatePartName );
+ await page.keyboard.press( 'Tab' );
+ await page.keyboard.type( themeName );
+ await page.keyboard.press( 'Tab' );
+ await page.keyboard.press( 'Enter' );
+ await page.waitForSelector(
+ isNested
+ ? '.wp-block[data-type="core/template-part"] .wp-block[data-type="core/template-part"] .block-editor-inner-blocks'
+ : '.wp-block[data-type="core/template-part"] .block-editor-inner-blocks'
+ );
+};
+
+const editTemplatePart = async ( textToAdd, isNested = false ) => {
+ await page.click(
+ isNested
+ ? '.wp-block[data-type="core/template-part"] .wp-block[data-type="core/template-part"]'
+ : '.wp-block[data-type="core/template-part"]'
+ );
+ for ( const text of textToAdd ) {
+ await page.keyboard.type( text );
+ await page.keyboard.press( 'Enter' );
+ }
+};
+
+const saveAllEntities = async () => {
+ if ( await openEntitySavePanel() ) {
+ await page.click( 'button.editor-entities-saved-states__save-button' );
+ }
+};
+
+const openEntitySavePanel = async () => {
+ // Open the entity save panel if it is not already open.
+ try {
+ await page.waitForSelector( '.entities-saved-states__panel', {
+ timeout: 100,
+ } );
+ } catch {
+ try {
+ await page.click(
+ '.edit-site-save-button__button[aria-disabled=false]',
+ { timeout: 100 }
+ );
+ } catch {
+ return false; // Not dirty because the button is disabled.
+ }
+ await page.waitForSelector( '.entities-saved-states__panel' );
+ }
+ // If we made it this far, the panel is opened.
+ return true;
+};
+
+const isEntityDirty = async ( name ) => {
+ const isOpen = await openEntitySavePanel();
+ if ( ! isOpen ) {
+ return false;
+ }
+ try {
+ await page.waitForXPath(
+ `//label[@class="components-checkbox-control__label"]//strong[contains(text(),"${ name }")]`,
+ { timeout: 500 }
+ );
+ return true;
+ } catch {}
+ return false;
+};
+
+const removeErrorMocks = () => {
+ // TODO: Add back console mocks when
+ // https://github.com/WordPress/gutenberg/issues/17355 is fixed.
+ /* eslint-disable no-console */
+ console.warn.mockReset();
+ console.error.mockReset();
+ console.info.mockReset();
+ /* eslint-enable no-console */
+};
+
+describe( 'Multi-entity editor states', () => {
+ // Setup & Teardown.
+ const requiredExperiments = [
+ '#gutenberg-full-site-editing',
+ '#gutenberg-full-site-editing-demo',
+ ];
+
+ const templatePartName = 'Test Template Part Name Edit';
+ const templateName = 'Test Template Name Edit';
+ const nestedTPName = 'Test Nested Template Part Name Edit';
+
+ beforeAll( async () => {
+ await enableExperimentalFeatures( requiredExperiments );
+ await trashExistingPosts( 'wp_template' );
+ await trashExistingPosts( 'wp_template_part' );
+ } );
+
+ afterAll( async () => {
+ await disableExperimentalFeatures( requiredExperiments );
+ } );
+
+ it( 'should not display any dirty entities when loading the site editor', async () => {
+ await visitSiteEditor();
+ expect( await openEntitySavePanel() ).toBe( true );
+
+ await saveAllEntities();
+ await visitSiteEditor();
+
+ // Unable to open the save panel implies that no entities are dirty.
+ expect( await openEntitySavePanel() ).toBe( false );
+ } );
+
+ it( 'should not dirty an entity by switching to it in the template dropdown', async () => {
+ const templatePartButton = await getTemplateDropdownElement( 'header' );
+ await templatePartButton.click();
+
+ // Wait for blocks to load.
+ await page.waitForSelector( '.wp-block' );
+ expect( await isEntityDirty( 'header' ) ).toBe( false );
+ expect( await isEntityDirty( 'front-page' ) ).toBe( false );
+
+ // Switch back and make sure it is still clean.
+ const templateButton = await getTemplateDropdownElement( 'front-page' );
+ await templateButton.click();
+ await page.waitForSelector( '.wp-block' );
+ expect( await isEntityDirty( 'header' ) ).toBe( false );
+ expect( await isEntityDirty( 'front-page' ) ).toBe( false );
+
+ removeErrorMocks();
+ } );
+
+ describe( 'Multi-entity edit', () => {
+ beforeAll( async () => {
+ await visitSiteEditor();
+ await createTemplate( templateName );
+ await createTemplatePart( templatePartName );
+ await editTemplatePart( [
+ 'Default template part test text.',
+ 'Second paragraph test.',
+ ] );
+ await createTemplatePart( nestedTPName, 'test-theme', true );
+ await editTemplatePart(
+ [ 'Nested Template Part Text.', 'Second Nested test.' ],
+ true
+ );
+ await saveAllEntities();
+ removeErrorMocks();
+ } );
+
+ afterEach( async () => {
+ await saveAllEntities();
+ removeErrorMocks();
+ } );
+
+ it( 'should only dirty the parent entity when editing the parent', async () => {
+ await page.click( '.block-editor-button-block-appender' );
+ await page.waitForSelector( '.block-editor-inserter__menu' );
+ await page.click( 'button.editor-block-list-item-paragraph' );
+
+ // Add changes to the main parent entity.
+ await page.keyboard.type( 'Test.' );
+
+ expect( await isEntityDirty( templateName ) ).toBe( true );
+ expect( await isEntityDirty( templatePartName ) ).toBe( false );
+ expect( await isEntityDirty( nestedTPName ) ).toBe( false );
+ } );
+
+ it( 'should only dirty the child when editing the child', async () => {
+ await page.click(
+ '.wp-block[data-type="core/template-part"] .wp-block[data-type="core/paragraph"]'
+ );
+ await page.keyboard.type( 'Some more test words!' );
+
+ expect( await isEntityDirty( templateName ) ).toBe( false );
+ expect( await isEntityDirty( templatePartName ) ).toBe( true );
+ expect( await isEntityDirty( nestedTPName ) ).toBe( false );
+ } );
+
+ it( 'should only dirty the nested entity when editing the nested entity', async () => {
+ await page.click(
+ '.wp-block[data-type="core/template-part"] .wp-block[data-type="core/template-part"] .wp-block[data-type="core/paragraph"]'
+ );
+ await page.keyboard.type( 'Nested test words!' );
+
+ expect( await isEntityDirty( templateName ) ).toBe( false );
+ expect( await isEntityDirty( templatePartName ) ).toBe( false );
+ expect( await isEntityDirty( nestedTPName ) ).toBe( true );
+ } );
+
+ it( 'should not dirty any entities when hovering over template preview', async () => {
+ const mainTemplateButton = await getTemplateDropdownElement(
+ kebabCase( templateName )
+ );
+ // Hover and wait for template/template part to load.
+ await mainTemplateButton.hover();
+ await page.waitForSelector(
+ '.edit-site-template-switcher__template-preview .wp-block[data-type="core/template-part"]'
+ );
+ expect( await isEntityDirty( templateName ) ).toBe( false );
+ expect( await isEntityDirty( templatePartName ) ).toBe( false );
+ expect( await isEntityDirty( nestedTPName ) ).toBe( false );
+ } );
+ } );
+} );
diff --git a/packages/edit-site/src/components/add-template/index.js b/packages/edit-site/src/components/add-template/index.js
index 9cdf5352001231..1337a5f45afe1d 100644
--- a/packages/edit-site/src/components/add-template/index.js
+++ b/packages/edit-site/src/components/add-template/index.js
@@ -30,7 +30,7 @@ export default function AddTemplate( {
);
const { saveEntityRecord } = useDispatch( 'core' );
- const [ slug, _setSlug ] = useState();
+ const [ slug, _setSlug ] = useState( '' );
const [ help, setHelp ] = useState();
const setSlug = useCallback(
( nextSlug ) => {