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 & in the expected settings link
The handler builds the general settings link with esc_url(), which
encodes the ampersand as &, but then passes the whole notice
through wp_kses_post(), which turns it into &. The expected link
in the test uses & 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( '&', $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 & into &, so the rendered link uses &.
+ $settings_link = '<a href="' . admin_url( 'admin.php?page=wc-settings&tab=general' ) . '">WooCommerce > Settings > 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().' );
+ }
+}