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,
+	};
 };