Commit 2f46f7a092f for woocommerce
commit 2f46f7a092f67dc978730df1e7b2b52f613b2042
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date: Wed Oct 21 09:38:10 2026 +0100
Fix Checkout order summary extension SlotFills (#68940)
* Fix Checkout order summary extension SlotFills
* Add changelog entry for Checkout SlotFill fix
* Move open Checkout summary near actions on scroll
* Connect Checkout summary before mounting portal content
* Preserve scroll position when moving Checkout summary
* Consolidate Checkout order summary tests
* Use parameterized Checkout summary tests
* Keep existing Checkout summary tests unchanged
* Clarify Checkout summary portal host naming
* Align Checkout actions anchor naming
* Avoid redundant Checkout summary DOM moves
diff --git a/plugins/woocommerce/changelog/fix-checkout-order-summary-slotfills b/plugins/woocommerce/changelog/fix-checkout-order-summary-slotfills
new file mode 100644
index 00000000000..f555b9cb51d
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-checkout-order-summary-slotfills
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep extension SlotFills mounted when repositioning the Checkout order summary.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/frontend.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/frontend.tsx
index c0be0bac6f0..36d2ed63704 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/frontend.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/frontend.tsx
@@ -6,6 +6,13 @@ import { getCurrencyFromPriceResponse } from '@woocommerce/price-format';
import { useStoreCart } from '@woocommerce/base-context/hooks';
import { __ } from '@wordpress/i18n';
import { Icon, chevronDown, chevronUp } from '@wordpress/icons';
+import {
+ createPortal,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from '@wordpress/element';
import clsx from 'clsx';
import { FormattedMonetaryAmount } from '@woocommerce/blocks-components';
/**
@@ -23,19 +30,125 @@ const FrontendBlock = ( {
className?: string;
} ): JSX.Element | null => {
const { cartTotals } = useStoreCart();
- const { isOpen, isLarge, ariaControlsId, toggleProps } =
- useOrderSummaryToggle();
+ const {
+ isOpen,
+ hasContainerWidth,
+ isLarge,
+ closeSummary,
+ ariaControlsId,
+ toggleProps,
+ } = useOrderSummaryToggle();
+ const [ titleElement, setTitleElement ] = useState< HTMLDivElement | null >(
+ null
+ );
+ // The stable content container moves between these hosts without remounting its portal children.
+ const [ inlineHost, setInlineHost ] = useState< HTMLDivElement | null >(
+ null
+ );
+ const [ checkoutActionsHost, setCheckoutActionsHost ] =
+ useState< HTMLDivElement | null >( null );
+ // This wrapper is observed to detect proximity and preserve scroll position when the container moves.
+ const [ checkoutActionsAnchor, setCheckoutActionsAnchor ] =
+ useState< HTMLDivElement | null >( null );
+ const [ isContentReady, setIsContentReady ] = useState( false );
+ const checkoutActionsTopBeforeMove = useRef< number | null >( null );
+ const [ contentContainer ] = useState( () => {
+ const container = document.createElement( 'div' );
+ container.className =
+ 'wc-block-components-checkout-order-summary__content-container';
+ return container;
+ } );
const totalsCurrency = getCurrencyFromPriceResponse( cartTotals );
const totalPrice = parseInt( cartTotals.total_price, 10 );
+ const showInline = ! hasContainerWidth || isLarge || isOpen;
+
+ useEffect( () => {
+ if (
+ ! hasContainerWidth ||
+ isLarge ||
+ ! isOpen ||
+ ! titleElement ||
+ ! checkoutActionsAnchor ||
+ typeof window.IntersectionObserver !== 'function'
+ ) {
+ return;
+ }
+
+ let isTitleVisible = true;
+ let areCheckoutActionsNear = false;
+ const observer = new window.IntersectionObserver(
+ ( entries ) => {
+ entries.forEach( ( entry ) => {
+ if ( entry.target === titleElement ) {
+ isTitleVisible = entry.isIntersecting;
+ } else if ( entry.target === checkoutActionsAnchor ) {
+ areCheckoutActionsNear = entry.isIntersecting;
+ }
+ } );
+
+ if ( ! isTitleVisible && areCheckoutActionsNear ) {
+ checkoutActionsTopBeforeMove.current =
+ checkoutActionsAnchor.getBoundingClientRect().top;
+ closeSummary();
+ }
+ },
+ { rootMargin: '0px 0px 25% 0px' }
+ );
+
+ observer.observe( titleElement );
+ observer.observe( checkoutActionsAnchor );
+
+ return () => observer.disconnect();
+ }, [
+ checkoutActionsAnchor,
+ closeSummary,
+ hasContainerWidth,
+ isLarge,
+ isOpen,
+ titleElement,
+ ] );
+
+ // Attach the empty container before mounting portal children. Later moves
+ // preserve the same mounted extension component instances.
+ useLayoutEffect( () => {
+ const destination = showInline ? inlineHost : checkoutActionsHost;
+
+ if ( destination ) {
+ if ( contentContainer.parentElement !== destination ) {
+ destination.appendChild( contentContainer );
+ }
+ setIsContentReady( true );
+
+ if (
+ ! showInline &&
+ checkoutActionsAnchor &&
+ checkoutActionsTopBeforeMove.current !== null
+ ) {
+ const offset =
+ checkoutActionsAnchor.getBoundingClientRect().top -
+ checkoutActionsTopBeforeMove.current;
+ checkoutActionsTopBeforeMove.current = null;
+ window.scrollBy( 0, offset );
+ }
+ }
+ }, [
+ checkoutActionsAnchor,
+ checkoutActionsHost,
+ contentContainer,
+ inlineHost,
+ showInline,
+ ] );
+
+ useLayoutEffect( () => {
+ return () => contentContainer.remove();
+ }, [ contentContainer ] );
- // Render the summary once here in the block and once in the fill, so the
- // fill can be slotted elsewhere. Below the large breakpoint both render and
- // the CSS decides which one is visible.
return (
<>
<div className={ className }>
<div
+ ref={ setTitleElement }
className={ clsx(
'wc-block-components-checkout-order-summary__title',
{
@@ -61,6 +174,7 @@ const FrontendBlock = ( {
</span>
</div>
<div
+ ref={ setInlineHost }
className={ clsx(
'wc-block-components-checkout-order-summary__content',
{
@@ -68,41 +182,43 @@ const FrontendBlock = ( {
}
) }
id={ ariaControlsId }
- >
- { children }
- <div className="wc-block-components-totals-wrapper">
- <TotalsFooterItem
- currency={ totalsCurrency }
- values={ cartTotals }
- />
- </div>
- <OrderMetaSlotFill />
- </div>
+ />
</div>
- { /* Render a second instance of the order summary in a different location for smaller screens.
- On large containers the CSS hides this fill, so rendering it while the
- width is still unknown is safe. */ }
- { ! isLarge && (
- <CheckoutOrderSummaryFill>
+ <CheckoutOrderSummaryFill>
+ <div
+ ref={ setCheckoutActionsAnchor }
+ aria-hidden={ showInline }
+ className={ clsx(
+ className,
+ 'checkout-order-summary-block-fill-wrapper',
+ {
+ 'is-content-inline': showInline,
+ }
+ ) }
+ >
+ <FormStepHeading>
+ <>{ __( 'Order summary', 'woocommerce' ) }</>
+ </FormStepHeading>
<div
- className={ `${ className } checkout-order-summary-block-fill-wrapper` }
- >
- <FormStepHeading>
- <>{ __( 'Order summary', 'woocommerce' ) }</>
- </FormStepHeading>
- <div className="checkout-order-summary-block-fill">
- { children }
- <div className="wc-block-components-totals-wrapper">
- <TotalsFooterItem
- currency={ totalsCurrency }
- values={ cartTotals }
- />
- </div>
- <OrderMetaSlotFill />
+ ref={ setCheckoutActionsHost }
+ className="checkout-order-summary-block-fill"
+ />
+ </div>
+ </CheckoutOrderSummaryFill>
+ { isContentReady &&
+ createPortal(
+ <>
+ { children }
+ <div className="wc-block-components-totals-wrapper">
+ <TotalsFooterItem
+ currency={ totalsCurrency }
+ values={ cartTotals }
+ />
</div>
- </div>
- </CheckoutOrderSummaryFill>
- ) }
+ <OrderMetaSlotFill />
+ </>,
+ contentContainer
+ ) }
</>
);
};
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/style.scss b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/style.scss
index 783f9770169..16a2f35250a 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/style.scss
@@ -1,3 +1,7 @@
+.wc-block-components-checkout-order-summary__content-container {
+ display: contents;
+}
+
.wp-block-woocommerce-checkout-order-summary-block {
border: 1px solid $universal-border-light;
border-radius: 5px;
@@ -94,6 +98,13 @@
&.checkout-order-summary-block-fill-wrapper {
padding-bottom: $gap-larger;
+
+ &.is-content-inline {
+ height: 1px;
+ overflow: hidden;
+ padding-bottom: 0;
+ visibility: hidden;
+ }
}
.wc-block-components-checkout-order-summary__title {
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/test/collapsible-bar.js b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/test/collapsible-bar.js
index 15df12fadd9..25f7b9c5f07 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/test/collapsible-bar.js
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/test/collapsible-bar.js
@@ -1,8 +1,19 @@
/**
* External dependencies
*/
-import { render, screen, fireEvent, createEvent } from '@testing-library/react';
-import { SlotFillProvider } from '@woocommerce/blocks-checkout';
+import {
+ act,
+ render,
+ screen,
+ fireEvent,
+ createEvent,
+} from '@testing-library/react';
+import {
+ ExperimentalDiscountsMeta,
+ ExperimentalOrderMeta,
+ SlotFillProvider,
+} from '@woocommerce/blocks-checkout';
+import { useLayoutEffect, useRef } from '@wordpress/element';
/**
* Internal dependencies
@@ -166,10 +177,9 @@ describe( 'Checkout Order Summary collapsible bar', () => {
} );
} );
-describe( 'Checkout Order Summary fill', () => {
+describe( 'Checkout Order Summary placement', () => {
/**
- * Renders the block together with the slot the fill targets, so the second
- * summary instance actually appears in the tree.
+ * Renders the block together with the action-area slot.
*
* @param {string} containerClassName Class name for the measured width.
* @return {import('@testing-library/react').RenderResult} The render result.
@@ -181,36 +191,221 @@ describe( 'Checkout Order Summary fill', () => {
return render(
<SlotFillProvider>
<SummaryBlock>
- <div />
+ <div data-testid="summary-content" />
</SummaryBlock>
<CheckoutOrderSummarySlot />
</SlotFillProvider>
);
};
- /**
- * @param {HTMLElement} container Render container.
- * @return {NodeList} The rendered fill wrappers.
- */
- const fillsIn = ( container ) =>
- container.querySelectorAll(
- '.checkout-order-summary-block-fill-wrapper'
+ it( 'connects extension content before its first layout effect', () => {
+ const firstLayout = jest.fn();
+ const ExtensionContent = () => {
+ const element = useRef( null );
+
+ useLayoutEffect( () => {
+ firstLayout( {
+ isConnected: element.current.isConnected,
+ isInline: Boolean(
+ element.current.closest(
+ '.wc-block-components-checkout-order-summary__content'
+ )
+ ),
+ } );
+ }, [] );
+
+ return (
+ <div ref={ element } data-testid="layout-effect-extension" />
+ );
+ };
+
+ baseContext.useContainerWidthContext.mockReturnValue(
+ containerWidthOf( '' )
+ );
+ render(
+ <SlotFillProvider>
+ <ExperimentalOrderMeta>
+ <ExtensionContent />
+ </ExperimentalOrderMeta>
+ <SummaryBlock>
+ <div />
+ </SummaryBlock>
+ <CheckoutOrderSummarySlot />
+ </SlotFillProvider>
);
- describe.each( [ '', 'is-mobile', 'is-small', 'is-medium' ] )(
- 'when the container reports %p',
- ( containerClassName ) => {
- it( 'renders the second summary into the slot', () => {
- const { container } = renderWithSlot( containerClassName );
+ expect( screen.getByTestId( 'layout-effect-extension' ) ).toBeVisible();
+ expect( firstLayout ).toHaveBeenCalledTimes( 1 );
+ expect( firstLayout ).toHaveBeenCalledWith( {
+ isConnected: true,
+ isInline: true,
+ } );
+ } );
- expect( fillsIn( container ) ).toHaveLength( 1 );
- } );
+ it.each( [
+ [ 'inline before measurement', '', 'inline' ],
+ [ 'in the action area at mobile width', 'is-mobile', 'action' ],
+ [ 'in the action area at small width', 'is-small', 'action' ],
+ [ 'in the action area at medium width', 'is-medium', 'action' ],
+ [ 'inline at large width', 'is-large', 'inline' ],
+ ] )(
+ 'places the summary %s',
+ ( description, containerClassName, hostLocation ) => {
+ const { container } = renderWithSlot( containerClassName );
+ const host = container.querySelector(
+ hostLocation === 'inline'
+ ? '.wc-block-components-checkout-order-summary__content'
+ : '.checkout-order-summary-block-fill'
+ );
+
+ expect( host ).toContainElement(
+ screen.getByTestId( 'summary-content' )
+ );
}
);
- it( 'renders no fill once the container is large enough for two columns', () => {
- const { container } = renderWithSlot( 'is-large' );
+ it( 'moves an open summary to the action area as it approaches the viewport', () => {
+ let observerCallback;
+ const disconnect = jest.fn();
+ const intersectionObserverSpy = jest
+ .spyOn( window, 'IntersectionObserver' )
+ .mockImplementation( ( callback ) => {
+ observerCallback = callback;
+ return {
+ observe: jest.fn(),
+ disconnect,
+ };
+ } );
+
+ const { container, unmount } = renderWithSlot( 'is-medium' );
+ const title = screen.getByRole( 'button', {
+ name: /Order summary/,
+ } );
+ const inlineSummary = container.querySelector(
+ '.wc-block-components-checkout-order-summary__content'
+ );
+ const checkoutActionsHost = container.querySelector(
+ '.checkout-order-summary-block-fill'
+ );
+ const checkoutActionsAnchor = container.querySelector(
+ '.checkout-order-summary-block-fill-wrapper'
+ );
+ const summaryContent = screen.getByTestId( 'summary-content' );
+ const checkoutActionsRectSpy = jest
+ .spyOn( checkoutActionsAnchor, 'getBoundingClientRect' )
+ .mockReturnValueOnce( { top: 900 } )
+ .mockReturnValueOnce( { top: 400 } );
+ const scrollBySpy = jest
+ .spyOn( window, 'scrollBy' )
+ .mockImplementation( () => {} );
+
+ fireEvent.click( title );
+ expect( inlineSummary ).toContainElement( summaryContent );
+
+ act( () => {
+ observerCallback( [
+ { target: title, isIntersecting: false },
+ { target: checkoutActionsAnchor, isIntersecting: true },
+ ] );
+ } );
- expect( fillsIn( container ) ).toHaveLength( 0 );
+ expect( title ).toHaveAttribute( 'aria-expanded', 'false' );
+ expect( checkoutActionsHost ).toContainElement( summaryContent );
+ expect( checkoutActionsAnchor ).toHaveAttribute(
+ 'aria-hidden',
+ 'false'
+ );
+ expect( scrollBySpy ).toHaveBeenCalledWith( 0, -500 );
+
+ unmount();
+ expect( disconnect ).toHaveBeenCalled();
+ checkoutActionsRectSpy.mockRestore();
+ scrollBySpy.mockRestore();
+ intersectionObserverSpy.mockRestore();
+ } );
+
+ it( 'keeps public extension fills mounted once while the summary moves', () => {
+ let containerClassName = '';
+ baseContext.useContainerWidthContext.mockImplementation( () =>
+ containerWidthOf( containerClassName )
+ );
+
+ const ExtensionContent = ( { testId } ) => (
+ <div data-testid={ testId } />
+ );
+ const tree = () => (
+ <SlotFillProvider>
+ <ExperimentalDiscountsMeta>
+ <ExtensionContent testId="discount-extension" />
+ </ExperimentalDiscountsMeta>
+ <ExperimentalOrderMeta>
+ <ExtensionContent testId="order-extension" />
+ </ExperimentalOrderMeta>
+ <SummaryBlock>
+ <ExperimentalDiscountsMeta.Slot
+ extensions={ {} }
+ cart={ {} }
+ context="woocommerce/checkout"
+ />
+ </SummaryBlock>
+ <CheckoutOrderSummarySlot />
+ </SlotFillProvider>
+ );
+ const { container, rerender } = render( tree() );
+ const discountExtension = screen.getByTestId( 'discount-extension' );
+ const orderExtension = screen.getByTestId( 'order-extension' );
+ const inlineSummary = container.querySelector(
+ '.wc-block-components-checkout-order-summary__content'
+ );
+
+ expect( inlineSummary ).toContainElement( discountExtension );
+ expect( inlineSummary ).toContainElement( orderExtension );
+
+ containerClassName = 'is-large';
+ rerender( tree() );
+
+ expect( screen.getAllByTestId( 'discount-extension' ) ).toHaveLength(
+ 1
+ );
+ expect( screen.getAllByTestId( 'order-extension' ) ).toHaveLength( 1 );
+ expect( screen.getByTestId( 'discount-extension' ) ).toBe(
+ discountExtension
+ );
+ expect( screen.getByTestId( 'order-extension' ) ).toBe(
+ orderExtension
+ );
+
+ containerClassName = 'is-medium';
+ rerender( tree() );
+
+ const checkoutActionsHost = container.querySelector(
+ '.checkout-order-summary-block-fill'
+ );
+ expect( checkoutActionsHost ).toContainElement( discountExtension );
+ expect( checkoutActionsHost ).toContainElement( orderExtension );
+
+ fireEvent.click(
+ screen.getByRole( 'button', { name: /Order summary/ } )
+ );
+
+ expect( inlineSummary ).toContainElement( discountExtension );
+ expect( inlineSummary ).toContainElement( orderExtension );
+ expect( screen.getAllByTestId( 'discount-extension' ) ).toHaveLength(
+ 1
+ );
+ expect( screen.getAllByTestId( 'order-extension' ) ).toHaveLength( 1 );
+
+ fireEvent.click(
+ screen.getByRole( 'button', { name: /Order summary/ } )
+ );
+
+ expect( checkoutActionsHost ).toContainElement( discountExtension );
+ expect( checkoutActionsHost ).toContainElement( orderExtension );
+ expect( screen.getByTestId( 'discount-extension' ) ).toBe(
+ discountExtension
+ );
+ expect( screen.getByTestId( 'order-extension' ) ).toBe(
+ orderExtension
+ );
} );
} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/use-order-summary-toggle.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/use-order-summary-toggle.ts
index bf9fc38c6a3..96985d234a6 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/use-order-summary-toggle.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-block/use-order-summary-toggle.ts
@@ -2,12 +2,14 @@
* External dependencies
*/
import { useContainerWidthContext } from '@woocommerce/base-context';
-import { useId, useState } from '@wordpress/element';
+import { useCallback, useId, useState } from '@wordpress/element';
import type { HTMLAttributes, KeyboardEvent } from 'react';
export type OrderSummaryToggle = {
isOpen: boolean;
+ hasContainerWidth: boolean;
isLarge: boolean;
+ closeSummary: () => void;
ariaControlsId: string;
toggleProps: HTMLAttributes< HTMLDivElement >;
};
@@ -26,9 +28,10 @@ export type OrderSummaryToggle = {
* controls, and the props to spread on the bar.
*/
export const useOrderSummaryToggle = (): OrderSummaryToggle => {
- const { isLarge } = useContainerWidthContext();
+ const { hasContainerWidth, isLarge } = useContainerWidthContext();
const [ isOpen, setIsOpen ] = useState( false );
const ariaControlsId = useId();
+ const closeSummary = useCallback( () => setIsOpen( false ), [] );
const toggleProps = ! isLarge
? {
@@ -49,5 +52,12 @@ export const useOrderSummaryToggle = (): OrderSummaryToggle => {
}
: {};
- return { isOpen, isLarge, ariaControlsId, toggleProps };
+ return {
+ isOpen,
+ hasContainerWidth,
+ isLarge,
+ closeSummary,
+ ariaControlsId,
+ toggleProps,
+ };
};