Commit aea0dcd2488 for woocommerce

commit aea0dcd2488f2abc3aff97144bc99eb70d5477c7
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date:   Fri Jun 26 12:09:05 2026 +0100

    Fix Settings UI save-and-continue navigation (#65910)

diff --git a/packages/js/settings-ui/changelog/fix-save-and-continue-navigation b/packages/js/settings-ui/changelog/fix-save-and-continue-navigation
new file mode 100644
index 00000000000..7fc048a34da
--- /dev/null
+++ b/packages/js/settings-ui/changelog/fix-save-and-continue-navigation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve Settings UI pending navigation after form-post saves.
diff --git a/packages/js/settings-ui/src/settings-ui-page.tsx b/packages/js/settings-ui/src/settings-ui-page.tsx
index 09d2066520c..a3878323a9d 100644
--- a/packages/js/settings-ui/src/settings-ui-page.tsx
+++ b/packages/js/settings-ui/src/settings-ui-page.tsx
@@ -49,6 +49,8 @@ type PendingNavigation = {
 	href: string;
 };

+const FORM_POST_REDIRECT_INPUT_NAME = 'wc_settings_ui_redirect_to';
+
 const normalizeSection = ( section?: string ) =>
 	section === 'default' ? '' : section;

@@ -117,6 +119,21 @@ const clearLegacyFormPrompt = () => {
 	window.onbeforeunload = null;
 };

+const setFormPostRedirectInput = ( form: HTMLFormElement, href: string ) => {
+	let redirectInput = form.querySelector< HTMLInputElement >(
+		`input[name="${ FORM_POST_REDIRECT_INPUT_NAME }"]`
+	);
+
+	if ( ! redirectInput ) {
+		redirectInput = document.createElement( 'input' );
+		redirectInput.type = 'hidden';
+		redirectInput.name = FORM_POST_REDIRECT_INPUT_NAME;
+		form.appendChild( redirectInput );
+	}
+
+	redirectInput.value = href;
+};
+
 const shouldPromptForNavigation = ( event: MouseEvent ) => {
 	if (
 		event.defaultPrevented ||
@@ -562,27 +579,31 @@ export const SettingsUIPage = ( {
 		clearLegacyFormPrompt();
 	}, [] );

-	const submitSettingsForm = useCallback( () => {
-		const form = document.getElementById( 'mainform' );
+	const submitSettingsForm = useCallback(
+		( redirectTo?: string ) => {
+			const form = document.getElementById( 'mainform' );

-		if ( ! ( form instanceof HTMLFormElement ) ) {
-			return;
-		}
+			if ( ! ( form instanceof HTMLFormElement ) ) {
+				return;
+			}

-		allowNavigation();
+			if ( typeof redirectTo === 'string' && redirectTo ) {
+				setFormPostRedirectInput( form, redirectTo );
+			}

-		const saveButton = document.querySelector( '.woocommerce-save-button' );
+			allowNavigation();

-		if (
-			saveButton instanceof HTMLButtonElement &&
-			saveButton.form === form
-		) {
-			form.requestSubmit( saveButton );
-			return;
-		}
+			const saveButton = form.querySelector( '.woocommerce-save-button' );

-		form.requestSubmit();
-	}, [ allowNavigation ] );
+			if ( saveButton instanceof HTMLButtonElement ) {
+				form.requestSubmit( saveButton );
+				return;
+			}
+
+			form.requestSubmit();
+		},
+		[ allowNavigation ]
+	);

 	const setValues = useCallback(
 		( nextValues: Partial< SettingsValues > ) => {
@@ -736,7 +757,7 @@ export const SettingsUIPage = ( {
 		}

 		if ( saveStrategy.adapter === 'form_post' ) {
-			submitSettingsForm();
+			submitSettingsForm( pendingNavigation.href );
 			return;
 		}

diff --git a/packages/js/settings-ui/src/test/html-rendering.test.tsx b/packages/js/settings-ui/src/test/html-rendering.test.tsx
index 12ae729731c..433c8def161 100644
--- a/packages/js/settings-ui/src/test/html-rendering.test.tsx
+++ b/packages/js/settings-ui/src/test/html-rendering.test.tsx
@@ -8,12 +8,19 @@ import type { ReactNode } from 'react';

 jest.mock( '@wordpress/admin-ui', () => ( {
 	Page: ( {
+		actions,
 		children,
 		className,
 	}: {
+		actions?: ReactNode;
 		children: ReactNode;
 		className?: string;
-	} ) => <div className={ className }>{ children }</div>,
+	} ) => (
+		<div className={ className }>
+			{ actions }
+			{ children }
+		</div>
+	),
 } ) );

 /**
@@ -40,6 +47,54 @@ const renderElement = ( element: JSX.Element ) => {
 	return { container, root };
 };

+const renderElementInMainForm = ( element: JSX.Element ) => {
+	const form = document.createElement( 'form' );
+	form.id = 'mainform';
+	document.body.appendChild( form );
+
+	const container = document.createElement( 'div' );
+	form.appendChild( container );
+	const root = createRoot( container );
+
+	act( () => {
+		root.render( element );
+	} );
+
+	return { container, form, root };
+};
+
+const changeTextInput = ( input: HTMLInputElement, value: string ) => {
+	const valueSetter = Object.getOwnPropertyDescriptor(
+		HTMLInputElement.prototype,
+		'value'
+	)?.set;
+
+	if ( ! valueSetter ) {
+		throw new Error( 'Expected HTMLInputElement value setter.' );
+	}
+
+	valueSetter.call( input, value );
+	input.dispatchEvent(
+		new Event( 'input', { bubbles: true, cancelable: true } )
+	);
+};
+
+const getUnsavedChangesActionButton = ( label: string ): HTMLButtonElement => {
+	const button = Array.from(
+		document.body.querySelectorAll< HTMLButtonElement >(
+			'.wc-settings-ui__unsaved-changes-actions button'
+		)
+	).find( ( candidate ) => candidate.textContent?.trim() === label );
+
+	if ( ! ( button instanceof HTMLButtonElement ) ) {
+		throw new Error(
+			`Expected unsaved changes action button "${ label }".`
+		);
+	}
+
+	return button;
+};
+
 const expectUnsafeMarkupRemoved = ( container: HTMLElement ) => {
 	expect( container.querySelector( 'strong' )?.textContent ).toBe( 'Safe' );
 	expect( container.querySelector( 'script' ) ).toBeNull();
@@ -302,6 +357,98 @@ describe( 'settings HTML rendering', () => {
 		container.remove();
 	} );

+	it( 'submits form-post saves with the pending destination', () => {
+		const requestSubmit = jest
+			.spyOn( HTMLFormElement.prototype, 'requestSubmit' )
+			.mockImplementation( () => undefined );
+
+		const schema: SettingsUISchema = {
+			id: 'test-page',
+			title: 'Test page',
+			section: 'default',
+			save: { adapter: 'form_post' },
+			shell: {
+				navigation: [
+					{
+						id: 'next-page',
+						label: 'Next page',
+						href: 'https://example.com/next',
+					},
+				],
+			},
+			groups: {
+				general: {
+					id: 'general',
+					fields: [
+						{
+							id: 'test_field',
+							label: 'Test field',
+							type: 'text',
+							value: 'Initial value',
+						},
+					],
+				},
+			},
+		};
+
+		const { container, form, root } = renderElementInMainForm(
+			<SettingsUIPage schema={ schema } />
+		);
+
+		try {
+			const input = container.querySelector( 'input[type="text"]' );
+			const link = container.querySelector(
+				'a[href="https://example.com/next"]'
+			);
+
+			expect( input ).toBeInstanceOf( HTMLInputElement );
+			expect( link ).not.toBeNull();
+
+			act( () => {
+				changeTextInput( input as HTMLInputElement, 'Changed value' );
+			} );
+
+			act( () => {
+				link?.dispatchEvent(
+					new MouseEvent( 'click', {
+						bubbles: true,
+						cancelable: true,
+						button: 0,
+					} )
+				);
+			} );
+
+			const saveButton = getUnsavedChangesActionButton( 'Save' );
+
+			act( () => {
+				saveButton.dispatchEvent(
+					new MouseEvent( 'click', {
+						bubbles: true,
+						cancelable: true,
+						button: 0,
+					} )
+				);
+			} );
+
+			const redirectInput = form.querySelector(
+				'input[name="wc_settings_ui_redirect_to"]'
+			);
+
+			expect( redirectInput ).toBeInstanceOf( HTMLInputElement );
+			expect( redirectInput ).toHaveAttribute(
+				'value',
+				'https://example.com/next'
+			);
+			expect( requestSubmit ).toHaveBeenCalledWith(
+				container.querySelector( '.woocommerce-save-button' )
+			);
+		} finally {
+			act( () => root.unmount() );
+			form.remove();
+			requestSubmit.mockRestore();
+		}
+	} );
+
 	it( 'keeps unload protection when custom save before navigation fails', async () => {
 		const saveHandler = jest
 			.fn()
@@ -376,14 +523,10 @@ describe( 'settings HTML rendering', () => {
 			);
 		} );

-		const saveButton = Array.from(
-			document.body.querySelectorAll(
-				'.wc-settings-ui__unsaved-changes-actions button'
-			)
-		).find( ( button ) => button.textContent === 'Save' );
+		const saveButton = getUnsavedChangesActionButton( 'Save' );

 		await act( async () => {
-			saveButton?.dispatchEvent(
+			saveButton.dispatchEvent(
 				new MouseEvent( 'click', {
 					bubbles: true,
 					cancelable: true,
diff --git a/plugins/woocommerce/changelog/fix-settings-ui-save-and-continue b/plugins/woocommerce/changelog/fix-settings-ui-save-and-continue
new file mode 100644
index 00000000000..7fc048a34da
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-settings-ui-save-and-continue
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve Settings UI pending navigation after form-post saves.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-settings.php b/plugins/woocommerce/includes/admin/class-wc-admin-settings.php
index 0d7a646fcf6..d6722ccab75 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-settings.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-settings.php
@@ -106,6 +106,33 @@ if ( ! class_exists( 'WC_Admin_Settings', false ) ) :
 			WC()->query->add_endpoints();

 			do_action( 'woocommerce_settings_saved' );
+
+			self::maybe_redirect_after_settings_ui_save();
+		}
+
+		/**
+		 * Redirect to the requested Settings UI destination after a form-post save.
+		 */
+		private static function maybe_redirect_after_settings_ui_save(): void {
+			// phpcs:ignore WordPress.Security.NonceVerification.Missing -- The settings nonce is verified before this method is called.
+			if ( empty( $_POST['wc_settings_ui_redirect_to'] ) ) {
+				return;
+			}
+
+			$redirect_to = wp_validate_redirect(
+				esc_url_raw(
+					// phpcs:ignore WordPress.Security.NonceVerification.Missing -- The settings nonce is verified before this method is called.
+					wp_unslash( $_POST['wc_settings_ui_redirect_to'] )
+				),
+				''
+			);
+
+			if ( '' === $redirect_to ) {
+				return;
+			}
+
+			wp_safe_redirect( $redirect_to );
+			exit;
 		}

 		/**
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-settings-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-settings-test.php
index 176c01566a2..666ccdf7e47 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-settings-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-settings-test.php
@@ -23,6 +23,9 @@ class WC_Admin_Settings_Test extends WC_Unit_Test_Case {
 			delete_option( $option_name );
 		}
 		$this->option_names_to_clean = array();
+		unset( $_POST['_wpnonce'], $_POST['save'], $_POST['wc_settings_ui_redirect_to'], $_REQUEST['_wpnonce'] );
+		unset( $GLOBALS['current_tab'] );
+		wp_set_current_user( 0 );
 		parent::tearDown();
 	}

@@ -219,4 +222,93 @@ class WC_Admin_Settings_Test extends WC_Unit_Test_Case {

 		$this->assertSame( 'bold text', get_option( $option_name ), 'Text fields should still go through wc_clean' );
 	}
+
+	/**
+	 * @testdox Should redirect to the requested Settings UI destination after saving.
+	 */
+	public function test_save_redirects_to_settings_ui_destination(): void {
+		$redirect_to = admin_url( 'admin.php?page=wc-settings&tab=checkout&section=bacs' );
+		$this->prepare_settings_save_request( $redirect_to );
+
+		$intercept_redirect = function ( string $location ) use ( $redirect_to ): string {
+			$this->assertSame( $redirect_to, $location );
+			throw new RuntimeException( 'wp_redirect intercepted.' );
+		};
+		add_filter( 'wp_redirect', $intercept_redirect );
+
+		try {
+			$this->expectException( RuntimeException::class );
+			$this->expectExceptionMessage( 'wp_redirect intercepted.' );
+
+			WC_Admin_Settings::save();
+		} finally {
+			remove_filter( 'wp_redirect', $intercept_redirect );
+		}
+	}
+
+	/**
+	 * @testdox Should not redirect after a standard settings save without a Settings UI destination.
+	 */
+	public function test_save_does_not_redirect_without_settings_ui_destination(): void {
+		$this->prepare_settings_save_request();
+
+		$redirect_attempted = false;
+		$intercept_redirect = function ( string $location ) use ( &$redirect_attempted ): string {
+			$redirect_attempted = true;
+			throw new RuntimeException( 'Unexpected redirect to ' . esc_url_raw( $location ) . '.' );
+		};
+		add_filter( 'wp_redirect', $intercept_redirect );
+
+		try {
+			WC_Admin_Settings::save();
+		} finally {
+			remove_filter( 'wp_redirect', $intercept_redirect );
+		}
+
+		$this->assertFalse( $redirect_attempted );
+	}
+
+	/**
+	 * @testdox Should ignore unsafe Settings UI redirect destinations after saving.
+	 */
+	public function test_save_ignores_unsafe_settings_ui_destination(): void {
+		$this->prepare_settings_save_request( 'https://example.invalid/wp-admin/admin.php?page=wc-settings' );
+
+		$redirect_attempted = false;
+		$intercept_redirect = function ( string $location ) use ( &$redirect_attempted ): string {
+			$redirect_attempted = true;
+			throw new RuntimeException( 'Unexpected redirect to ' . esc_url_raw( $location ) . '.' );
+		};
+		add_filter( 'wp_redirect', $intercept_redirect );
+
+		try {
+			WC_Admin_Settings::save();
+		} finally {
+			remove_filter( 'wp_redirect', $intercept_redirect );
+		}
+
+		$this->assertFalse( $redirect_attempted );
+	}
+
+	/**
+	 * Prepare globals used by WC_Admin_Settings::save().
+	 *
+	 * @param string|null $redirect_to Requested redirect target, or null to omit the Settings UI redirect field.
+	 */
+	private function prepare_settings_save_request( ?string $redirect_to = null ): void {
+		global $current_tab;
+
+		$current_tab = 'settings_ui_redirect_test';
+		$this->login_as_administrator();
+
+		$nonce = wp_create_nonce( 'woocommerce-settings' );
+
+		$_POST['_wpnonce']    = $nonce;
+		$_POST['save']        = 'Save changes';
+		$_REQUEST['_wpnonce'] = $nonce;
+
+		if ( null !== $redirect_to ) {
+			$_POST['wc_settings_ui_redirect_to'] = $redirect_to;
+		}
+	}
 }