Commit af9a8756962 for woocommerce

commit af9a8756962dd55f27c86445da92b20f3db04a8b
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Tue Oct 13 14:47:24 2026 +0300

    Let merchants skip setup when the site's REST API fails (#69721)

    * fix(core-profiler): let merchants skip setup when REST requests fail

    When a host breaks WordPress REST routing (for example a missing
    .htaccess rewrite, as on the multisite in #50865), the Core Profiler
    could not be skipped. Any unhandled background request failure put the
    xstate machine into an error state, so the intro never rendered. Even
    when it did, a failed skip write left the loader stuck, and without a
    saved "skipped" flag WooCommerce Home kept redirecting to the wizard.

    Keep the profiler running when background actors fail, require only
    the "skipped" profile write when skipping (location and currency are
    best effort), and leave for Home even if Coming Soon setup fails. When
    the "skipped" write itself fails, navigate to a capability- and
    nonce-protected admin-post handler. admin-post.php is a real file, so
    it works without REST routing. It saves the flag in the site option
    and shows a confirmation page.

    The wizard's country error and the confirmation page point to Site
    Health, which already reports the REST API failure, or to the hosting
    provider for users who cannot open Site Health.

    Refs #52014

    * fix(homescreen): trust the preloaded profile before redirecting to setup

    Home pushes merchants to the setup wizard when the onboarding store
    says setup is neither completed nor skipped. When the REST API is
    broken, the profile request can fail before the preloaded profile is
    hydrated, so the store holds empty defaults. A merchant who has just
    skipped setup is then sent straight back to the wizard on their first
    visit to Home. This was reproduced on a subdomain multisite subsite in
    a cold browser session.

    Only redirect when the profile preloaded with the page also says
    setup is pending. The server renders Home only after setup is
    completed or skipped, and the preloaded value does not depend on REST.

    Refs #52014

    * chore(changelog): add entry for the Core Profiler skip recovery

    Refs #52014

    * fix(core-profiler): keep the Jetpack exit moving when Coming Soon fails

    The skip flow's Home exit already treats Coming Soon setup as best
    effort, but the Jetpack exit still awaited it without a catch. When
    only the Coming Soon request fails, a merchant who picked a plugin
    that needs Jetpack was left on a loader that never finished.

    Move the Jetpack exit into a named actor that ignores a failed Coming
    Soon request, as the Home exit does, and then fetches the Jetpack
    authorization URL. The store pages stay hidden either way, because
    WooCommerce turns on Coming Soon for store pages at install time.

    Refs #52014

    * fix(onboarding): stop the skip confirmation from blaming the REST API

    The fallback confirmation runs whenever the REST write of the skipped
    flag fails. That is usually broken REST routing, but it can also be a
    one-off, such as a dropped connection. The page still told every
    merchant their REST API was broken and sent them to look for a fault.

    State what is known (a REST request failed, so the skip was saved
    another way) and make the Site Health or hosting provider guidance
    conditional on WooCommerce screens continuing to fail.

    Refs #52014

    * fix(onboarding): keep the original summary on the skip confirmation

    The confirmation page's first paragraph goes back to saying the REST
    API did not respond correctly and that some WooCommerce screens may
    not work until it is fixed. That is the likely cause and tells the
    merchant what to expect next.

    The Site Health and hosting provider lines stay conditional on
    WooCommerce screens continuing to fail, so a one-off failure does not
    send the merchant looking for a fault.

    Refs #52014

    * refactor(onboarding): Reuse should_show() for the fallback URL check

    The skip fallback URL was preloaded behind an inline
    is_setup_wizard() || needs_completion() check, which repeats what
    should_show() already does. Calling should_show() keeps the two
    conditions from drifting apart.

    Suggested in review.

    Refs #52014

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * fix(core-profiler): Log the errors the profiler recovers from

    The Core Profiler now recovers from failed REST requests: a catch-all
    handler absorbs unhandled actor errors, the skip flow falls back to an
    admin-post request, and both exits treat Coming Soon setup as best
    effort. Each of those paths dropped the error without a trace, so a
    stuck loader left nothing in the console for support to look at.

    Send every recovered error to console.error with a
    "WooCommerce Core Profiler:" prefix so it can be found in support
    sessions.

    Suggested in review.

    Refs #52014

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * fix(core-profiler): Tell merchants when the skip flow loses their location

    When a merchant skips setup, the profiler saves the skipped flag, the
    store location, and the matching currency. Only the skipped flag is
    required to leave the profiler, so a failed location or currency save
    used to be ignored: picking Germany could still land on Home with
    US:CA and USD saved, and no message. The admin-post fallback for a
    failed skipped flag only saves that flag, so it dropped the location
    silently too. Taxes and prices depend on both settings.

    The skip flow now tracks whether the location and currency saved. When
    they did not, it adds store_location_not_saved=1 to the URL it leaves
    through:

    - WooCommerce Home shows an error snackbar linking to Settings >
      General. It stays until dismissed, and Home drops the flag from the
      URL so a reload does not repeat it.
    - The admin-post fallback page adds a paragraph with the same link.

    Suggested in review.

    Refs #52014

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * test(onboarding): Pin where the fallback page shows the location notice

    The test for the skip fallback page only checked that the "store
    location and currency were not saved" paragraph appeared somewhere on
    the page. Mutation testing showed that moving it, or dropping the
    closing tag of the paragraph before it, still passed.

    Assert the paragraph sits between the summary and the next-step line,
    each in its own <p>, so markup regressions on that page fail the test.

    Refs #52014

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * refactor(core-profiler): Give the store location flag one meaning

    The skip flow carried whether the store location and currency saved
    through two channels with opposite meanings: the actor returned
    storeLocationSaved, the machine context stored storeLocationNotSaved,
    and SkippedFlagNotSavedError carried storeLocationSaved again. Each
    exit negated the value and built the same query arg object itself.

    Use storeLocationNotSaved everywhere, matching the query arg, and build
    the query args in one helper that both the Home exit and the fallback
    redirect call.

    The actor also stopped logging at the first failed save, because
    every() short-circuits. It now loops over all results, so a failed
    currency save is still logged when the location save failed too.

    Raised in an automated review of the PR.

    Refs #52014

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * refactor(core-profiler): Pass the country error help as plain children

    The country error Notice wrapped its message and the new REST API help
    in a keyed array and a Fragment. Notice accepts several children, and
    the other core profiler Notices pass them as plain JSX. The array also
    re-indented the whole existing message.

    Render the message and the help paragraph as direct children of the
    Notice and drop the keys and the Fragment import. The rendered markup
    and the spoken message are unchanged.

    Raised in an automated review of the PR.

    Refs #52014

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * test(core-profiler): Pin each log call for errors the profiler recovers from

    Only the fallback redirect's log call had a test, so deleting the root
    handler's logging, the location and currency logging, or the Coming
    Soon catch logging on either exit left every test green.

    Assert that each of those failures reaches console.error with the
    profiler prefix. The simulated requests reject with "<request> failed",
    so each assertion names the exact failure. The plugins prefetch has no
    error handler of its own, which makes it the check for the root
    handler. Deleting any of the log calls now fails a test, and so does
    stopping at the first failed location or currency save.

    Raised in an automated review of the PR.

    Refs #52014

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * test(core-profiler): Check the Home URL the skip flow lands on

    The skip-flow tests mocked getNewPath to return a constant, so the
    navigated URL was the plain Home path whether or not the store
    location flag was added. The flag was only proven through the mock's
    last call arguments, which tied the test to the helper's call shape
    and call order.

    Use the real getNewPath and assert the final URL instead, as the
    fallback tests already do. Both profiler call sites pass explicit
    arguments, so the real helper needs no history setup. Forcing the flag
    on or off now fails a test.

    Raised in an automated review of the PR.

    Refs #52014

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * test(core-profiler): Cover the no-Site-Health case when countries load

    The healthy-path BusinessLocation test only ran with a Site Health URL
    and only checked that the Site Health link was absent. A change that
    showed the hosting provider help whenever countries load and the user
    cannot open Site Health would have passed.

    Run the healthy-path case with and without a Site Health URL, and
    assert that neither the link nor the hosting provider help appears.

    Raised in an automated review of the PR.

    Refs #52014

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * test(onboarding): Explain the &amp; in the expected settings link

    The handler builds the general settings link with esc_url(), which
    encodes the ampersand as &#038;, but then passes the whole notice
    through wp_kses_post(), which turns it into &amp;. The expected link
    in the test uses &amp; for that reason. Without a note it reads like a
    mismatch with esc_url(), and a review bot proposed "fixing" it, which
    would make the test fail.

    Add a one-line comment that names the wp_kses_post() step.

    Refs #52014

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-core-profiler-skip-fallback b/plugins/woocommerce/changelog/fix-core-profiler-skip-fallback
new file mode 100644
index 00000000000..895e8b7cec4
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-core-profiler-skip-fallback
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Let merchants skip setup when the site's REST API fails, and point them to Site Health to find the cause.
diff --git a/plugins/woocommerce/client/admin/client/core-profiler/index.tsx b/plugins/woocommerce/client/admin/client/core-profiler/index.tsx
index 967f7539581..bd21e0d3371 100644
--- a/plugins/woocommerce/client/admin/client/core-profiler/index.tsx
+++ b/plugins/woocommerce/client/admin/client/core-profiler/index.tsx
@@ -10,6 +10,7 @@ import {
 	assertEvent,
 	enqueueActions,
 	DoneActorEvent,
+	ErrorActorEvent,
 	fromCallback,
 	or,
 } from 'xstate5';
@@ -17,6 +18,7 @@ import { useMachine, useSelector } from '@xstate5/react';
 import { useMemo } from '@wordpress/element';
 import { resolveSelect, dispatch } from '@wordpress/data';
 import { store as coreStore } from '@wordpress/core-data';
+import { addQueryArgs } from '@wordpress/url';
 import {
 	updateQueryString,
 	getQuery,
@@ -46,6 +48,7 @@ import { recordEvent } from '@woocommerce/tracks';
  * Internal dependencies
  */
 import { findComponentMeta } from '~/utils/xstate/find-component';
+import { getAdminSetting } from '~/utils/admin-settings';
 import { initRemoteLogging } from '~/lib/init-remote-logging';
 import { IntroOptIn } from './pages/IntroOptIn';
 import {
@@ -61,6 +64,7 @@ import {
 } from './pages/BusinessInfo';
 import { BusinessLocation } from './pages/BusinessLocation';
 import { getCountryStateOptions } from './services/country';
+import { getStoreLocationNotSavedQuery } from './utils/store-location-not-saved';
 import { CoreProfilerLoader } from './components/loader/Loader';
 import { Plugins } from './pages/Plugins/Plugins';
 import { NoPermissionsError } from './pages/Plugins/NoPermissions';
@@ -131,6 +135,7 @@ export type CoreProfilerStateMachineContext = {
 	jetpackAuthUrl?: string;
 	currentUserEmail: string | undefined;
 	currentUser?: WCUser;
+	storeLocationNotSaved: boolean;
 };

 const getAllowTrackingOption = fromPromise( async () =>
@@ -324,11 +329,91 @@ const handleGeolocation = assign( {

 const redirectToWooHome = raise( { type: 'REDIRECT_TO_WOO_HOME' } );

-const exitToWooHome = fromPromise( async () => {
-	await dispatch( onboardingStore ).coreProfilerCompleted();
-	window.location.href = getNewPath( {}, '/', {} );
+// Errors the profiler recovers from still go to the console, so support has something to look at.
+const logProfilerError = ( error: unknown ) => {
+	// eslint-disable-next-line no-console
+	console.error( 'WooCommerce Core Profiler:', error );
+};
+
+const logActorError = ( { event }: { event: ErrorActorEvent } ) => {
+	logProfilerError( event.error );
+};
+
+const exitToWooHome = fromPromise(
+	async ( {
+		input,
+	}: {
+		input?: Pick<
+			CoreProfilerStateMachineContext,
+			'storeLocationNotSaved'
+		>;
+	} ) => {
+		// Coming Soon setup is best effort; a failed request must not leave the loader stuck.
+		await dispatch( onboardingStore )
+			.coreProfilerCompleted()
+			.catch( logProfilerError );
+		window.location.href = getNewPath(
+			getStoreLocationNotSavedQuery( !! input?.storeLocationNotSaved ),
+			'/',
+			{}
+		);
+	}
+);
+
+const exitToJetpackAuth = fromPromise( async () => {
+	// Coming Soon setup is best effort, as in exitToWooHome.
+	await dispatch( onboardingStore )
+		.coreProfilerCompleted()
+		.catch( logProfilerError );
+	return await resolveSelect( onboardingStore ).getJetpackAuthUrl( {
+		redirectUrl: getAdminLink( 'admin.php?page=wc-admin' ),
+		from: 'woocommerce-core-profiler',
+	} );
 } );

+/**
+ * Thrown when the skip flow cannot save the skipped flag. It also records whether the store location
+ * and currency saved, so the fallback page can tell the merchant to set them.
+ */
+class SkippedFlagNotSavedError extends Error {
+	cause: unknown;
+
+	storeLocationNotSaved: boolean;
+
+	constructor( cause: unknown, storeLocationNotSaved: boolean ) {
+		super( 'The skipped flag for onboarding could not be saved.' );
+		this.cause = cause;
+		this.storeLocationNotSaved = storeLocationNotSaved;
+	}
+}
+
+/**
+ * Saves the skip through a non-REST admin-post request when the REST write fails,
+ * so WooCommerce Home stops redirecting back to the profiler.
+ */
+const redirectToSkipOnboardingFallback = ( {
+	event,
+}: {
+	event: ErrorActorEvent;
+} ) => {
+	logProfilerError( event.error );
+
+	const { skipOnboardingFallbackUrl } = getAdminSetting(
+		'onboarding',
+		{}
+	) as { skipOnboardingFallbackUrl?: unknown };
+	const storeLocationQuery = getStoreLocationNotSavedQuery(
+		event.error instanceof SkippedFlagNotSavedError &&
+			event.error.storeLocationNotSaved
+	);
+
+	window.location.href =
+		typeof skipOnboardingFallbackUrl === 'string' &&
+		skipOnboardingFallbackUrl
+			? addQueryArgs( skipOnboardingFallbackUrl, storeLocationQuery )
+			: getNewPath( storeLocationQuery, '/', {} );
+};
+
 const getPluginNameParam = (
 	pluginsSelected: CoreProfilerStateMachineContext[ 'pluginsSelected' ],
 	availablePlugins: CoreProfilerStateMachineContext[ 'pluginsAvailable' ]
@@ -641,7 +726,27 @@ const skipFlowUpdateBusinessLocation = fromPromise(
 			getCountryCode( context.businessInfo.location ) as string
 		);

-		return Promise.all( [ skipped, businessLocation, currencyUpdate ] );
+		// Only the skipped flag is required to leave the profiler; location and currency are best effort.
+		const [ skippedResult, ...storeLocationResults ] =
+			await Promise.allSettled( [
+				skipped,
+				businessLocation,
+				currencyUpdate,
+			] );
+		let storeLocationNotSaved = false;
+		for ( const result of storeLocationResults ) {
+			if ( result.status === 'rejected' ) {
+				logProfilerError( result.reason );
+				storeLocationNotSaved = true;
+			}
+		}
+		if ( skippedResult.status === 'rejected' ) {
+			throw new SkippedFlagNotSavedError(
+				skippedResult.reason,
+				storeLocationNotSaved
+			);
+		}
+		return { storeLocationNotSaved };
 	}
 );

@@ -658,7 +763,7 @@ export const preFetchActions = {
 	preFetchJetpackAuthUrl,
 };

-const coreProfilerMachineActions = {
+export const coreProfilerMachineActions = {
 	...preFetchActions,
 	...recordTracksActions,
 	handlePlugins,
@@ -678,12 +783,14 @@ const coreProfilerMachineActions = {
 	assignCurrentUserEmail,
 	assignCurrentUser,
 	redirectToWooHome,
+	redirectToSkipOnboardingFallback,
+	logActorError,
 	redirectToJetpackAuthPage,
 	updateLoaderProgressWithPluginInstall,
 	reloadPage,
 };

-const coreProfilerMachineActors = {
+export const coreProfilerMachineActors = {
 	preFetchGetPlugins,
 	getAllowTrackingOption,
 	getStoreNameOption,
@@ -704,13 +811,14 @@ const coreProfilerMachineActors = {
 	skipFlowUpdateBusinessLocation,
 	pluginInstallerMachine,
 	exitToWooHome,
+	exitToJetpackAuth,
 };
 export const coreProfilerStateMachineDefinition = createMachine( {
 	id: 'coreProfiler',
 	initial: 'navigate',
 	types: {} as {
 		context: CoreProfilerStateMachineContext;
-		events: CoreProfilerEvents;
+		events: CoreProfilerEvents | ErrorActorEvent;
 	},
 	invoke: {
 		id: 'browserPopstateHandler',
@@ -723,6 +831,12 @@ export const coreProfilerStateMachineDefinition = createMachine( {
 		REDIRECT_TO_WOO_HOME: {
 			target: '#redirectingToWooHome',
 		},
+		// Background prefetches and writes are spawned without error handlers. Without this,
+		// one failed REST request (for example, when /wp-json/ is not routed) stops the whole profiler.
+		// Invoke-level onError handlers belong to deeper states and still take precedence.
+		'xstate.error.actor.*': {
+			actions: 'logActorError',
+		},
 	},
 	context: {
 		// these are safe default values if for some reason the steps fail to complete correctly
@@ -746,6 +860,7 @@ export const coreProfilerStateMachineDefinition = createMachine( {
 		currentUserEmail: undefined,
 		currentUser: undefined,
 		coreProfilerCompletedSteps: {},
+		storeLocationNotSaved: false,
 	} as CoreProfilerStateMachineContext,
 	states: {
 		navigate: {
@@ -924,7 +1039,9 @@ export const coreProfilerStateMachineDefinition = createMachine( {
 									input: ( {
 										event,
 									}: {
-										event: CoreProfilerEvents;
+										event:
+											| CoreProfilerEvents
+											| ErrorActorEvent;
 									} ) => {
 										assertEvent( event, 'INTRO_SKIPPED' );
 										return event.payload;
@@ -1365,6 +1482,19 @@ export const coreProfilerStateMachineDefinition = createMachine( {
 								src: 'skipFlowUpdateBusinessLocation',
 								onDone: {
 									target: 'progress20',
+									actions: assign( {
+										storeLocationNotSaved: ( {
+											event,
+										}: {
+											event: DoneActorEvent< {
+												storeLocationNotSaved: boolean;
+											} >;
+										} ) =>
+											event.output.storeLocationNotSaved,
+									} ),
+								},
+								onError: {
+									actions: 'redirectToSkipOnboardingFallback',
 								},
 							},
 						},
@@ -1667,24 +1797,18 @@ export const coreProfilerStateMachineDefinition = createMachine( {
 				},
 				sendToJetpackAuthPage: {
 					invoke: {
-						src: fromPromise( async () => {
-							await dispatch(
-								onboardingStore
-							).coreProfilerCompleted();
-							return await resolveSelect(
-								onboardingStore
-							).getJetpackAuthUrl( {
-								redirectUrl: getAdminLink(
-									'admin.php?page=wc-admin'
-								),
-								from: 'woocommerce-core-profiler',
-							} );
-						} ),
+						src: 'exitToJetpackAuth',
 						onDone: {
 							actions: enqueueActions( ( { enqueue, check } ) => {
 								if (
 									check(
-										( { event } ) => event.output.success
+										( {
+											event,
+										}: {
+											event: DoneActorEvent< {
+												success: boolean;
+											} >;
+										} ) => event.output.success
 									)
 								) {
 									enqueue( {
@@ -1775,6 +1899,7 @@ export const coreProfilerStateMachineDefinition = createMachine( {
 			id: 'redirectingToWooHome',
 			invoke: {
 				src: 'exitToWooHome',
+				input: ( { context } ) => context,
 			},
 		},
 	},
diff --git a/plugins/woocommerce/client/admin/client/core-profiler/pages/BusinessLocation.tsx b/plugins/woocommerce/client/admin/client/core-profiler/pages/BusinessLocation.tsx
index b004357c2b7..7da5ddbfa69 100644
--- a/plugins/woocommerce/client/admin/client/core-profiler/pages/BusinessLocation.tsx
+++ b/plugins/woocommerce/client/admin/client/core-profiler/pages/BusinessLocation.tsx
@@ -14,6 +14,7 @@ import { CountryStateOption } from '../services/country';
 import { Heading } from '../components/heading/heading';
 import { Navigation } from '../components/navigation/navigation';
 import { GeolocationCountrySelect } from '../components/geolocation-country-select/geolocation-country-select';
+import { getAdminSetting } from '~/utils/admin-settings';

 export const BusinessLocation = ( {
 	sendEvent,
@@ -33,6 +34,10 @@ export const BusinessLocation = ( {
 	} );

 	const inputLabel = __( 'Select country/region', 'woocommerce' );
+	// Only preloaded for users who can open Site Health.
+	const { siteHealthUrl } = getAdminSetting( 'onboarding', {} ) as {
+		siteHealthUrl?: string;
+	};

 	return (
 		<div
@@ -109,6 +114,27 @@ export const BusinessLocation = ( {
 								),
 							}
 						) }
+						<p>
+							{ siteHealthUrl
+								? createInterpolateElement(
+										__(
+											'If this keeps happening, your site’s REST API may be blocked. <siteHealthLink>Check Site Health</siteHealthLink> for details.',
+											'woocommerce'
+										),
+										{
+											siteHealthLink: (
+												<Button
+													variant="link"
+													href={ siteHealthUrl }
+												/>
+											),
+										}
+									)
+								: __(
+										'If this keeps happening, ask your hosting provider to check that your site’s REST API is working.',
+										'woocommerce'
+									) }
+						</p>
 					</Notice>
 				) }
 				<div className="woocommerce-profiler-button-container woocommerce-profiler-go-to-mystore__button-container">
diff --git a/plugins/woocommerce/client/admin/client/core-profiler/pages/test/business-location.test.tsx b/plugins/woocommerce/client/admin/client/core-profiler/pages/test/business-location.test.tsx
new file mode 100644
index 00000000000..8f4c58c4d6d
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/core-profiler/pages/test/business-location.test.tsx
@@ -0,0 +1,76 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { BusinessLocation } from '../BusinessLocation';
+import { getAdminSetting } from '~/utils/admin-settings';
+
+jest.mock( '~/utils/admin-settings', () => {
+	const onboardingSetting = {};
+	return { getAdminSetting: jest.fn( () => onboardingSetting ) };
+} );
+const mockOnboardingSetting = getAdminSetting( 'onboarding' ) as {
+	siteHealthUrl?: string;
+};
+
+const SITE_HEALTH_URL = 'http://example.test/wp-admin/site-health.php';
+const HOSTING_HELP =
+	'If this keeps happening, ask your hosting provider to check that your site’s REST API is working.';
+
+const renderPage = ( countries: Array< { key: string; label: string } > ) =>
+	render(
+		<BusinessLocation
+			sendEvent={ jest.fn() }
+			navigationProgress={ 80 }
+			context={ { geolocatedLocation: undefined, countries } }
+		/>
+	);
+
+describe( 'BusinessLocation country error help', () => {
+	beforeEach( () => {
+		delete mockOnboardingSetting.siteHealthUrl;
+	} );
+
+	it( 'links to Site Health when the countries cannot be loaded and the user can open it', () => {
+		mockOnboardingSetting.siteHealthUrl = SITE_HEALTH_URL;
+
+		renderPage( [] );
+
+		expect(
+			screen.getByRole( 'link', { name: 'Check Site Health' } )
+		).toHaveAttribute( 'href', SITE_HEALTH_URL );
+		expect( screen.queryByText( HOSTING_HELP ) ).not.toBeInTheDocument();
+	} );
+
+	it( 'points to the hosting provider when the user cannot open Site Health', () => {
+		renderPage( [] );
+
+		expect( screen.getByText( HOSTING_HELP ) ).toBeInTheDocument();
+		expect(
+			screen.queryByRole( 'link', { name: 'Check Site Health' } )
+		).not.toBeInTheDocument();
+	} );
+
+	it.each( [
+		[ 'can', SITE_HEALTH_URL ],
+		[ 'cannot', undefined ],
+	] )(
+		'shows no help when the countries load and the user %s open Site Health',
+		( _, siteHealthUrl ) => {
+			mockOnboardingSetting.siteHealthUrl = siteHealthUrl;
+
+			renderPage( [ { key: 'DE:DE-BE', label: 'Germany — Berlin' } ] );
+
+			expect(
+				screen.queryByRole( 'link', { name: 'Check Site Health' } )
+			).not.toBeInTheDocument();
+			expect(
+				screen.queryByText( HOSTING_HELP )
+			).not.toBeInTheDocument();
+		}
+	);
+} );
diff --git a/plugins/woocommerce/client/admin/client/core-profiler/test/skip-flow-rest-failures.test.ts b/plugins/woocommerce/client/admin/client/core-profiler/test/skip-flow-rest-failures.test.ts
new file mode 100644
index 00000000000..900172bf40b
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/core-profiler/test/skip-flow-rest-failures.test.ts
@@ -0,0 +1,266 @@
+/**
+ * External dependencies
+ */
+import { getQuery } from '@woocommerce/navigation';
+import { createActor, toPromise, waitFor } from 'xstate5';
+
+/**
+ * Internal dependencies
+ */
+import {
+	coreProfilerMachineActions,
+	coreProfilerMachineActors,
+	coreProfilerStateMachineDefinition,
+} from '../index';
+
+jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) );
+
+jest.mock( '@woocommerce/navigation', () => ( {
+	...jest.requireActual( '@woocommerce/navigation' ),
+	getQuery: jest.fn( () => ( {} ) ),
+	updateQueryString: jest.fn(),
+} ) );
+
+const mockOnboardingSetting: { skipOnboardingFallbackUrl?: string } = {};
+jest.mock( '~/utils/admin-settings', () => ( {
+	getAdminSetting: jest.fn( ( name: string, fallback: unknown ) =>
+		name === 'onboarding' ? mockOnboardingSetting : fallback
+	),
+} ) );
+
+// Simulates REST for WooCommerce stores: actions and resolvers listed here reject, all others resolve.
+const mockJetpackAuthResult = {
+	success: true,
+	url: 'https://jetpack.test/authorize',
+};
+const mockFailingRequests = new Set< string >();
+const mockStoreCalls: string[] = [];
+function mockWooStore( resolvedValues: Record< string, unknown > ) {
+	return new Proxy(
+		{},
+		{
+			get: ( _target, name: string ) => () => {
+				mockStoreCalls.push( name );
+				if (
+					! mockFailingRequests.has( name ) &&
+					! mockFailingRequests.has( '*' )
+				) {
+					return Promise.resolve( resolvedValues[ name ] );
+				}
+				// apiFetch rejects with a plain error object, not an Error.
+				const request = Promise.reject( {
+					code: 'rest_no_route',
+					message: `${ name } failed`,
+				} );
+				// The profiler fires some writes without awaiting them; awaiting callers still see the rejection.
+				request.catch( () => undefined );
+				return request;
+			},
+		}
+	);
+}
+jest.mock( '@wordpress/data', () => {
+	const actual = jest.requireActual( '@wordpress/data' );
+	const isWooStore = ( store: string | { name: string } ) =>
+		( typeof store === 'string' ? store : store.name ).startsWith( 'wc/' );
+	return {
+		...actual,
+		dispatch: ( store: { name: string } ) =>
+			isWooStore( store ) ? mockWooStore( {} ) : actual.dispatch( store ),
+		resolveSelect: ( store: { name: string } ) =>
+			isWooStore( store )
+				? mockWooStore( {
+						getCountries: [],
+						getFreeExtensions: [],
+						getCoreProfilerCompletedSteps: {},
+						getJetpackAuthUrl: mockJetpackAuthResult,
+					} )
+				: actual.resolveSelect( store ),
+	};
+} );
+
+const FALLBACK_URL =
+	'http://example.test/wp-admin/admin-post.php?action=woocommerce_skip_onboarding_setup';
+const HOME_PATH = 'admin.php?page=wc-admin';
+
+const startProfiler = () => {
+	const actor = createActor(
+		coreProfilerStateMachineDefinition.provide( {
+			// @ts-expect-error Same action map CoreProfilerController provides.
+			actions: coreProfilerMachineActions,
+			actors: coreProfilerMachineActors,
+			guards: {
+				hasStepInUrl: ( _, params ) =>
+					( getQuery() as { step?: string } ).step ===
+					( params as { step: string } ).step,
+			},
+		} )
+	);
+	actor.start();
+	return actor;
+};
+
+const skipToBusinessLocationAndSubmit = async (
+	actor: ReturnType< typeof startProfiler >
+) => {
+	await waitFor( actor, ( snapshot ) =>
+		snapshot.matches( { introOptIn: 'introOptIn' } )
+	);
+	actor.send( {
+		type: 'INTRO_SKIPPED',
+		payload: { optInDataSharing: false },
+	} );
+	await waitFor( actor, ( snapshot ) =>
+		snapshot.matches( { skipGuidedSetup: 'skipFlowBusinessLocation' } )
+	);
+	actor.send( {
+		type: 'BUSINESS_LOCATION_COMPLETED',
+		payload: { storeLocation: 'DE:DE-BE' },
+	} );
+};
+
+const waitForNavigation = async () => {
+	// The skip flow shows its loader for two 1500 ms steps before leaving.
+	for ( let i = 0; i < 40 && window.location.href === 'about:blank'; i++ ) {
+		await jest.advanceTimersByTimeAsync( 100 );
+	}
+	return window.location.href;
+};
+
+describe( 'Core Profiler skip flow when REST requests fail', () => {
+	const originalLocation = window.location;
+	let consoleError: jest.SpyInstance;
+
+	// Each simulated request rejects with "<request> failed", so this pins which failure was logged.
+	const expectLoggedFailure = ( request: string ) =>
+		expect( consoleError ).toHaveBeenCalledWith(
+			'WooCommerce Core Profiler:',
+			expect.objectContaining( { message: `${ request } failed` } )
+		);
+
+	beforeEach( () => {
+		jest.useFakeTimers();
+		jest.clearAllMocks();
+		// Recovered errors are logged on purpose; keep them out of the test output.
+		consoleError = jest
+			.spyOn( console, 'error' )
+			.mockImplementation( () => undefined );
+		mockFailingRequests.clear();
+		mockStoreCalls.length = 0;
+		mockOnboardingSetting.skipOnboardingFallbackUrl = FALLBACK_URL;
+		// @ts-expect-error Replace jsdom's location so navigation can be observed.
+		delete window.location;
+		// @ts-expect-error Minimal location stub.
+		window.location = { href: 'about:blank' };
+	} );
+
+	afterEach( () => {
+		jest.useRealTimers();
+		jest.restoreAllMocks();
+		// @ts-expect-error Restore jsdom's location.
+		window.location = originalLocation;
+	} );
+
+	it( 'keeps the profiler usable when every REST request fails', async () => {
+		mockFailingRequests.add( '*' );
+		const actor = startProfiler();
+
+		await skipToBusinessLocationAndSubmit( actor );
+
+		expect( await waitForNavigation() ).toBe(
+			`${ FALLBACK_URL }&store_location_not_saved=1`
+		);
+		expect( actor.getSnapshot().status ).toBe( 'active' );
+		expect( mockStoreCalls ).not.toContain( 'coreProfilerCompleted' );
+		expect( consoleError ).toHaveBeenCalledWith(
+			'WooCommerce Core Profiler:',
+			expect.objectContaining( {
+				cause: expect.objectContaining( { code: 'rest_no_route' } ),
+			} )
+		);
+		// The plugins prefetch has no error handler of its own, so only the root handler logs it.
+		expectLoggedFailure( 'getFreeExtensions' );
+		actor.stop();
+	} );
+
+	it( 'leaves the store location flag off the fallback page when only the skipped flag fails', async () => {
+		mockFailingRequests.add( 'updateProfileItems' );
+		const actor = startProfiler();
+
+		await skipToBusinessLocationAndSubmit( actor );
+
+		expect( await waitForNavigation() ).toBe( FALLBACK_URL );
+		actor.stop();
+	} );
+
+	it( 'leaves through Home when the skipped flag saves but location and currency fail', async () => {
+		mockFailingRequests.add( 'saveSetting' );
+		mockFailingRequests.add( 'updateStoreCurrencyAndMeasurementUnits' );
+		const actor = startProfiler();
+
+		await skipToBusinessLocationAndSubmit( actor );
+
+		expect( await waitForNavigation() ).toBe(
+			`${ HOME_PATH }&store_location_not_saved=1`
+		);
+		expect( mockStoreCalls ).toEqual(
+			expect.arrayContaining( [
+				'updateProfileItems',
+				'coreProfilerCompleted',
+			] )
+		);
+		expectLoggedFailure( 'saveSetting' );
+		expectLoggedFailure( 'updateStoreCurrencyAndMeasurementUnits' );
+		actor.stop();
+	} );
+
+	it( 'leaves through Home when Coming Soon setup fails', async () => {
+		mockFailingRequests.add( 'coreProfilerCompleted' );
+		const actor = startProfiler();
+
+		await skipToBusinessLocationAndSubmit( actor );
+
+		expect( await waitForNavigation() ).toBe( HOME_PATH );
+		expectLoggedFailure( 'coreProfilerCompleted' );
+		actor.stop();
+	} );
+
+	it( 'falls back to Home when no fallback URL was preloaded', async () => {
+		mockFailingRequests.add( 'updateProfileItems' );
+		delete mockOnboardingSetting.skipOnboardingFallbackUrl;
+		const actor = startProfiler();
+
+		await skipToBusinessLocationAndSubmit( actor );
+
+		expect( await waitForNavigation() ).toBe( HOME_PATH );
+		expect( mockStoreCalls ).not.toContain( 'coreProfilerCompleted' );
+		actor.stop();
+	} );
+
+	it( 'does not use the fallback when every request succeeds', async () => {
+		const actor = startProfiler();
+
+		await skipToBusinessLocationAndSubmit( actor );
+
+		expect( await waitForNavigation() ).toBe( HOME_PATH );
+		expect( mockStoreCalls ).toContain( 'coreProfilerCompleted' );
+		expect( consoleError ).not.toHaveBeenCalled();
+		actor.stop();
+	} );
+
+	it( 'still fetches the Jetpack auth URL when Coming Soon setup fails', async () => {
+		mockFailingRequests.add( 'coreProfilerCompleted' );
+		const actor = createActor(
+			coreProfilerMachineActors.exitToJetpackAuth
+		).start();
+
+		await expect( toPromise( actor ) ).resolves.toEqual(
+			mockJetpackAuthResult
+		);
+		expect( mockStoreCalls ).toEqual( [
+			'coreProfilerCompleted',
+			'getJetpackAuthUrl',
+		] );
+		expectLoggedFailure( 'coreProfilerCompleted' );
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/core-profiler/utils/store-location-not-saved.ts b/plugins/woocommerce/client/admin/client/core-profiler/utils/store-location-not-saved.ts
new file mode 100644
index 00000000000..35960d9eae3
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/core-profiler/utils/store-location-not-saved.ts
@@ -0,0 +1,17 @@
+/**
+ * Query arg the skip flow adds to WooCommerce Home and the skip fallback page when the store location
+ * and currency could not be saved, so those pages can tell the merchant to set them.
+ */
+export const STORE_LOCATION_NOT_SAVED_QUERY_ARG = 'store_location_not_saved';
+
+/**
+ * Builds the query args that carry the store location flag, or none when the location saved.
+ *
+ * @param storeLocationNotSaved Whether the store location and currency failed to save.
+ */
+export const getStoreLocationNotSavedQuery = (
+	storeLocationNotSaved: boolean
+): Record< string, string > =>
+	storeLocationNotSaved
+		? { [ STORE_LOCATION_NOT_SAVED_QUERY_ARG ]: '1' }
+		: {};
diff --git a/plugins/woocommerce/client/admin/client/homescreen/index.tsx b/plugins/woocommerce/client/admin/client/homescreen/index.tsx
index fc502af17fb..e8c09e04571 100644
--- a/plugins/woocommerce/client/admin/client/homescreen/index.tsx
+++ b/plugins/woocommerce/client/admin/client/homescreen/index.tsx
@@ -2,22 +2,32 @@
  * External dependencies
  */
 import { compose } from '@wordpress/compose';
-import { withSelect } from '@wordpress/data';
+import { useDispatch, withSelect } from '@wordpress/data';
 import type { SelectFunction } from '@wordpress/data/build-types/types';
 import { useEffect } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
 import { onboardingStore, withOnboardingHydration } from '@woocommerce/data';
 import { getHistory, getNewPath, useQuery } from '@woocommerce/navigation';
+import { getAdminLink } from '@woocommerce/settings';

 /**
  * Internal dependencies
  */
 import Layout from './layout';
 import { getAdminSetting } from '~/utils/admin-settings';
+import { STORE_LOCATION_NOT_SAVED_QUERY_ARG } from '~/core-profiler/utils/store-location-not-saved';

 type HomescreenProps = ReturnType< typeof withSelectHandler > & {
 	hasFinishedResolution: boolean;
 };

+const onboardingData = getAdminSetting( 'onboarding', {} );
+
+// The preloaded profile does not depend on the REST API. When the profile request fails
+// before hydration runs, the store only holds empty defaults.
+const isProfilerDoneOnPageLoad = () =>
+	!! ( onboardingData.profile?.completed || onboardingData.profile?.skipped );
+
 const Homescreen = ( {
 	profileItems: {
 		completed: profilerCompleted,
@@ -29,20 +39,54 @@ const Homescreen = ( {
 		if (
 			hasFinishedResolution &&
 			! profilerCompleted &&
-			! profilerSkipped
+			! profilerSkipped &&
+			! isProfilerDoneOnPageLoad()
 		) {
 			getHistory().push( getNewPath( {}, '/setup-wizard', {} ) );
 		}
 	}, [ hasFinishedResolution, profilerCompleted, profilerSkipped ] );

 	const query = useQuery();
+	const { createErrorNotice } = useDispatch( 'core/notices' );
+	const storeLocationNotSaved =
+		!! query[ STORE_LOCATION_NOT_SAVED_QUERY_ARG ];
+
+	useEffect( () => {
+		if ( ! storeLocationNotSaved ) {
+			return;
+		}
+		createErrorNotice(
+			__(
+				'Your store location and currency were not saved. Set them in the general settings, since taxes and prices depend on them.',
+				'woocommerce'
+			),
+			{
+				id: STORE_LOCATION_NOT_SAVED_QUERY_ARG,
+				explicitDismiss: true,
+				actions: [
+					{
+						label: __( 'Go to settings', 'woocommerce' ),
+						url: getAdminLink(
+							'admin.php?page=wc-settings&tab=general'
+						),
+					},
+				],
+			}
+		);
+		// Drop the flag so a reload does not repeat the notice.
+		getHistory().replace(
+			getNewPath(
+				{ [ STORE_LOCATION_NOT_SAVED_QUERY_ARG ]: undefined },
+				'/',
+				query
+			)
+		);
+	}, [ storeLocationNotSaved, createErrorNotice, query ] );

 	// @ts-expect-error -- TODO: convert Layout to TS component
 	return <Layout query={ query } />;
 };

-const onboardingData = getAdminSetting( 'onboarding', {} );
-
 const withSelectHandler = ( select: SelectFunction ) => {
 	const { getProfileItems, hasFinishedResolution } =
 		select( onboardingStore );
diff --git a/plugins/woocommerce/client/admin/client/homescreen/test/setup-wizard-redirect.test.tsx b/plugins/woocommerce/client/admin/client/homescreen/test/setup-wizard-redirect.test.tsx
new file mode 100644
index 00000000000..61fb816c078
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/homescreen/test/setup-wizard-redirect.test.tsx
@@ -0,0 +1,133 @@
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+import { getHistory, getNewPath, useQuery } from '@woocommerce/navigation';
+import type { ComponentType } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import Homescreen from '../index';
+import { getAdminSetting } from '~/utils/admin-settings';
+
+const mockPush = jest.fn();
+const mockReplace = jest.fn();
+jest.mock( '@woocommerce/navigation', () => ( {
+	getHistory: jest.fn( () => ( { push: mockPush, replace: mockReplace } ) ),
+	getNewPath: jest.fn( ( _query, path ) => path ),
+	useQuery: jest.fn( () => ( {} ) ),
+} ) );
+
+jest.mock( '@woocommerce/settings', () => ( {
+	getAdminLink: ( path: string ) => `http://example.test/wp-admin/${ path }`,
+} ) );
+
+jest.mock( '../layout', () => () => null );
+
+// Without the REST API, the store finishes resolving the profile with its empty defaults.
+jest.mock( '@woocommerce/data', () => ( {
+	onboardingStore: 'wc/admin/onboarding',
+	withOnboardingHydration: () => ( Component: ComponentType ) => Component,
+} ) );
+const mockCreateErrorNotice = jest.fn();
+jest.mock( '@wordpress/data', () => ( {
+	useDispatch: () => ( { createErrorNotice: mockCreateErrorNotice } ),
+	withSelect:
+		( mapSelect: ( select: unknown ) => object ) =>
+		( Component: ComponentType ) =>
+		( props: object ) => (
+			<Component
+				{ ...props }
+				{ ...mapSelect( () => ( {
+					getProfileItems: () => ( {
+						completed: null,
+						skipped: null,
+					} ),
+					hasFinishedResolution: () => true,
+				} ) ) }
+			/>
+		),
+} ) );
+
+// Homescreen reads this setting when it is imported, so the object lives inside the mock.
+jest.mock( '~/utils/admin-settings', () => {
+	const onboardingSetting = { profile: {} };
+	return { getAdminSetting: jest.fn( () => onboardingSetting ) };
+} );
+const mockOnboardingSetting = getAdminSetting( 'onboarding' ) as {
+	profile: Record< string, boolean >;
+};
+
+const renderHomescreen = () => render( <Homescreen /> );
+
+describe( 'Homescreen setup wizard redirect', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+		( useQuery as jest.Mock ).mockReturnValue( {} );
+	} );
+
+	it( 'redirects to the setup wizard while setup is pending', () => {
+		mockOnboardingSetting.profile = {};
+
+		renderHomescreen();
+
+		expect( getHistory().push ).toHaveBeenCalledWith( '/setup-wizard' );
+	} );
+
+	it.each( [ { skipped: true }, { completed: true } ] )(
+		'stays on Home when the preloaded profile is %o but the REST profile request failed',
+		( profile ) => {
+			mockOnboardingSetting.profile = profile;
+
+			renderHomescreen();
+
+			expect( mockPush ).not.toHaveBeenCalled();
+		}
+	);
+} );
+
+describe( 'Homescreen store location notice', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+		mockOnboardingSetting.profile = { skipped: true };
+	} );
+
+	it( 'points to the general settings and drops the flag when the skip flow could not save the store location', () => {
+		( useQuery as jest.Mock ).mockReturnValue( {
+			store_location_not_saved: '1',
+		} );
+
+		renderHomescreen();
+
+		expect( mockCreateErrorNotice ).toHaveBeenCalledWith(
+			expect.stringContaining(
+				'Your store location and currency were not saved.'
+			),
+			expect.objectContaining( {
+				explicitDismiss: true,
+				actions: [
+					{
+						label: 'Go to settings',
+						url: 'http://example.test/wp-admin/admin.php?page=wc-settings&tab=general',
+					},
+				],
+			} )
+		);
+		expect( getNewPath ).toHaveBeenCalledWith(
+			{ store_location_not_saved: undefined },
+			'/',
+			{ store_location_not_saved: '1' }
+		);
+		expect( mockReplace ).toHaveBeenCalledWith( '/' );
+	} );
+
+	it( 'shows no notice without the flag', () => {
+		( useQuery as jest.Mock ).mockReturnValue( {} );
+
+		renderHomescreen();
+
+		expect( mockCreateErrorNotice ).not.toHaveBeenCalled();
+		expect( mockReplace ).not.toHaveBeenCalled();
+	} );
+} );
diff --git a/plugins/woocommerce/src/Internal/Admin/Onboarding/OnboardingSetupWizard.php b/plugins/woocommerce/src/Internal/Admin/Onboarding/OnboardingSetupWizard.php
index 0c7cbfc3232..50dcdda4ba3 100644
--- a/plugins/woocommerce/src/Internal/Admin/Onboarding/OnboardingSetupWizard.php
+++ b/plugins/woocommerce/src/Internal/Admin/Onboarding/OnboardingSetupWizard.php
@@ -16,6 +16,11 @@ use Automattic\WooCommerce\Internal\Admin\RemoteFreeExtensions\ProcessCoreProfil
  * Contains backend logic for the onboarding profile and checklist feature.
  */
 class OnboardingSetupWizard {
+	/**
+	 * Admin-post action for skipping onboarding setup.
+	 */
+	private const SKIP_ONBOARDING_SETUP_ACTION = 'woocommerce_skip_onboarding_setup';
+
 	/**
 	 * Class instance.
 	 *
@@ -62,6 +67,71 @@ class OnboardingSetupWizard {
 		add_action( 'current_screen', array( $this, 'redirect_to_profiler' ) );
 		add_filter( 'woocommerce_show_admin_notice', array( $this, 'remove_old_install_notice' ), 10, 2 );
 		add_filter( 'admin_viewport_meta', array( $this, 'set_viewport_meta_tag' ) );
+		add_action( 'admin_post_' . self::SKIP_ONBOARDING_SETUP_ACTION, array( $this, 'handle_admin_post_woocommerce_skip_onboarding_setup' ) );
+	}
+
+	/**
+	 * Handle the authenticated onboarding setup skip fallback.
+	 *
+	 * Saves the skip without the REST API, then explains that some screens may not work and where to look.
+	 *
+	 * @internal
+	 *
+	 * @since 11.3.0
+	 */
+	public function handle_admin_post_woocommerce_skip_onboarding_setup(): void {
+		if ( ! current_user_can( 'manage_woocommerce' ) ) {
+			wp_die(
+				esc_html__( 'You do not have permission to skip WooCommerce setup.', 'woocommerce' ),
+				'',
+				array( 'response' => 403 )
+			);
+		}
+
+		check_admin_referer( self::SKIP_ONBOARDING_SETUP_ACTION );
+
+		$profile            = (array) get_option( OnboardingProfile::DATA_OPTION, array() );
+		$profile['skipped'] = true;
+		update_option( OnboardingProfile::DATA_OPTION, $profile );
+
+		$persisted_profile = (array) get_option( OnboardingProfile::DATA_OPTION, array() );
+		if ( true !== ( $persisted_profile['skipped'] ?? false ) ) {
+			wp_die(
+				esc_html__( 'WooCommerce could not save your setup preference. Please try again.', 'woocommerce' ),
+				'',
+				array( 'response' => 500 )
+			);
+		}
+
+		if ( current_user_can( 'view_site_health_checks' ) ) {
+			$next_step = sprintf(
+				/* translators: %s: link to the WordPress Site Health screen. */
+				esc_html__( 'If WooCommerce screens keep failing to load, check %s for problems with the REST API.', 'woocommerce' ),
+				'<a href="' . esc_url( admin_url( 'site-health.php' ) ) . '">' . esc_html__( 'Site Health', 'woocommerce' ) . '</a>'
+			);
+		} else {
+			$next_step = esc_html__( 'If WooCommerce screens keep failing to load, ask your hosting provider to check that your site’s REST API is working.', 'woocommerce' );
+		}
+
+		$store_location_notice = '';
+		// The profiler adds this flag when it could not save the country and currency the merchant picked.
+		if ( isset( $_GET['store_location_not_saved'] ) ) {
+			$store_location_notice = '<p>' . sprintf(
+				/* translators: %s: link to the WooCommerce general settings screen. */
+				esc_html__( 'Your store location and currency were not saved. Set them in %s, since taxes and prices depend on them.', 'woocommerce' ),
+				'<a href="' . esc_url( admin_url( 'admin.php?page=wc-settings&tab=general' ) ) . '">' . esc_html__( 'WooCommerce > Settings > General', 'woocommerce' ) . '</a>'
+			) . '</p>';
+		}
+
+		wp_die(
+			'<h1>' . esc_html__( 'WooCommerce setup skipped', 'woocommerce' ) . '</h1>' .
+			'<p>' . esc_html__( 'Your choice to skip setup is saved. Your site’s REST API did not respond correctly, so some WooCommerce screens may not work until that is fixed.', 'woocommerce' ) . '</p>' .
+			wp_kses_post( $store_location_notice ) .
+			'<p>' . wp_kses_post( $next_step ) . '</p>' .
+			'<p><a class="button button-primary" href="' . esc_url( wc_admin_url() ) . '">' . esc_html__( 'Continue to WooCommerce Home', 'woocommerce' ) . '</a></p>',
+			esc_html__( 'WooCommerce setup skipped', 'woocommerce' ),
+			array( 'response' => 200 )
+		);
 	}

 	/**
@@ -226,6 +296,19 @@ class OnboardingSetupWizard {
 		$settings['onboarding'] = array(
 			'profile' => $profile,
 		);
+		// Home can route to the setup wizard without a page load, so preload the URL whenever setup is still pending.
+		if ( $this->should_show() && current_user_can( 'manage_woocommerce' ) ) {
+			$settings['onboarding']['skipOnboardingFallbackUrl'] = add_query_arg(
+				array(
+					'action'   => self::SKIP_ONBOARDING_SETUP_ACTION,
+					'_wpnonce' => wp_create_nonce( self::SKIP_ONBOARDING_SETUP_ACTION ),
+				),
+				admin_url( 'admin-post.php' )
+			);
+			if ( current_user_can( 'view_site_health_checks' ) ) {
+				$settings['onboarding']['siteHealthUrl'] = admin_url( 'site-health.php' );
+			}
+		}

 		// Only fetch if the onboarding wizard OR the task list is incomplete or currently shown
 		// or the current page is one of the WooCommerce Admin pages.
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Onboarding/OnboardingSetupWizardTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Onboarding/OnboardingSetupWizardTest.php
new file mode 100644
index 00000000000..89a93967f4c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Onboarding/OnboardingSetupWizardTest.php
@@ -0,0 +1,460 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Admin\Onboarding;
+
+use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
+use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingSetupWizard;
+use RuntimeException;
+use WC_Unit_Test_Case;
+use WPDieException;
+
+/**
+ * Tests for the OnboardingSetupWizard class.
+ */
+class OnboardingSetupWizardTest extends WC_Unit_Test_Case {
+
+	private const ACTION = 'woocommerce_skip_onboarding_setup';
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var OnboardingSetupWizard
+	 */
+	private $sut;
+
+	/**
+	 * Shared administrator user ID.
+	 *
+	 * @var int
+	 */
+	private static $admin_id;
+
+	/**
+	 * Shared subscriber user ID.
+	 *
+	 * @var int
+	 */
+	private static $subscriber_id;
+
+	/**
+	 * Shared shop manager user ID.
+	 *
+	 * @var int
+	 */
+	private static $shop_manager_id;
+
+	/**
+	 * Create immutable user fixtures once for the class.
+	 *
+	 * @param \WP_UnitTest_Factory $factory Test fixture factory.
+	 */
+	public static function wpSetUpBeforeClass( $factory ): void {
+		self::$admin_id        = $factory->user->create( array( 'role' => 'administrator' ) );
+		self::$subscriber_id   = $factory->user->create( array( 'role' => 'subscriber' ) );
+		self::$shop_manager_id = $factory->user->create( array( 'role' => 'shop_manager' ) );
+	}
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->sut = OnboardingSetupWizard::instance();
+
+		delete_option( OnboardingProfile::DATA_OPTION );
+	}
+
+	/**
+	 * @testdox The production singleton handles only the authenticated skip action.
+	 */
+	public function test_registers_authenticated_skip_action_only(): void {
+		$callback = array( $this->sut, 'handle_admin_post_woocommerce_skip_onboarding_setup' );
+		set_current_screen( 'dashboard' );
+		$this->sut->init();
+
+		$this->assertSame(
+			10,
+			has_action( 'admin_post_' . self::ACTION, $callback ),
+			'The authenticated admin-post action should use the production singleton callback.'
+		);
+		$this->assertFalse(
+			has_action( 'admin_post_nopriv_' . self::ACTION ),
+			'The skip fallback must not be available to logged-out requests.'
+		);
+	}
+
+	/**
+	 * @testdox An administrator receives a raw, nonce-protected admin-post fallback URL while setup can still be skipped.
+	 *
+	 * @dataProvider data_provider_test_component_settings_exposes_fallback_url
+	 *
+	 * @param array<string, mixed>|null $profile Existing profile, or null when absent.
+	 * @param array<string, string>     $query   Query variables for the request.
+	 */
+	public function test_component_settings_exposes_fallback_url( ?array $profile, array $query ): void {
+		$this->set_profile( $profile );
+		wp_set_current_user( self::$admin_id );
+		$_GET = $query;
+		set_current_screen( 'dashboard' );
+
+		$settings = $this->sut->component_settings( array() );
+		$url      = $settings['onboarding']['skipOnboardingFallbackUrl'] ?? '';
+		$query    = array();
+		parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query );
+		$nonce = is_string( $query['_wpnonce'] ?? null ) ? $query['_wpnonce'] : '';
+
+		$this->assertSame(
+			admin_url( 'admin-post.php' ),
+			remove_query_arg( array( 'action', '_wpnonce' ), $url ),
+			'The fallback should use the WordPress admin-post endpoint.'
+		);
+		$this->assertSame( self::ACTION, $query['action'] ?? null, 'The fallback URL should carry the exact action.' );
+		$this->assertCount( 2, $query, 'The fallback URL should contain only the action and nonce.' );
+		$this->assertContains(
+			wp_verify_nonce( $nonce, self::ACTION ),
+			array( 1, 2 ),
+			'The fallback nonce should verify for the skip action.'
+		);
+		$this->assertStringNotContainsString( '&amp;', $url, 'The preloaded URL must remain raw for JSON serialization.' );
+		$this->assertSame( admin_url( 'site-health.php' ), $settings['onboarding']['siteHealthUrl'] ?? null, 'Administrators who can open Site Health should get its URL.' );
+	}
+
+	/**
+	 * @testdox A shop manager gets the fallback URL but no Site Health URL, because they cannot open Site Health.
+	 */
+	public function test_component_settings_omits_site_health_url_for_shop_manager(): void {
+		wp_set_current_user( self::$shop_manager_id );
+		$_GET = array(
+			'page' => 'wc-admin',
+			'path' => '/setup-wizard',
+		);
+		set_current_screen( 'dashboard' );
+
+		$settings = $this->sut->component_settings( array() );
+
+		$this->assertArrayHasKey( 'skipOnboardingFallbackUrl', $settings['onboarding'], 'Shop managers can skip setup.' );
+		$this->assertArrayNotHasKey( 'siteHealthUrl', $settings['onboarding'], 'Shop managers cannot open Site Health.' );
+	}
+
+	/**
+	 * Data provider for fallback URL exposure tests.
+	 *
+	 * @return array<string, array{array<string, mixed>|null, array<string, string>}>
+	 */
+	public static function data_provider_test_component_settings_exposes_fallback_url(): array {
+		return array(
+			'setup wizard after onboarding was completed' => array(
+				array( 'completed' => true ),
+				array(
+					'page' => 'wc-admin',
+					'path' => '/setup-wizard',
+				),
+			),
+			'Home while onboarding is pending'            => array(
+				null,
+				array( 'page' => 'wc-admin' ),
+			),
+		);
+	}
+
+	/**
+	 * @testdox The fallback URL is hidden once onboarding is done outside the setup wizard, and from users without permission.
+	 *
+	 * @dataProvider data_provider_test_component_settings_hides_fallback_url_without_access
+	 *
+	 * @param string                    $role    Fixture role to use.
+	 * @param array<string, mixed>|null $profile Existing profile, or null when absent.
+	 * @param array<string, string>     $query   Query variables for the request.
+	 */
+	public function test_component_settings_hides_fallback_url_without_access( string $role, ?array $profile, array $query ): void {
+		$this->set_profile( $profile );
+		$user_id = 'administrator' === $role ? self::$admin_id : self::$subscriber_id;
+		wp_set_current_user( $user_id );
+		$_GET = $query;
+		set_current_screen( 'dashboard' );
+
+		$settings = $this->sut->component_settings( array() );
+
+		$this->assertArrayNotHasKey(
+			'skipOnboardingFallbackUrl',
+			$settings['onboarding'],
+			'The fallback URL should only be preloaded for authorized users who can still skip setup.'
+		);
+	}
+
+	/**
+	 * Data provider for fallback URL visibility tests.
+	 *
+	 * @return array<string, array{string, array<string, mixed>|null, array<string, string>}>
+	 */
+	public static function data_provider_test_component_settings_hides_fallback_url_without_access(): array {
+		return array(
+			'administrator on Home after skipping'   => array(
+				'administrator',
+				array( 'skipped' => true ),
+				array( 'page' => 'wc-admin' ),
+			),
+			'administrator on Home after completion' => array(
+				'administrator',
+				array( 'completed' => true ),
+				array( 'page' => 'wc-admin' ),
+			),
+			'subscriber on setup wizard'             => array(
+				'subscriber',
+				null,
+				array(
+					'page' => 'wc-admin',
+					'path' => '/setup-wizard',
+				),
+			),
+		);
+	}
+
+	/**
+	 * @testdox A valid request persists only the skipped flag and confirms it with links to Site Health and WooCommerce Home.
+	 *
+	 * @dataProvider data_provider_test_valid_request_persists_skipped_and_confirms
+	 *
+	 * @param array<string, mixed>|string|null $profile          Existing profile, or null when absent.
+	 * @param array<int|string, mixed>         $expected_profile Expected persisted profile.
+	 */
+	public function test_valid_request_persists_skipped_and_confirms( $profile, array $expected_profile ): void {
+		$this->set_profile( $profile );
+		$this->prepare_valid_request( self::$admin_id );
+		$rest_update_actions = 0;
+		$observe_rest_update = static function () use ( &$rest_update_actions ): void {
+			++$rest_update_actions;
+		};
+		add_action( 'woocommerce_onboarding_profile_data_updated', $observe_rest_update, 10, 0 );
+
+		try {
+			$response = $this->dispatch_request();
+		} finally {
+			remove_action( 'woocommerce_onboarding_profile_data_updated', $observe_rest_update, 10 );
+		}
+		$stored = get_option( OnboardingProfile::DATA_OPTION );
+
+		$page = $response->getMessage();
+		$this->assertSame( 200, $response->getCode(), 'A successful fallback should answer with HTTP 200.' );
+		$this->assertStringStartsWith( '<h1>WooCommerce setup skipped</h1><p>Your choice to skip setup is saved.', $page, 'The confirmation should open with its heading and say the skip is saved.' );
+		$this->assertStringContainsString(
+			'<p>If WooCommerce screens keep failing to load, check <a href="' . esc_url( admin_url( 'site-health.php' ) ) . '">Site Health</a> for problems with the REST API.</p>',
+			$page,
+			'Administrators should be pointed to Site Health.'
+		);
+		$this->assertStringEndsWith(
+			'<p><a class="button button-primary" href="' . esc_url( wc_admin_url() ) . '">Continue to WooCommerce Home</a></p>',
+			$page,
+			'The confirmation should end with a link to WooCommerce Home.'
+		);
+		$this->assertSame( $expected_profile, $stored, 'The fallback should preserve all profile fields except skipped.' );
+		$this->assertTrue( $stored['skipped'], 'The persisted skipped value should be strictly true.' );
+		$this->assertSame( 0, $rest_update_actions, 'The fallback should not fire the REST-only profile update action.' );
+	}
+
+	/**
+	 * Data provider for successful fallback requests.
+	 *
+	 * @return array<string, array{array<string, mixed>|string|null, array<int|string, mixed>}>
+	 */
+	public static function data_provider_test_valid_request_persists_skipped_and_confirms(): array {
+		return array(
+			'absent profile'  => array(
+				null,
+				array( 'skipped' => true ),
+			),
+			'scalar profile'  => array(
+				'unexpected',
+				array(
+					0         => 'unexpected',
+					'skipped' => true,
+				),
+			),
+			'neutral fields'  => array(
+				array(
+					'business_choice' => 'im_already_selling',
+					'completed'       => false,
+					'setup_client'    => true,
+				),
+				array(
+					'business_choice' => 'im_already_selling',
+					'completed'       => false,
+					'setup_client'    => true,
+					'skipped'         => true,
+				),
+			),
+			'already skipped' => array(
+				array(
+					'skipped'  => true,
+					'industry' => array( 'fashion-apparel-accessories' ),
+				),
+				array(
+					'skipped'  => true,
+					'industry' => array( 'fashion-apparel-accessories' ),
+				),
+			),
+		);
+	}
+
+	/**
+	 * @testdox A shop manager's confirmation points to the hosting provider instead of Site Health.
+	 */
+	public function test_shop_manager_confirmation_points_to_hosting_provider(): void {
+		$this->prepare_valid_request( self::$shop_manager_id );
+
+		$response = $this->dispatch_request();
+
+		$this->assertSame( 200, $response->getCode(), 'Shop managers can skip setup.' );
+		$this->assertStringContainsString( 'hosting provider', $response->getMessage(), 'Users who cannot open Site Health should be told whom to ask.' );
+		$this->assertStringNotContainsString( 'site-health.php', $response->getMessage(), 'Users who cannot open Site Health should not get a link to it.' );
+		$this->assertTrue( get_option( OnboardingProfile::DATA_OPTION )['skipped'], 'The skip should be saved for shop managers.' );
+	}
+
+	/**
+	 * @testdox The confirmation points to the general settings only when the profiler could not save the store location.
+	 */
+	public function test_confirmation_mentions_unsaved_store_location_only_when_flagged(): void {
+		$this->prepare_valid_request( self::$admin_id );
+		// wp_kses_post() turns esc_url()'s &#038; into &amp;, so the rendered link uses &amp;.
+		$settings_link = '<a href="' . admin_url( 'admin.php?page=wc-settings&amp;tab=general' ) . '">WooCommerce &gt; Settings &gt; General</a>';
+
+		$unflagged_page = $this->dispatch_request()->getMessage();
+
+		$_GET['store_location_not_saved'] = '1';
+		$flagged_page                     = $this->dispatch_request()->getMessage();
+
+		$this->assertStringNotContainsString( 'store location', $unflagged_page, 'Without the flag, the confirmation should not mention the store location.' );
+		$this->assertStringContainsString(
+			'until that is fixed.</p><p>Your store location and currency were not saved. Set them in ' . $settings_link . ', since taxes and prices depend on them.</p><p>If WooCommerce screens keep failing to load',
+			$flagged_page,
+			'With the flag, the confirmation should link to the general settings in its own paragraph, between the summary and the next step.'
+		);
+	}
+
+	/**
+	 * @testdox An invalid nonce cannot mutate the profile.
+	 */
+	public function test_invalid_nonce_does_not_mutate_profile(): void {
+		$profile = array(
+			'completed' => false,
+			'purpose'   => 'setup',
+		);
+		$this->set_profile( $profile );
+		$this->prepare_request( self::$admin_id, 'invalid' );
+
+		$exception = $this->dispatch_request();
+
+		$this->assertSame( 403, $exception->getCode(), 'The invalid nonce should be rejected with HTTP 403.' );
+		$this->assertSame( $profile, get_option( OnboardingProfile::DATA_OPTION ), 'An invalid nonce must not mutate the profile.' );
+	}
+
+	/**
+	 * @testdox Capability denial happens before nonce validation and before reading profile state.
+	 */
+	public function test_subscriber_is_denied_before_nonce_and_option_access(): void {
+		$profile = array( 'completed' => false );
+		$this->set_profile( $profile );
+		$this->prepare_request( self::$subscriber_id, 'invalid' );
+		$option_read_probe = static function ( $pre_option ): string {
+			if ( is_string( $pre_option ) ) {
+				return $pre_option;
+			}
+
+			throw new RuntimeException( 'The onboarding profile was read before capability denial.' );
+		};
+		add_filter( 'pre_option_' . OnboardingProfile::DATA_OPTION, $option_read_probe );
+
+		try {
+			$exception = $this->dispatch_request();
+		} finally {
+			remove_filter( 'pre_option_' . OnboardingProfile::DATA_OPTION, $option_read_probe );
+		}
+
+		$this->assertSame(
+			esc_html__( 'You do not have permission to skip WooCommerce setup.', 'woocommerce' ),
+			$exception->getMessage(),
+			'Capability denial should win over the invalid nonce.'
+		);
+		$this->assertSame( 403, $exception->getCode(), 'Capability denial should use HTTP 403.' );
+		$this->assertSame( $profile, get_option( OnboardingProfile::DATA_OPTION ), 'A denied request must not mutate the profile.' );
+	}
+
+	/**
+	 * @testdox A failed persistence postcondition stops with HTTP 500 instead of confirming.
+	 */
+	public function test_failed_persistence_postcondition_stops_with_error(): void {
+		$profile = array(
+			'completed' => false,
+			'purpose'   => 'setup',
+		);
+		$this->set_profile( $profile );
+		$this->prepare_valid_request( self::$admin_id );
+		$block_update = static function ( $new_value, $old_value ) {
+			unset( $new_value );
+			return $old_value;
+		};
+		add_filter( 'pre_update_option_' . OnboardingProfile::DATA_OPTION, $block_update, 10, 2 );
+
+		try {
+			$exception = $this->dispatch_request();
+		} finally {
+			remove_filter( 'pre_update_option_' . OnboardingProfile::DATA_OPTION, $block_update, 10 );
+		}
+
+		$this->assertSame( 500, $exception->getCode(), 'A failed persistence postcondition should use HTTP 500.' );
+		$this->assertSame( $profile, get_option( OnboardingProfile::DATA_OPTION ), 'A failed update should leave the profile unchanged.' );
+	}
+
+	/**
+	 * Store or remove the onboarding profile fixture.
+	 *
+	 * @param array<string, mixed>|string|null $profile Profile data, or null to remove it.
+	 */
+	private function set_profile( $profile ): void {
+		if ( null === $profile ) {
+			delete_option( OnboardingProfile::DATA_OPTION );
+			return;
+		}
+
+		update_option( OnboardingProfile::DATA_OPTION, $profile );
+	}
+
+	/**
+	 * Prepare an authenticated admin-post request.
+	 *
+	 * @param int         $user_id User ID.
+	 * @param string|null $nonce   Request nonce, or null to create a valid nonce.
+	 */
+	private function prepare_request( int $user_id, ?string $nonce ): void {
+		wp_set_current_user( $user_id );
+		$nonce                = $nonce ?? wp_create_nonce( self::ACTION );
+		$_GET['_wpnonce']     = $nonce;
+		$_REQUEST['_wpnonce'] = $nonce;
+	}
+
+	/**
+	 * Prepare an authenticated admin-post request with a valid nonce.
+	 *
+	 * @param int $user_id User ID.
+	 */
+	private function prepare_valid_request( int $user_id ): void {
+		$this->prepare_request( $user_id, null );
+	}
+
+	/**
+	 * Dispatch the skip action and capture the page it ends with.
+	 *
+	 * @return WPDieException The confirmation or error response.
+	 */
+	private function dispatch_request(): WPDieException {
+		try {
+			$this->sut->handle_admin_post_woocommerce_skip_onboarding_setup();
+		} catch ( WPDieException $exception ) {
+			return $exception;
+		}
+
+		$this->fail( 'Expected the skip handler to finish with wp_die().' );
+	}
+}