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();
+ } );
+} );