diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js
index d2ebae05d24715..9b67261faf2845 100644
--- a/packages/block-editor/src/components/block-list/block.js
+++ b/packages/block-editor/src/components/block-list/block.js
@@ -127,7 +127,10 @@ function BlockListBlock( {
[ clientId ]
);
const { removeBlock } = useDispatch( 'core/block-editor' );
- const onRemove = useCallback( () => removeBlock( clientId ), [ clientId ] );
+ const onRemove = useCallback( () => removeBlock( clientId ), [
+ clientId,
+ removeBlock,
+ ] );
// Handling the error state
const [ hasError, setErrorState ] = useState( false );
@@ -214,23 +217,41 @@ function BlockListBlock( {
);
}
- const value = {
- clientId,
- rootClientId,
- isSelected,
- isFirstMultiSelected,
- isLastMultiSelected,
- isPartOfMultiSelection,
- enableAnimation,
- index,
- className: wrapperClassName,
- isLocked,
- name,
- mode,
- blockTitle: blockType.title,
- wrapperProps: omit( wrapperProps, [ 'data-align' ] ),
- };
- const memoizedValue = useMemo( () => value, Object.values( value ) );
+ const wrapperPropsOmitAlign = omit( wrapperProps, [ 'data-align' ] );
+ const memoizedValue = useMemo(
+ () => ( {
+ clientId,
+ rootClientId,
+ isSelected,
+ isFirstMultiSelected,
+ isLastMultiSelected,
+ isPartOfMultiSelection,
+ enableAnimation,
+ index,
+ className: wrapperClassName,
+ isLocked,
+ name,
+ mode,
+ blockTitle: blockType.title,
+ wrapperProps: wrapperPropsOmitAlign,
+ } ),
+ [
+ clientId,
+ rootClientId,
+ isSelected,
+ isFirstMultiSelected,
+ isLastMultiSelected,
+ isPartOfMultiSelection,
+ enableAnimation,
+ index,
+ wrapperClassName,
+ isLocked,
+ name,
+ mode,
+ blockType.title,
+ wrapperPropsOmitAlign,
+ ]
+ );
let block;
diff --git a/packages/compose/README.md b/packages/compose/README.md
index 3c9ad543080dcc..741ef1a48d08a3 100644
--- a/packages/compose/README.md
+++ b/packages/compose/README.md
@@ -157,6 +157,16 @@ _Parameters_
- _args_ `...any`: Arguments passed to Lodash's `debounce`.
+# **useDidMount**
+
+A drop-in replacement of the hook version of `componentDidMount`.
+Like `useEffect` but only called once when the component is mounted.
+This hook is only used for backward-compatibility reason. Consider using `useEffect` wherever possible.
+
+_Parameters_
+
+- _effect_ `Function`: The effect callback passed to `useEffect`.
+
# **useInstanceId**
Provides a unique instance ID.
@@ -176,6 +186,22 @@ _Parameters_
- _callback_ `Function`: Shortcut callback.
- _options_ `WPKeyboardShortcutConfig`: Shortcut options.
+# **useLazyRef**
+
+Like `useRef` but only run the initializer once.
+
+_Parameters_
+
+- _initializer_ `Function`: A function to return the ref object.
+
+_Returns_
+
+- `MutableRefObject`: The returned ref object.
+
+_Type Definition_
+
+- _MutableRefObject_ (unknown type)
+
# **useMediaQuery**
Runs a media query and returns its value when it changes.
@@ -233,6 +259,17 @@ _Returns_
- `Array`: An array of {Element} `resizeListener` and {?Object} `sizes` with properties `width` and `height`
+# **useShallowCompareEffect**
+
+Like `useEffect` but call the effect when the dependencies are not shallowly equal.
+Useful when the size of the dependency array might change during re-renders.
+This hook is only used for backward-compatibility reason. Consider using `useEffect` wherever possible.
+
+_Parameters_
+
+- _effect_ `Function`: The effect callback passed to `useEffect`.
+- _deps_ `Array`: The dependency array that is compared against shallowly.
+
# **useViewportMatch**
Returns true if the viewport matches the given query, or false otherwise.
diff --git a/packages/compose/src/hooks/use-did-mount/index.js b/packages/compose/src/hooks/use-did-mount/index.js
new file mode 100644
index 00000000000000..a1c26e9243026c
--- /dev/null
+++ b/packages/compose/src/hooks/use-did-mount/index.js
@@ -0,0 +1,21 @@
+/**
+ * WordPress dependencies
+ */
+import { useLayoutEffect, useRef } from '@wordpress/element';
+
+/**
+ * A drop-in replacement of the hook version of `componentDidMount`.
+ * Like `useEffect` but only called once when the component is mounted.
+ * This hook is only used for backward-compatibility reason. Consider using `useEffect` wherever possible.
+ *
+ * @param {Function} effect The effect callback passed to `useEffect`.
+ */
+function useDidMount( effect ) {
+ const effectRef = useRef( effect );
+ effectRef.current = effect;
+
+ // `useLayoutEffect` because that's closer to how the `componentDidMount` works.
+ useLayoutEffect( () => effectRef.current(), [] );
+}
+
+export default useDidMount;
diff --git a/packages/compose/src/hooks/use-did-mount/test/index.js b/packages/compose/src/hooks/use-did-mount/test/index.js
new file mode 100644
index 00000000000000..010d02d41a1dfd
--- /dev/null
+++ b/packages/compose/src/hooks/use-did-mount/test/index.js
@@ -0,0 +1,91 @@
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+
+/**
+ * WordPress dependencies
+ */
+import React, { Component, useEffect } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import useDidMount from '../';
+
+describe( 'useDidMount', () => {
+ it( 'should call the effect when did mount', () => {
+ const mountEffect = jest.fn();
+
+ function TestComponent() {
+ useDidMount( mountEffect );
+ return null;
+ }
+
+ render( );
+
+ expect( mountEffect ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'should call the cleanup function when unmount', () => {
+ const unmountCallback = jest.fn();
+
+ function TestComponent() {
+ useDidMount( () => unmountCallback );
+ return null;
+ }
+
+ const { unmount } = render( );
+
+ expect( unmountCallback ).not.toHaveBeenCalled();
+
+ unmount();
+
+ expect( unmountCallback ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'should match the calling order of componentDidMount', async () => {
+ const mountEffectCallback = jest.fn();
+ const effectCallback = jest.fn();
+
+ const didMountCallback = jest.fn(
+ () =>
+ new Promise( ( resolve ) => {
+ expect( mountEffectCallback ).toHaveBeenCalled();
+ expect( effectCallback ).not.toHaveBeenCalled();
+
+ resolve();
+ } )
+ );
+
+ let promise;
+
+ class DidMount extends Component {
+ componentDidMount() {
+ promise = didMountCallback();
+ }
+ render() {
+ return null;
+ }
+ }
+
+ function Hook() {
+ useDidMount( mountEffectCallback );
+ useEffect( effectCallback );
+ return null;
+ }
+
+ function TestComponent() {
+ return (
+ <>
+
+
+ >
+ );
+ }
+
+ render( );
+
+ await promise;
+ } );
+} );
diff --git a/packages/compose/src/hooks/use-lazy-ref/index.js b/packages/compose/src/hooks/use-lazy-ref/index.js
new file mode 100644
index 00000000000000..31ab0cf6b3a20f
--- /dev/null
+++ b/packages/compose/src/hooks/use-lazy-ref/index.js
@@ -0,0 +1,26 @@
+/**
+ * WordPress dependencies
+ */
+import { useRef } from '@wordpress/element';
+
+const INITIAL_TAG = Symbol( 'INITIAL_TAG' );
+
+/**
+ * Like `useRef` but only run the initializer once.
+ *
+ * @typedef {import('@types/react').MutableRefObject} MutableRefObject
+ *
+ * @param {Function} initializer A function to return the ref object.
+ * @return {MutableRefObject} The returned ref object.
+ */
+function useLazyRef( initializer ) {
+ const ref = useRef( INITIAL_TAG );
+
+ if ( ref.current === INITIAL_TAG ) {
+ ref.current = initializer();
+ }
+
+ return ref;
+}
+
+export default useLazyRef;
diff --git a/packages/compose/src/hooks/use-lazy-ref/test/index.js b/packages/compose/src/hooks/use-lazy-ref/test/index.js
new file mode 100644
index 00000000000000..6f2ebd6cbd02f1
--- /dev/null
+++ b/packages/compose/src/hooks/use-lazy-ref/test/index.js
@@ -0,0 +1,82 @@
+/**
+ * WordPress dependencies
+ */
+import React, { useReducer } from '@wordpress/element';
+
+/**
+ * External dependencies
+ */
+import { render, act } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import useLazyRef from '..';
+
+describe( 'useLazyRef', () => {
+ it( 'should lazily initialize the initializer only once', () => {
+ const initializer = jest.fn( () => 87 );
+ let result;
+ let forceUpdate = () => {};
+
+ function TestComponent() {
+ const ref = useLazyRef( initializer );
+
+ forceUpdate = useReducer( ( c ) => c + 1, 0 )[ 1 ];
+
+ result = ref.current;
+
+ return null;
+ }
+
+ render( );
+
+ expect( initializer ).toHaveBeenCalledTimes( 1 );
+ expect( result ).toBe( 87 );
+
+ act( () => {
+ forceUpdate();
+ } );
+
+ expect( initializer ).toHaveBeenCalledTimes( 1 );
+ expect( result ).toBe( 87 );
+ } );
+
+ it( 'should not accept falsy values', () => {
+ const initializer = jest.fn( () => 87 );
+ let result;
+ let ref = { current: null };
+ let forceUpdate = () => {};
+
+ function TestComponent() {
+ ref = useLazyRef( initializer );
+
+ forceUpdate = useReducer( ( c ) => c + 1, 0 )[ 1 ];
+
+ result = ref.current;
+
+ return null;
+ }
+
+ render( );
+
+ expect( initializer ).toHaveBeenCalledTimes( 1 );
+ expect( result ).toBe( 87 );
+
+ ref.current = undefined;
+ act( () => {
+ forceUpdate();
+ } );
+
+ expect( initializer ).toHaveBeenCalledTimes( 1 );
+ expect( result ).toBe( undefined );
+
+ ref.current = null;
+ act( () => {
+ forceUpdate();
+ } );
+
+ expect( initializer ).toHaveBeenCalledTimes( 1 );
+ expect( result ).toBe( null );
+ } );
+} );
diff --git a/packages/compose/src/hooks/use-shallow-compare-effect/index.js b/packages/compose/src/hooks/use-shallow-compare-effect/index.js
new file mode 100644
index 00000000000000..84d190964782ba
--- /dev/null
+++ b/packages/compose/src/hooks/use-shallow-compare-effect/index.js
@@ -0,0 +1,26 @@
+/**
+ * WordPress dependencies
+ */
+import { useEffect, useRef } from '@wordpress/element';
+import isShallowEqual from '@wordpress/is-shallow-equal';
+
+/**
+ * Like `useEffect` but call the effect when the dependencies are not shallowly equal.
+ * Useful when the size of the dependency array might change during re-renders.
+ * This hook is only used for backward-compatibility reason. Consider using `useEffect` wherever possible.
+ *
+ * @param {Function} effect The effect callback passed to `useEffect`.
+ * @param {Array} deps The dependency array that is compared against shallowly.
+ */
+function useShallowCompareEffect( effect, deps ) {
+ const ref = useRef();
+
+ if ( ! isShallowEqual( ref.current, deps ) ) {
+ ref.current = deps;
+ }
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ useEffect( effect, [ ref.current ] );
+}
+
+export default useShallowCompareEffect;
diff --git a/packages/compose/src/hooks/use-shallow-compare-effect/test/index.js b/packages/compose/src/hooks/use-shallow-compare-effect/test/index.js
new file mode 100644
index 00000000000000..51cbaad835d391
--- /dev/null
+++ b/packages/compose/src/hooks/use-shallow-compare-effect/test/index.js
@@ -0,0 +1,58 @@
+/**
+ * WordPress dependencies
+ */
+import React from '@wordpress/element';
+
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import useShallowCompareEffect from '../';
+
+describe( 'useShallowCompareEffect', () => {
+ it( 'should call the effect when the dependencies are not shallowly equal', () => {
+ const effectCallback = jest.fn();
+
+ let deps = [ 1, 2, 3 ];
+
+ function TestComponent() {
+ useShallowCompareEffect( effectCallback, deps );
+ return null;
+ }
+
+ const { rerender } = render( );
+
+ expect( effectCallback ).toHaveBeenCalledTimes( 1 );
+
+ deps = [ 4, 5, 6 ];
+
+ rerender( );
+
+ expect( effectCallback ).toHaveBeenCalledTimes( 2 );
+ } );
+
+ it( 'should not call the effect when the dependencies are shallowly equal', () => {
+ const effectCallback = jest.fn();
+
+ let deps = [ 1, 2, 3 ];
+
+ function TestComponent() {
+ useShallowCompareEffect( effectCallback, deps );
+ return null;
+ }
+
+ const { rerender } = render( );
+
+ expect( effectCallback ).toHaveBeenCalledTimes( 1 );
+
+ deps = [ 1, 2, 3 ]; // Different instance
+
+ rerender( );
+
+ expect( effectCallback ).toHaveBeenCalledTimes( 1 );
+ } );
+} );
diff --git a/packages/compose/src/index.js b/packages/compose/src/index.js
index 3afcc9df90c585..f19c4cd977cf8f 100644
--- a/packages/compose/src/index.js
+++ b/packages/compose/src/index.js
@@ -25,3 +25,6 @@ export { default as useResizeObserver } from './hooks/use-resize-observer';
export { default as useAsyncList } from './hooks/use-async-list';
export { default as useWarnOnChange } from './hooks/use-warn-on-change';
export { default as useDebounce } from './hooks/use-debounce';
+export { default as useLazyRef } from './hooks/use-lazy-ref';
+export { default as useShallowCompareEffect } from './hooks/use-shallow-compare-effect';
+export { default as useDidMount } from './hooks/use-did-mount';
diff --git a/packages/rich-text/src/component/index.js b/packages/rich-text/src/component/index.js
index 04fcb77f92b606..846f3c7afe830c 100644
--- a/packages/rich-text/src/component/index.js
+++ b/packages/rich-text/src/component/index.js
@@ -6,14 +6,7 @@ import { find, isNil } from 'lodash';
/**
* WordPress dependencies
*/
-import {
- forwardRef,
- useEffect,
- useRef,
- useState,
- useMemo,
- useLayoutEffect,
-} from '@wordpress/element';
+import { forwardRef, useEffect, useRef, useState } from '@wordpress/element';
import {
BACKSPACE,
DELETE,
@@ -24,6 +17,11 @@ import {
ESCAPE,
} from '@wordpress/keycodes';
import deprecated from '@wordpress/deprecated';
+import {
+ useLazyRef,
+ useShallowCompareEffect,
+ useDidMount,
+} from '@wordpress/compose';
/**
* Internal dependencies
@@ -129,6 +127,60 @@ function fixPlaceholderSelection( defaultView ) {
selection.collapseToStart();
}
+// These effects are better grouped together as they serve the same logic.
+function useReapply( {
+ TagName,
+ placeholder,
+ _value,
+ value,
+ record,
+ selectionStart,
+ selectionEnd,
+ isSelected,
+ dependencies,
+ applyFromProps,
+} ) {
+ const shouldReapplyRef = useRef( false );
+
+ useEffect( () => {
+ shouldReapplyRef.current = true;
+ }, [ TagName, placeholder ] );
+
+ useEffect( () => {
+ if ( value !== _value.current ) {
+ shouldReapplyRef.current = true;
+ }
+ }, [ value, _value ] );
+
+ useEffect( () => {
+ const hasSelectionChanged =
+ selectionStart !== record.current.start ||
+ selectionEnd !== record.current.end;
+
+ if ( isSelected && hasSelectionChanged ) {
+ shouldReapplyRef.current = true;
+ } else if ( hasSelectionChanged ) {
+ record.current = {
+ ...record.current,
+ start: selectionStart,
+ end: selectionEnd,
+ };
+ }
+ }, [ selectionStart, selectionEnd, isSelected, record ] );
+
+ useShallowCompareEffect( () => {
+ shouldReapplyRef.current = true;
+ }, dependencies );
+
+ useEffect( () => {
+ if ( shouldReapplyRef.current ) {
+ applyFromProps();
+
+ shouldReapplyRef.current = false;
+ }
+ } );
+}
+
function RichText(
{
tagName: TagName = 'div',
@@ -273,14 +325,12 @@ function RichText(
// Internal values are updated synchronously, unlike props and state.
const _value = useRef( value );
- const record = useRef(
- useMemo( () => {
- const initialRecord = formatToValue( value );
- initialRecord.start = selectionStart;
- initialRecord.end = selectionEnd;
- return initialRecord;
- }, [] )
- );
+ const record = useLazyRef( () => {
+ const initialRecord = formatToValue( value );
+ initialRecord.start = selectionStart;
+ initialRecord.end = selectionEnd;
+ return initialRecord;
+ } );
function createRecord() {
const selection = getWin().getSelection();
@@ -808,8 +858,6 @@ function RichText(
getDoc().addEventListener( 'selectionchange', handleSelectionChange );
}
- const didMount = useRef( false );
-
/**
* Syncs the selection to local state. A callback for the `selectionchange`
* native events, `keyup`, `mouseup` and `touchend` synthetic events, and
@@ -1036,49 +1084,9 @@ function RichText(
applyRecord( record.current );
}
- useEffect( () => {
- if ( didMount.current ) {
- applyFromProps();
- }
- }, [ TagName, placeholder ] );
-
- useEffect( () => {
- if ( didMount.current && value !== _value.current ) {
- applyFromProps();
- }
- }, [ value ] );
-
- useEffect( () => {
- if ( ! didMount.current ) {
- return;
- }
-
- if (
- isSelected &&
- ( selectionStart !== record.current.start ||
- selectionEnd !== record.current.end )
- ) {
- applyFromProps();
- } else {
- record.current = {
- ...record.current,
- start: selectionStart,
- end: selectionEnd,
- };
- }
- }, [ selectionStart, selectionEnd, isSelected ] );
-
- useEffect( () => {
- if ( didMount.current ) {
- applyFromProps();
- }
- }, dependencies );
-
- useLayoutEffect( () => {
+ useDidMount( () => {
applyRecord( record.current, { domOnly: true } );
- didMount.current = true;
-
return () => {
getDoc().removeEventListener(
'selectionchange',
@@ -1087,7 +1095,20 @@ function RichText(
getWin().cancelAnimationFrame( rafId.current );
getWin().clearTimeout( timeout.current );
};
- }, [] );
+ } );
+
+ useReapply( {
+ TagName,
+ placeholder,
+ _value,
+ value,
+ record,
+ selectionStart,
+ selectionEnd,
+ isSelected,
+ dependencies,
+ applyFromProps,
+ } );
function focus() {
ref.current.focus();