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§ion=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;
+ }
+ }
}