diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 5459fa35d806a2..b70fbbd9b6ab44 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -5,6 +5,7 @@ ### Bug Fix - `Popover`: enable auto-updating every animation frame ([#43617](https://github.com/WordPress/gutenberg/pull/43617)). +- `Popover`: improve the component's performance and reactivity to prop changes by reworking its internals ([#43335](https://github.com/WordPress/gutenberg/pull/43335)). ### Internal diff --git a/packages/components/src/popover/index.js b/packages/components/src/popover/index.js index 269c15d946df3a..eb3607e5b305c5 100644 --- a/packages/components/src/popover/index.js +++ b/packages/components/src/popover/index.js @@ -26,6 +26,8 @@ import { createContext, useContext, useMemo, + useState, + useCallback, useEffect, } from '@wordpress/element'; import { @@ -47,6 +49,8 @@ import { getFrameOffset, positionToPlacement, placementToMotionAnimationProps, + getReferenceOwnerDocument, + getReferenceElement, } from './utils'; /** @@ -90,6 +94,10 @@ const MaybeAnimatedWrapper = forwardRef( }, forwardedRef ) => { + // When animating, animate only once (i.e. when the popover is opened), and + // do not animate on subsequent prop changes (as it conflicts with + // floating-ui's positioning updates). + const [ hasAnimatedOnce, setHasAnimatedOnce ] = useState( false ); const shouldReduceMotion = useReducedMotion(); const { style: motionInlineStyles, ...otherMotionProps } = useMemo( @@ -97,6 +105,11 @@ const MaybeAnimatedWrapper = forwardRef( [ placement ] ); + const onAnimationComplete = useCallback( + () => setHasAnimatedOnce( true ), + [] + ); + if ( shouldAnimate && ! shouldReduceMotion ) { return ( @@ -172,7 +189,14 @@ const Popover = ( } const arrowRef = useRef( null ); - const anchorRefFallback = useRef( null ); + + const [ fallbackReferenceElement, setFallbackReferenceElement ] = + useState(); + const [ referenceOwnerDocument, setReferenceOwnerDocument ] = useState(); + + const anchorRefFallback = useCallback( ( node ) => { + setFallbackReferenceElement( node ); + }, [] ); const isMobileViewport = useViewportMatch( 'medium', '<' ); const isExpanded = expandOnMobile && isMobileViewport; @@ -181,29 +205,6 @@ const Popover = ( ? positionToPlacement( position ) : placementProp; - const referenceOwnerDocument = useMemo( () => { - let documentToReturn; - - if ( anchorRef?.top ) { - documentToReturn = anchorRef?.top.ownerDocument; - } else if ( anchorRef?.startContainer ) { - documentToReturn = anchorRef.startContainer.ownerDocument; - } else if ( anchorRef?.current ) { - documentToReturn = anchorRef.current.ownerDocument; - } else if ( anchorRef ) { - // This one should be deprecated. - documentToReturn = anchorRef.ownerDocument; - } else if ( anchorRect && anchorRect?.ownerDocument ) { - documentToReturn = anchorRect.ownerDocument; - } else if ( getAnchorRect ) { - documentToReturn = getAnchorRect( - anchorRefFallback.current - )?.ownerDocument; - } - - return documentToReturn ?? document; - }, [ anchorRef, anchorRect, getAnchorRect ] ); - /** * Offsets the position of the popover when the anchor is inside an iframe. * @@ -268,7 +269,7 @@ const Popover = ( padding: 1, // Necessary to avoid flickering at the edge of the viewport. } ) : undefined, - hasArrow ? arrow( { element: arrowRef } ) : undefined, + arrow( { element: arrowRef } ), ].filter( ( m ) => !! m ); const slotName = useContext( slotNameContext ) || __unstableSlotName; const slot = useSlot( slotName ); @@ -299,7 +300,7 @@ const Popover = ( y, // Callback refs (not regular refs). This allows the position to be updated. // when either elements change. - reference, + reference: referenceCallbackRef, floating, // Object with "regular" refs to both "reference" and "floating" refs, @@ -308,106 +309,70 @@ const Popover = ( update, placement: computedPlacement, middlewareData: { arrow: arrowData = {} }, - } = useFloating( { placement: normalizedPlacementFromProps, middleware } ); + } = useFloating( { + placement: normalizedPlacementFromProps, + middleware, + whileElementsMounted: ( referenceParam, floatingParam, updateParam ) => + autoUpdate( referenceParam, floatingParam, updateParam, { + animationFrame: true, + } ), + } ); useEffect( () => { offsetRef.current = offsetProp; update(); }, [ offsetProp, update ] ); - // Update the `reference`'s ref. - // - // In floating-ui's terms: - // - "reference" refers to the popover's anchor element. - // - "floating" refers the floating popover's element. - // A floating element can also be positioned relative to a virtual element, - // instead of a real one. A virtual element is represented by an object - // with the `getBoundingClientRect()` function (like real elements). - // See https://floating-ui.com/docs/virtual-elements for more info. - useLayoutEffect( () => { - let resultingReferenceRef; - - if ( anchorRef?.top ) { - // Create a virtual element for the ref. The expectation is that - // if anchorRef.top is defined, then anchorRef.bottom is defined too. - resultingReferenceRef = { - getBoundingClientRect() { - const topRect = anchorRef.top.getBoundingClientRect(); - const bottomRect = anchorRef.bottom.getBoundingClientRect(); - return new window.DOMRect( - topRect.x, - topRect.y, - topRect.width, - bottomRect.bottom - topRect.top - ); - }, - }; - } else if ( anchorRef?.current ) { - // Standard React ref. - resultingReferenceRef = anchorRef.current; - } else if ( anchorRef ) { - // If `anchorRef` holds directly the element's value (no `current` key) - // This is a weird scenario and should be deprecated. - resultingReferenceRef = anchorRef; - } else if ( anchorRect ) { - // Create a virtual element for the ref. - resultingReferenceRef = { - getBoundingClientRect() { - return anchorRect; - }, - }; - } else if ( getAnchorRect ) { - // Create a virtual element for the ref. - resultingReferenceRef = { - getBoundingClientRect() { - const rect = getAnchorRect( anchorRefFallback.current ); - return new window.DOMRect( - rect.x ?? rect.left, - rect.y ?? rect.top, - rect.width ?? rect.right - rect.left, - rect.height ?? rect.bottom - rect.top - ); - }, - }; - } else if ( anchorRefFallback.current ) { - // If no explicit ref is passed via props, fall back to - // anchoring to the popover's parent node. - resultingReferenceRef = anchorRefFallback.current.parentNode; - } - - if ( ! resultingReferenceRef ) { - return; - } + const arrowCallbackRef = useCallback( + ( node ) => { + arrowRef.current = node; + update(); + }, + [ update ] + ); - reference( resultingReferenceRef ); + // When any of the possible anchor "sources" change, + // recompute the reference element (real or virtual) and its owner document. + useLayoutEffect( () => { + const resultingReferenceOwnerDoc = getReferenceOwnerDocument( { + anchorRef, + anchorRect, + getAnchorRect, + fallbackReferenceElement, + fallbackDocument: document, + } ); + const resultingReferenceElement = getReferenceElement( { + anchorRef, + anchorRect, + getAnchorRect, + fallbackReferenceElement, + } ); - if ( ! refs.floating.current ) { - return; - } + referenceCallbackRef( resultingReferenceElement ); - return autoUpdate( - resultingReferenceRef, - refs.floating.current, - update, - { - animationFrame: true, - } - ); - // 'reference' and 'refs.floating' are refs and don't need to be listed - // as dependencies (see https://github.com/WordPress/gutenberg/pull/41612) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ anchorRef, anchorRect, getAnchorRect, update ] ); + setReferenceOwnerDocument( resultingReferenceOwnerDoc ); + }, [ + anchorRef, + anchorRef?.top, + anchorRef?.bottom, + anchorRef?.startContainer, + anchorRef?.current, + anchorRect, + getAnchorRect, + fallbackReferenceElement, + referenceCallbackRef, + ] ); // If the reference element is in a different ownerDocument (e.g. iFrame), // we need to manually update the floating's position as the reference's owner // document scrolls. Also update the frame offset if the view resizes. useLayoutEffect( () => { - const referenceAndFloatingHaveSameDocument = + const referenceAndFloatingAreInSameDocument = referenceOwnerDocument === document; const hasFrameElement = !! referenceOwnerDocument?.defaultView?.frameElement; - if ( referenceAndFloatingHaveSameDocument || ! hasFrameElement ) { + if ( referenceAndFloatingAreInSameDocument || ! hasFrameElement ) { frameOffsetRef.current = undefined; return; } @@ -477,17 +442,23 @@ const Popover = (
{ children }
{ hasArrow && (
diff --git a/packages/components/src/popover/stories/index.js b/packages/components/src/popover/stories/index.js index ee2991105b130b..68c0488e92ffc5 100644 --- a/packages/components/src/popover/stories/index.js +++ b/packages/components/src/popover/stories/index.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { useState, useRef } from '@wordpress/element'; +import { useState, useRef, useEffect } from '@wordpress/element'; import { __unstableIframe as Iframe } from '@wordpress/block-editor'; /** @@ -107,18 +107,29 @@ export const Default = ( args ) => { const toggleVisible = () => { setIsVisible( ( state ) => ! state ); }; + const buttonRef = useRef(); + useEffect( () => { + buttonRef.current?.scrollIntoView?.( { + block: 'center', + inline: 'center', + } ); + }, [] ); return (
- @@ -246,6 +257,8 @@ export const WithSlotOutsideIframe = ( args ) => { style={ { width: '100%', height: '400px', + border: '0', + outline: '1px solid purple', } } >
{ const iframeRect = frameElement.getBoundingClientRect(); return { x: iframeRect.left, y: iframeRect.top }; }; + +export const getReferenceOwnerDocument = ( { + // @ts-ignore + anchorRef, + // @ts-ignore + anchorRect, + // @ts-ignore + getAnchorRect, + // @ts-ignore + fallbackReferenceElement, + // @ts-ignore + fallbackDocument, +} ) => { + // In floating-ui's terms: + // - "reference" refers to the popover's anchor element. + // - "floating" refers the floating popover's element. + // A floating element can also be positioned relative to a virtual element, + // instead of a real one. A virtual element is represented by an object + // with the `getBoundingClientRect()` function (like real elements). + // See https://floating-ui.com/docs/virtual-elements for more info. + let resultingReferenceOwnerDoc; + if ( anchorRef?.top ) { + resultingReferenceOwnerDoc = anchorRef?.top.ownerDocument; + } else if ( anchorRef?.startContainer ) { + resultingReferenceOwnerDoc = anchorRef.startContainer.ownerDocument; + } else if ( anchorRef?.current ) { + resultingReferenceOwnerDoc = anchorRef.current.ownerDocument; + } else if ( anchorRef ) { + // This one should be deprecated. + resultingReferenceOwnerDoc = anchorRef.ownerDocument; + } else if ( anchorRect && anchorRect?.ownerDocument ) { + resultingReferenceOwnerDoc = anchorRect.ownerDocument; + } else if ( getAnchorRect ) { + resultingReferenceOwnerDoc = getAnchorRect( + fallbackReferenceElement + )?.ownerDocument; + } + + return resultingReferenceOwnerDoc ?? fallbackDocument; +}; + +export const getReferenceElement = ( { + // @ts-ignore + anchorRef, + // @ts-ignore + anchorRect, + // @ts-ignore + getAnchorRect, + // @ts-ignore + fallbackReferenceElement, +} ) => { + /** @type {import('@floating-ui/react-dom').ReferenceType | undefined} */ + let referenceElement; + + if ( anchorRef?.top ) { + // Create a virtual element for the ref. The expectation is that + // if anchorRef.top is defined, then anchorRef.bottom is defined too. + // Seems to be used by the block toolbar, when multiple blocks are selected + // (top and bottom blocks are used to calculate the resulting rect). + referenceElement = { + getBoundingClientRect() { + const topRect = anchorRef.top.getBoundingClientRect(); + const bottomRect = anchorRef.bottom.getBoundingClientRect(); + return new window.DOMRect( + topRect.x, + topRect.y, + topRect.width, + bottomRect.bottom - topRect.top + ); + }, + }; + } else if ( anchorRef?.current ) { + // Standard React ref. + referenceElement = anchorRef.current; + } else if ( anchorRef ) { + // If `anchorRef` holds directly the element's value (no `current` key) + // This is a weird scenario and should be deprecated. + referenceElement = anchorRef; + } else if ( anchorRect ) { + // Create a virtual element for the ref. + referenceElement = { + getBoundingClientRect() { + return anchorRect; + }, + }; + } else if ( getAnchorRect ) { + // Create a virtual element for the ref. + referenceElement = { + getBoundingClientRect() { + const rect = getAnchorRect( fallbackReferenceElement ); + return new window.DOMRect( + rect.x ?? rect.left, + rect.y ?? rect.top, + rect.width ?? rect.right - rect.left, + rect.height ?? rect.bottom - rect.top + ); + }, + }; + } else if ( fallbackReferenceElement ) { + // If no explicit ref is passed via props, fall back to + // anchoring to the popover's parent node. + referenceElement = fallbackReferenceElement.parentNode; + } + + return referenceElement; +};