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