Commit 01164f554d6 for woocommerce
commit 01164f554d675693eb50af44465d65cefc98bd72
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Wed Aug 26 10:05:52 2026 +0300
Fix product permalink radio always reverting to Custom base (#67346)
* fix: Keep the correct product permalink radio checked after saving
Settings > Permalinks > Product permalinks stores the chosen structure
through wc_sanitize_permalink(), which strips the trailing slash, and
maps the Default radio's empty value to the translated product slug. The
render path compared that stored value against the raw radio values, so
none of "Default", "Shop base", or "Shop base with category" ever
matched: /shop/ was compared against the stored /shop, and '' against
the stored product.
Every save therefore fell through to "Custom base" on the next page
load. The structure itself was stored and applied correctly, so the only
symptom was a wrong checked state - which still misreports the store's
configuration and invites the merchant to re-save something that was
never wrong. Reported since 2021.
Compare against a parallel array holding the same representation
settings_save() stores: the sanitized structures, and the resolved
default product slug in place of Default's empty value. The radio value
attributes keep their raw form, so the submitted payload is unchanged.
The Default preview switches from the 'default-slug' context to the
'slug' context for the same reason - it now shows the base products
actually resolve under, matching what the save path stores.
Refs #29050
* fix: Resolve the default product slug in the site locale
settings_save() runs inside wc_switch_to_site_locale(), so an explicit
"Default" save stores the product slug translated in the site's
language. settings() resolved the same slug outside that window, where
WordPress serves admin translations in the current user's language.
An administrator whose profile language differs from the site language
therefore saved one translation and was shown the comparison against
another: a German store administered in English stored "produkt" and
compared it against "product", so Default reverted to "Custom base" on
the very next page load. The previous commit's fix held only while both
languages happened to agree.
Resolve both translated slugs inside the same wc_switch_to_site_locale()
window the save path already opens. The two now agree by construction,
because they run through the identical mechanism.
The shop base slug joins the window for the matching reason: when no
Shop page exists it falls back to a translation, and settings_save()
resolves its own copy of that fallback in the site locale when deciding
whether verbose page rules are needed.
Refs #29050
* fix: Show the effective base in the product permalink Custom field
Selecting a predefined structure copied the radio's own value into the
Custom base field, so choosing "Default" blanked it. The field then read
as though no base were configured, when in fact products resolve under a
concrete base such as /product/. On first load the field had the same
gap: a stored base equal to the default rendered without its leading
slash, unlike every stored custom base.
Give each predefined radio a data-permalink-structure attribute carrying
the base products actually resolve under, and have the script read that
instead of the value. Default can then advertise /product/ while still
submitting the empty value the save path expects, so the payload is
unchanged.
The attribute is additive, and the script falls back to the radio value
when it is absent, so third-party markup reusing the .wctog class keeps
working.
Refs #29050
* fix: Guard non-scalar product permalink form input
settings_save() read both permalink fields straight out of $_POST.
wc_clean() returns an array unchanged, so an array posted for
product_permalink flowed into wc_sanitize_permalink(), and one posted
for product_permalink_structure reached trim() - a TypeError under PHP 8
for a value that only ever arrives from an untrusted request.
The rendered form cannot submit either shape, so nothing in WooCommerce
triggered it; nothing prevented it either, and both functions are
declared to take a string.
Coerce both fields to a string when they are scalar and to the empty
string otherwise, which the existing empty-value branch already resolves
to the default base. Every value the form can submit behaves exactly as
before, since wc_clean() delegates to sanitize_text_field() for scalars.
This resolves the two PHPStan baseline entries covering those calls, so
they are removed rather than left masking a fixed problem.
* test: Cover the product permalink screen in the browser
The PHPUnit class exercises settings_save() and settings() by
instantiating them directly, which skips what a merchant actually does:
the real form POST, WordPress's redirect back to the Permalinks screen,
and the script that mirrors the selected radio into the Custom base
field.
Add a Playwright journey that saves "Shop base" and then "Default"
through the page itself, asserting after each reload that the radio is
still selected and that the Custom base field shows the effective
structure. The expected Default base is derived from the rendered
preview instead of hardcoding a slug, so the spec passes on a store in
any language; it asserts the preview's shape first, so a changed preview
fails at its cause rather than as a later value mismatch.
The spec saves a global setting, so it is registered as serial and
restores the original selection in a finally block.
Refs #29050
* fix: Resolve the Shop page in the site locale when rendering permalinks
settings_save() resolves wc_get_page_id( 'shop' ) inside the
wc_switch_to_site_locale() window it opens; settings() resolved it before
opening its own. The woocommerce_get_shop_page_id filter is a documented
extension point, and multilingual plugins use it to return a different page per
locale — so the two paths could resolve different Shop pages, and the Shop base
radios would compare against a slug the save path never stores.
Move the call inside the window. Nothing else about the lookup changes, and on a
store where the filter is absent or locale-independent the resolved ID is
identical.
Refs #29050
* fix: Keep Default checked when its structure is saved as a custom base
The Custom base text field is the next tab stop after the product permalink
radio group — all four radios share a name, so they are one stop — and the
screen's focus handler selects "Custom base" the moment that field is focused.
A Tab keystroke is therefore enough to switch the form to Custom base with
whatever the field was prefilled with.
Saving from there posts the prefilled structure through the custom branch, which
prepends a slash before storing. Default's own structure comes back as
`/product`, while the Default radio stores a bare `product`, so the comparison
array no longer matched and the screen reported "Custom base" — the symptom of
#29050, reached from a keystroke rather than from the original mismatch.
The two values describe the same structure: WordPress strips leading slashes when
it builds rewrite rules, so product URLs are identical either way. Treat them as
the same choice. Saving Default again rewrites the stored value to its bare form,
so the state is not sticky.
Resolving the selection once into $selected_structure, rather than comparing at
each of the four radios, keeps the Custom base radio and the Custom field in
agreement with the three predefined ones by construction — previously the
in_array() check could disagree with the checked() calls above it.
Refs #29050
* docs: Scope the permalink comments to what the code guarantees
Three comments claimed more than the code delivers, which is worse than saying
nothing — a reader who trusts them reasons from a wrong model.
"Both fields post a scalar; anything else is coerced to the empty string, which
the Default branch below resolves" is true only for product_permalink. A
non-scalar product_permalink_structure takes the else branch inside the custom
block and resolves to '/', never reaching the Default branch the comment points
at. Describe each field's fallback.
"They agree by construction" and the render-side window comment both overstate
the locale guarantee. It holds for a product_base that is already persisted, not
for one that is missing: wc_get_permalink_structure() initializes that default in
the request locale, outside any window, before this screen renders. That is
pre-existing behavior tracked separately, but the comments should not imply it
is covered here.
No behavior change.
* test: Assert the permalink radios against the reloaded page
The spec waited only on the POST response before asserting. WordPress processes
that POST and redirects, so the response arrives before the new document commits
— and Playwright would resolve the assertions against the pre-submit DOM, which
still shows the radio the test had just checked. The assertions could pass
without the reload ever happening, which is the one thing the spec exists to
verify. Wait on the load event of the document the redirect produces instead.
The spec also asserted that the Custom field already held the default base before
touching any radio, which only holds if the store starts on Default. It does on a
fresh install, but nothing arranged or checked it, so a differently configured
store would fail there with a message pointing at the Default base rather than at
the unmet precondition. Select and save Default first, so the starting state is
established rather than assumed.
Issue #29050 names three predefined structures; the spec exercised two. Loop over
Shop base and Shop base with category so the third one round-trips through a real
save as well.
* fix: Normalize a Default-equivalent custom base on save
The screen previously reported a stored /product as Default at render
time, on the premise that the slash-prefixed and bare forms build the
same rewrite rules. They do not: under index.php (PATHINFO) permalinks
the leading slash reaches register_post_type() and produces an
index.php//product/%product% permastruct whose URLs fail to resolve,
while the bare slug works everywhere. Display-gating therefore reported
Default on stores whose actual URLs were broken, and rewrote the stored
value only as a side effect of the Default radio's empty POST.
Converge at the save path instead: when the custom branch resolves to
exactly the Default structure, store Default's bare site-locale slug.
The Tab-from-Default keystroke now persists a working value on every
permalink style, an already-persisted legacy /product is reported
honestly as a Custom base until a save converges it, and the render
never masks a stored shape that behaves differently.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: Keep one permalink radio checked when Shop rows are hidden
The Shop base rows render only when wc_get_page_id( 'shop' ) is truthy,
and the woocommerce_get_shop_page_id filter can make it return 0 by
yielding a truthy non-numeric value that absint() zeroes out. A stored
base matching one of those hidden rows resolved the selection to an
unrendered radio, so nothing on the screen was checked -- where trunk's
in_array() comparison structurally guaranteed Custom base as the
fallback. Restore that invariant by reporting such a base as Custom.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: Resolve a non-scalar custom permalink structure to the default base
The custom branch treated a missing or non-scalar
product_permalink_structure as '/', which wc_sanitize_permalink()
collapses to '' -- so the option was stored empty and
wc_get_permalink_structure() refilled it on the next request, in
whatever locale that request happened to run in (the nondeterminism
tracked in #67507). Resolving it to the default base inside the save
path's wc_switch_to_site_locale() window stores a deterministic
site-locale slug through the same code path the Default radio uses.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: Guard posted permalink fields with is_string
$_POST members are only ever strings or arrays, so is_string is the
precise guard and makes the (string) casts on the guarded reads
redundant. Matches the convention 0b6c602dd56 settled on for the same
includes/admin family after PHPStan flagged bool|float|int slipping
through an is_scalar check into string-typed parameters.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: Name and describe the Custom base field for assistive technology
The label element wraps the Custom base radio, not the text input, and
the description span sat unlinked beside it -- a screen reader landing
on the field heard only 'edit text' plus the prefilled value, with no
name and no guidance. Give the input an aria-label reusing the existing
'Custom base' string and link the description via aria-describedby.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: Restore the whole permalink option after the browser journey
The spec's teardown re-checked the original radio through the form,
which restores only what the form exposes. The journey's Shop base
saves also set the derived use_verbose_page_rules flag -- settings_save()
only ever writes it true, and no form field can unset it -- so the flag
leaked past the test. Snapshot the whole woocommerce_permalinks option
up front and restore it directly, followed by the rewrite flush a
Permalinks screen save would have performed.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: Cover save-side normalization from the Custom base focus flip
The claim that a Tab-from-Default save round-trips back to Default is
the branch's highest-risk behavior, and only PHPUnit exercised it.
Drive it through the real page: focus the Custom base field, confirm
the selection flips to Custom base, save, and assert the reloaded page
reports Default again with the stored product_base normalized to the
bare slug rather than the slash-prefixed form.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: Make the permalink spec branch-lint clean
Two prettier fixes on the wpCLI call parentheses, and the baseURL
guard rewritten from a conditional throw into a Playwright assertion,
which reads the same at failure time and clears
playwright/no-conditional-in-test.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: Focus the permalink comments on current behavior
Three comments narrated how the code used to behave or hedged with
untracked references: the render-side locale note ended in 'tracked
separately' where the issue now has a number (#67507), the input-guard
note still said 'scalars' after the guards moved to is_string, and the
non-string fallback recounted the '/'-then-'' history instead of
stating what the branch stores. Each now states the current contract
only; the normalization note's 'describing' tightened to 'equal to'.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: Run the permalink spec's WP-CLI calls without loading plugins
The spec's wp option calls are plain database operations, but WP-CLI
loads the full plugin stack to run them. In the CI serial suite the
onboarding wizard spec installs the default extension set first, and
booting those extensions under WP-CLI exhausts the cli container's
128M memory limit before the command runs -- every call fataled on the
google-listings-and-ads container boot. --skip-plugins --skip-themes
keeps the reads and the restore out of plugin code entirely.
The teardown's rewrite flush cannot take that flag: flushing under
--skip-plugins would persist a rule set missing every plugin rewrite.
Empty the rewrite_rules option instead, so WordPress regenerates it on
the next request with all plugins loaded.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: Cover the custom base slash and hash normalization
The custom branch removes every '#' from the posted structure and
collapses runs of slashes, but nothing asserted either half -- this PR
reformatted that statement and added a (string) cast, so the behavior
had no guard against an accidental change. One case covers both:
'//widgets///gad#gets' stores as '/widgets/gadgets'.
Also names what the transformation does at the call site. The regex
delimiter there is '#', the same character str_replace() strips two
arguments away, which reads as though the pattern involves hashes.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: Quote the permalink snapshot passed back to WP-CLI
wpCLI() builds one command string and runs it through exec(), which
hands it to a shell, so the restored JSON snapshot was subject to shell
parsing. It was wrapped in single quotes but not escaped, and a stored
base can legitimately contain one: wc_sanitize_permalink() leaves
quotes intact, so a custom base of shop's round-trips verbatim through
the settings screen.
Such a value aborted the teardown with a shell syntax error, and
because the teardown runs in finally, the abort masked whatever the
test itself had found. Escape the value with the POSIX '\'' idiom
instead.
Verified both directions against a store seeded with a product_base of
/shop's: the previous form fails with "unexpected EOF while looking
for matching", the escaped form passes and restores the option
byte-exact.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* refactor: Resolve the stored product base in one place
The render path decided which radio to check by rebuilding what a save
would have stored, and the save path built that value independently.
Every rule existed twice: the Default radio's empty value mapping to
the translated slug, the wc_sanitize_permalink() trailing-slash strip,
the site-locale resolution, and the /product to product convergence.
Three of the four were held together by a comment asserting the two
sides must match rather than by anything that enforced it -- and two
copies drifting apart is what made every predefined structure revert
to Custom base in the first place.
Extract get_stored_product_base(), which resolves a posted choice to
the value that gets persisted. The save path stores what it returns
and the render path maps the radio values through it, so the two agree
by construction and a future normalization cannot land on one side
only. The shop base slug expression, duplicated verbatim in both
methods apart from an urldecode(), moves to get_shop_base_slug().
Build the structures array with only the rows that render, so the
match can no longer name a hidden Shop row. That replaces the guard
that produced a match and then discarded it, along with the comment
explaining the correction.
Drop the JavaScript fallback to the radio's own value. It was a second
source for the structure, and for the Default radio that value is the
empty string -- exactly the behavior this branch set out to fix. Every
row this screen renders carries the data attribute.
Typing the shop base slug helper resolves a baselined urldecode()
argument error, so that entry goes too.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: Drop permalink teardown the WordPress test case already does
The class carried a closure registry, a reverse-order drain in
tearDown(), a ten-key $_POST unset list, and a wp_set_current_user( 0 )
call, all to undo what WP_UnitTestCase already undoes. Its set_up()
runs clean_up_global_scope(), which empties $_GET, $_POST and
$_REQUEST; its tear_down() runs _restore_hooks(), which reassigns
$wp_filter wholesale from the snapshot, and then resets the current
user itself. The registry bought nothing but obliged every future test
to remember to push a cleanup into it, and the unset list had to stay
in sync with whatever fields a new test posted.
Remove all of it. The two filters register plainly, and the one place
the reset is genuinely needed -- between the save instance and the
render instance inside save_and_render() -- clears $_POST directly.
Widen save_and_render() to take the posted values loosely. Two tests
duplicated its whole arrange block only because its string typehints
rejected the arrays they needed to post; they collapse into one
@testWith case. Give assert_only_radio_checked() the expected row list
so the hidden-Shop-rows test uses it instead of hand-rolling the same
assertions, which also gains it the exactly-one-checked guarantee.
Verified against the WordPress test case in the wp-env container
rather than assumed. 13 tests, 65 assertions, unchanged.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: Simplify the permalink spec's setup and base derivation
Three things cost more than they bought. The option snapshot ran after
the page load even though it only has to precede the first save, so a
WP-CLI container spawn -- seconds, on a spec the config already pins to
the serial project -- sat on the critical path for no reason. The same
option read was spelled out twice. And deriving the Default base meant
parsing the preview as a URL, reading the site path off the Playwright
baseURL, stripping its trailing slash, and slicing one from the other,
purely so a subdirectory install would not contribute its prefix.
Overlap the snapshot with the page load, give the read a name and call
it in both places, and match the base straight off the end of the
preview text. Anchoring the pattern at sample-product/ makes the
install layout irrelevant, so the baseURL fixture and the two guard
assertions propping up the arithmetic all go.
Coverage is unchanged: the same five saves round-trip through the real
POST and redirect, and the stored bare slug is still read back.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: Resolve an empty custom permalink base to the default base
A custom base carrying no usable characters sanitizes down to the
empty string: a blank field, whitespace, a value of only hashes, and
a lone or repeated slash all collapse to it once the hashes are
stripped and the slash runs are folded.
An empty product_base never survives in the option.
wc_get_permalink_structure() drops it with array_filter(), refills it
from _x( 'product', 'slug' ) resolved in the request locale -- outside
any locale window -- and writes the result back. An administrator
browsing the admin in their own language therefore persisted that
language's slug for a site whose locale never chose it, which is the
divergence the rest of this resolver exists to close. The non-string
path was already given this treatment; the empty-string path was not.
Fall back to the site-locale default after sanitizing instead. The
rendered outcome is unchanged when the two locales agree, since the
refill produced the same slug the fix now stores, so nothing about the
screen behaves differently for an English admin on an English store.
The regression test needs the locales to diverge for exactly that
reason: an earlier version of it passed against the unfixed code
because the refill was indistinguishable from the fix. Under a French
admin on an English store the unfixed code stores 'produit' for all
five collapsing inputs.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test: Cover the Shop base that collapses into the default base
A Shop page slug equal to the default product slug makes "Default" and
"Shop base" persist the same value. The checked-state search maps a
stored base back to whichever predefined choice would store it, so on
such a store that question has two answers and the first one wins.
Reporting "Shop base" instead would only move the wrong label onto the
merchant who picked Default. The two cannot be told apart at storage
either: the slashed /product that would distinguish them is the shape
that breaks PATHINFO permalinks, which is why the base is normalized
to the bare form to begin with.
Nothing downstream reads the label -- the stored base is identical
either way, so the product URLs and the derived use_verbose_page_rules
flag are too. Pin the behavior and its reasoning so the next reader
finds the argument instead of re-deriving it.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: Scope the resolver's agreement claim to the rules it shares
The docblock said the render and save paths "agree by construction"
because both go through the resolver. Sharing the rules is not the
same as receiving identical input: the save path runs the posted value
through sanitize_text_field() first, which strips percent-encoded
octets, so a Shop page whose slug carries a literal percent-escape can
still resolve differently on the two sides.
Reaching that needs a percent-escape typed into the Shop page title,
and the asymmetry predates this resolver -- the code it replaced ran
the same field through wc_clean(), which dispatches scalars to that
same sanitizer. Narrow the claim to what the code guarantees rather
than change what gets stored for input nobody has reported.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: Distinguish the Custom base field's name from its radio
The aria-label given to the field reused the "Custom base" string from
the radio in the same row. That <th> carries no scope attribute, so
the header-assignment algorithm resolves it to the row header for the
cell holding the field, and a screen reader announces one name twice
under two roles: "Custom base, radio button, not checked", then
"Custom base, edit".
WordPress core renders this same widget on this same screen and
deliberately avoids the reuse -- the radio keeps a visible label while
the field gets its own purpose-describing one. Name the field for what
it sets so the two controls are told apart.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs: Correct and rebalance the permalink settings comments
Two comments claimed more than the code delivers. "An empty base is
never stored" is not an invariant this resolver can hold: the default
base it falls back to is itself a translated slug run through
wc_sanitize_permalink(), so a translation or gettext filter resolving
it to something that sanitizes away leaves the guard assigning empty
to empty. And "a non-string value in either field resolves to the
default product base" holds for the radio value only -- a non-string
structure is passed as null, which the resolver reads solely inside
the custom branch, so a predefined radio stores its own structure
regardless.
State what each guarantees instead, and name the residual gap in the
empty-base case rather than papering over it.
The rest is balance and precision. The locale window is a
precondition, so it now precedes the sanitize-asymmetry caveat instead
of trailing it. "This is an invalid base structure and breaks pages"
says which structure and what breaks: a base of nothing but the
category token gives products the same URL shape as the category
archives they sit under. And get_shop_base_slug() explains why its
guard tests the ID against zero -- wc_get_page_id() returns -1 for an
unset page, which is truthy.
No behavior change.
Refs #29050
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: Select the Custom base only on a click or a keystroke
The Custom base field selected its radio on focus. The four product
permalink radios share a name, so they are a single tab stop and the
field is the next one: tabbing forward off the checked radio moved the
checked state to Custom base without the merchant choosing anything.
On trunk that was invisible. The radio already showed Custom base
whatever the store used, because the render path compared against raw
radio values the save path never stored -- the bug this branch fixes.
Now that the screen reports the real structure, the same handler visibly
undoes it: a store on Default shows Default, and one Tab reports Custom.
Bind the pair core binds to its own structure field instead. WordPress
selects its Custom radio on 'click input' and uses 'focus' only to
record that the caret has entered the field (wp-admin/js/common.js).
Clicking into the field and typing in it still select Custom base;
arriving by keyboard no longer does.
The Default structure can still reach the custom branch -- clicking into
the prefilled field and saving posts it -- so the save-side convergence
to Default's bare slug still has a caller and stays as it is.
Refs #29050
* test: Cover the category-token guard in the custom base
The save path prefixes the default base when a custom base is nothing
but the category token, so products do not take the same URL shape as
the category archives they sit under. The guard traces to #13374 and
was carried through this branch unchanged.
Nothing in the suite entered the custom branch with %product_cat%, so
deleting the guard left every test green. Post the token through the
real save path in the three shapes the field accepts -- bare, slashed,
and with slash runs -- and assert the stored base carries the default
prefix and the screen reports Custom base.
Verified by mutation: with the guard removed all three data sets fail,
and no other test does.
Refs #29050
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/67323-fix-product-permalink-radio-checked-state b/plugins/woocommerce/changelog/67323-fix-product-permalink-radio-checked-state
new file mode 100644
index 00000000000..9d5e133bc44
--- /dev/null
+++ b/plugins/woocommerce/changelog/67323-fix-product-permalink-radio-checked-state
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix product permalink settings radio buttons reverting to "Custom base" after saving Default, Shop base, or Shop base with category.
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-permalink-settings.php b/plugins/woocommerce/includes/admin/class-wc-admin-permalink-settings.php
index 536f3584876..2897d3dc42b 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-permalink-settings.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-permalink-settings.php
@@ -93,6 +93,97 @@ class WC_Admin_Permalink_Settings {
<?php
}
+ /**
+ * Resolve the Shop page URI that serves as the Shop base, or the default slug.
+ *
+ * Shared by the render and the save paths so both resolve the base the same way.
+ *
+ * wc_get_page_id() returns -1 when no page is set, so the ID is tested against zero rather
+ * than for truthiness -- and the page it names can still be gone, which get_post() catches.
+ *
+ * @param int $shop_page_id Shop page ID, as resolved by wc_get_page_id( 'shop' ).
+ * @return string Shop base slug.
+ */
+ private function get_shop_base_slug( int $shop_page_id ): string {
+ return (string) ( ( $shop_page_id > 0 && get_post( $shop_page_id ) ) ? get_page_uri( $shop_page_id ) : _x( 'shop', 'default-slug', 'woocommerce' ) );
+ }
+
+ /**
+ * Resolve a posted product permalink choice to the value that gets persisted for it.
+ *
+ * The render path checks a radio by comparing the stored base against this, and the save path
+ * stores what this returns, so both derive the base from one set of rules instead of restating
+ * them separately -- which is what made every predefined structure revert to "Custom base".
+ * See https://github.com/woocommerce/woocommerce/issues/29050.
+ *
+ * Must run inside a wc_switch_to_site_locale() window: the Default base is a translated slug,
+ * and an administrator whose profile language differs from the site language would otherwise
+ * store one translation and compare against another.
+ *
+ * Sharing the rules is not the same as receiving identical input: the save path runs the
+ * posted value through sanitize_text_field() first, which strips percent-encoded octets, so a
+ * Shop page whose slug carries a literal percent-escape can still resolve differently on the
+ * two sides.
+ *
+ * @param string $posted_base Posted `product_permalink` radio value.
+ * @param string|null $posted_structure Posted `product_permalink_structure` value, or null when that field is absent or not a string.
+ * @return string The base as it is stored.
+ */
+ private function get_stored_product_base( string $posted_base, ?string $posted_structure = null ): string {
+ $default_base = wc_sanitize_permalink( _x( 'product', 'slug', 'woocommerce' ) );
+
+ if ( 'custom' === $posted_base ) {
+ if ( null === $posted_structure ) {
+ // A missing or non-string field resolves to the default base, so the stored slug
+ // stays deterministic and in the site locale.
+ $base = $default_base;
+ } else {
+ // Remove every `#` so the base cannot open a URL fragment, prepend the leading
+ // slash, then collapse each run of slashes into one.
+ $base = (string) preg_replace( '~/+~', '/', '/' . str_replace( '#', '', trim( $posted_structure ) ) );
+ }
+
+ // A base of nothing but the category token gives products the same URL shape as the
+ // category archives they sit under, so the two collide. Prefix the default base.
+ if ( '/%product_cat%/' === trailingslashit( $base ) ) {
+ $base = '/' . $default_base . $base;
+ }
+ } else {
+ // The Default radio posts an empty value; store the site-locale slug.
+ $base = '' === $posted_base ? $default_base : $posted_base;
+ }
+
+ $base = wc_sanitize_permalink( $base );
+
+ /*
+ * Resolve an empty base to the default one. A custom base that is blank, whitespace, or
+ * nothing but hashes and slashes sanitizes down to empty, and an empty base does not
+ * survive: wc_get_permalink_structure() drops it and refills the option from the request
+ * locale, outside any locale window, persisting a slug the site locale never chose -- the
+ * same divergence this resolver exists to prevent.
+ *
+ * This narrows that window rather than closing it. The default base is itself a translated
+ * slug run through wc_sanitize_permalink(), so a translation or a gettext filter that
+ * resolves it to something sanitizing away -- `/` untrailingslashits to nothing -- leaves
+ * this assigning empty to empty.
+ */
+ if ( '' === $base ) {
+ $base = $default_base;
+ }
+
+ /*
+ * A base equal to the Default structure is reported in Default's bare form. The Custom
+ * base field is the next tab stop after the radio group and focusing it selects Custom
+ * base, so a single Tab from Default posts the field's prefilled `/product/` through the
+ * custom branch, which prepends a slash. The two forms are not interchangeable: under
+ * index.php (PATHINFO) permalinks the leading slash reaches register_post_type() and
+ * produces an `index.php//product/%product%` permastruct whose URLs do not resolve, while
+ * the bare slug works everywhere. Converging on the bare form keeps Default checked after
+ * that keystroke and never persists the broken shape.
+ */
+ return '/' . $default_base === $base ? $default_base : $base;
+ }
+
/**
* Show the settings.
*/
@@ -100,37 +191,67 @@ class WC_Admin_Permalink_Settings {
/* translators: %s: Home URL */
echo wp_kses_post( wpautop( sprintf( __( 'If you like, you may enter custom structures for your product URLs here. For example, using <code>shop</code> would make your product links like <code>%sshop/sample-product/</code>. This setting affects product URLs only, not things such as product categories.', 'woocommerce' ), esc_url( home_url( '/' ) ) ) ) );
+ /*
+ * Resolve the Shop page and the translated slugs inside the same window settings_save()
+ * opens, so the values compared below are the values a save would store.
+ *
+ * wc_get_page_id() is resolved here too because settings_save() resolves it inside its own
+ * window, and the woocommerce_get_shop_page_id filter multilingual plugins attach to can
+ * return a different page per locale.
+ *
+ * This holds only for a persisted product_base: wc_get_permalink_structure() initializes a
+ * missing one in the request locale, outside any window, before this screen renders.
+ * See https://github.com/woocommerce/woocommerce/issues/67507.
+ */
+ wc_switch_to_site_locale();
$shop_page_id = wc_get_page_id( 'shop' );
- $base_slug = urldecode( ( $shop_page_id > 0 && get_post( $shop_page_id ) ) ? get_page_uri( $shop_page_id ) : _x( 'shop', 'default-slug', 'woocommerce' ) );
- $product_base = _x( 'product', 'default-slug', 'woocommerce' );
+ $base_slug = urldecode( $this->get_shop_base_slug( $shop_page_id ) );
- $structures = array(
- 0 => '',
- 1 => '/' . trailingslashit( $base_slug ),
- 2 => '/' . trailingslashit( $base_slug ) . trailingslashit( '%product_cat%' ),
- );
+ // The value each radio posts. The Shop entries exist only when their rows render below, so
+ // a stored base matching a hidden row reports as Custom instead of checking nothing.
+ $structures = array( 0 => '' );
+ if ( $shop_page_id ) {
+ $structures[1] = '/' . trailingslashit( $base_slug );
+ $structures[2] = '/' . trailingslashit( $base_slug ) . trailingslashit( '%product_cat%' );
+ }
+
+ // What a save would store for each of them, so the comparison below cannot drift from
+ // settings_save().
+ $stored_forms = array_map( array( $this, 'get_stored_product_base' ), $structures );
+ wc_restore_locale();
+
+ $default_product_base = $stored_forms[0];
+ $stored_product_base = $this->permalinks['product_base'];
+ $default_product_structure = trailingslashit( '/' . ltrim( $default_product_base, '/' ) );
+
+ // The index of the predefined structure the stored base corresponds to, or false for a custom one.
+ $selected_structure = array_search( $stored_product_base, $stored_forms, true );
+
+ $product_permalink_structure = 0 === $selected_structure
+ ? $default_product_structure
+ : ( $stored_product_base ? trailingslashit( $stored_product_base ) : '' );
?>
<table class="form-table wc-permalink-structure">
<tbody>
<tr>
- <th><label><input name="product_permalink" type="radio" value="<?php echo esc_attr( $structures[0] ); ?>" class="wctog" <?php checked( $structures[0], $this->permalinks['product_base'] ); ?> /> <?php esc_html_e( 'Default', 'woocommerce' ); ?></label></th>
- <td><code class="default-example"><?php echo esc_html( home_url() ); ?>/?product=sample-product</code> <code class="non-default-example"><?php echo esc_html( home_url() ); ?>/<?php echo esc_html( $product_base ); ?>/sample-product/</code></td>
+ <th><label><input name="product_permalink" type="radio" value="<?php echo esc_attr( $structures[0] ); ?>" data-permalink-structure="<?php echo esc_attr( $default_product_structure ); ?>" class="wctog" <?php checked( 0 === $selected_structure ); ?> /> <?php esc_html_e( 'Default', 'woocommerce' ); ?></label></th>
+ <td><code class="default-example"><?php echo esc_html( home_url() ); ?>/?product=sample-product</code> <code class="non-default-example"><?php echo esc_html( home_url() ); ?>/<?php echo esc_html( $default_product_base ); ?>/sample-product/</code></td>
</tr>
<?php if ( $shop_page_id ) : ?>
<tr>
- <th><label><input name="product_permalink" type="radio" value="<?php echo esc_attr( $structures[1] ); ?>" class="wctog" <?php checked( $structures[1], $this->permalinks['product_base'] ); ?> /> <?php esc_html_e( 'Shop base', 'woocommerce' ); ?></label></th>
+ <th><label><input name="product_permalink" type="radio" value="<?php echo esc_attr( $structures[1] ); ?>" data-permalink-structure="<?php echo esc_attr( $structures[1] ); ?>" class="wctog" <?php checked( 1 === $selected_structure ); ?> /> <?php esc_html_e( 'Shop base', 'woocommerce' ); ?></label></th>
<td><code><?php echo esc_html( home_url() ); ?>/<?php echo esc_html( $base_slug ); ?>/sample-product/</code></td>
</tr>
<tr>
- <th><label><input name="product_permalink" type="radio" value="<?php echo esc_attr( $structures[2] ); ?>" class="wctog" <?php checked( $structures[2], $this->permalinks['product_base'] ); ?> /> <?php esc_html_e( 'Shop base with category', 'woocommerce' ); ?></label></th>
+ <th><label><input name="product_permalink" type="radio" value="<?php echo esc_attr( $structures[2] ); ?>" data-permalink-structure="<?php echo esc_attr( $structures[2] ); ?>" class="wctog" <?php checked( 2 === $selected_structure ); ?> /> <?php esc_html_e( 'Shop base with category', 'woocommerce' ); ?></label></th>
<td><code><?php echo esc_html( home_url() ); ?>/<?php echo esc_html( $base_slug ); ?>/product-category/sample-product/</code></td>
</tr>
<?php endif; ?>
<tr>
- <th><label><input name="product_permalink" id="woocommerce_custom_selection" type="radio" value="custom" class="tog" <?php checked( in_array( $this->permalinks['product_base'], $structures, true ), false ); ?> />
+ <th><label><input name="product_permalink" id="woocommerce_custom_selection" type="radio" value="custom" class="tog" <?php checked( false === $selected_structure ); ?> />
<?php esc_html_e( 'Custom base', 'woocommerce' ); ?></label></th>
<td>
- <input name="product_permalink_structure" id="woocommerce_permalink_structure" type="text" value="<?php echo esc_attr( $this->permalinks['product_base'] ? trailingslashit( $this->permalinks['product_base'] ) : '' ); ?>" class="regular-text code"> <span class="description"><?php esc_html_e( 'Enter a custom base to use. A base must be set or WordPress will use default instead.', 'woocommerce' ); ?></span>
+ <input name="product_permalink_structure" id="woocommerce_permalink_structure" type="text" value="<?php echo esc_attr( $product_permalink_structure ); ?>" class="regular-text code" aria-label="<?php esc_attr_e( 'Custom product permalink base', 'woocommerce' ); ?>" aria-describedby="woocommerce_permalink_structure_description"> <span class="description" id="woocommerce_permalink_structure_description"><?php esc_html_e( 'Enter a custom base to use. A base must be set or WordPress will use default instead.', 'woocommerce' ); ?></span>
</td>
</tr>
</tbody>
@@ -139,7 +260,7 @@ class WC_Admin_Permalink_Settings {
<script type="text/javascript">
jQuery( function() {
jQuery('input.wctog').on( 'change', function() {
- jQuery('#woocommerce_permalink_structure').val( jQuery( this ).val() );
+ jQuery('#woocommerce_permalink_structure').val( jQuery( this ).attr( 'data-permalink-structure' ) );
});
jQuery('.permalink-structure input').on( 'change', function() {
jQuery('.wc-permalink-structure').find('code.non-default-example, code.default-example').hide();
@@ -153,7 +274,11 @@ class WC_Admin_Permalink_Settings {
}
});
jQuery('.permalink-structure input:checked').trigger( 'change' );
- jQuery('#woocommerce_permalink_structure').on( 'focus', function(){
+ // Selecting Custom base takes a click or a typed character, the pair core binds to
+ // its own structure field. Focus alone must not: the radios share one tab stop, so
+ // tabbing forward lands here, and flipping on focus would move the checked radio
+ // off the structure the store actually uses.
+ jQuery('#woocommerce_permalink_structure').on( 'click input', function(){
jQuery('#woocommerce_custom_selection').trigger( 'click' );
} );
} );
@@ -178,29 +303,24 @@ class WC_Admin_Permalink_Settings {
$permalinks['tag_base'] = wc_sanitize_permalink( wp_unslash( $_POST['woocommerce_product_tag_slug'] ) );
$permalinks['attribute_base'] = wc_sanitize_permalink( wp_unslash( $_POST['woocommerce_product_attribute_slug'] ) );
- // Generate product base.
- $product_base = isset( $_POST['product_permalink'] ) ? wc_clean( wp_unslash( $_POST['product_permalink'] ) ) : '';
-
- if ( 'custom' === $product_base ) {
- if ( isset( $_POST['product_permalink_structure'] ) ) {
- $product_base = preg_replace( '#/+#', '/', '/' . str_replace( '#', '', trim( wp_unslash( $_POST['product_permalink_structure'] ) ) ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce is verified; permalink tokens require domain-specific cleaning.
- } else {
- $product_base = '/';
- }
-
- // This is an invalid base structure and breaks pages.
- if ( '/%product_cat%/' === trailingslashit( $product_base ) ) {
- $product_base = '/' . _x( 'product', 'slug', 'woocommerce' ) . $product_base;
- }
- } elseif ( empty( $product_base ) ) {
- $product_base = _x( 'product', 'slug', 'woocommerce' );
- }
+ /*
+ * The form only ever posts strings for these two fields, but nothing enforces that,
+ * and the resolver requires one. A non-string radio value resolves to the default
+ * product base; a non-string structure is passed as null, which resolves to the
+ * default base only when the radio selects the custom branch that reads it.
+ */
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized by sanitize_text_field().
+ $product_base = sanitize_text_field( isset( $_POST['product_permalink'] ) && is_string( $_POST['product_permalink'] ) ? wp_unslash( $_POST['product_permalink'] ) : '' );
+ // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized by wc_sanitize_permalink() inside the resolver.
+ $posted_structure = isset( $_POST['product_permalink_structure'] ) && is_string( $_POST['product_permalink_structure'] ) ? wp_unslash( $_POST['product_permalink_structure'] ) : null;
- $permalinks['product_base'] = wc_sanitize_permalink( $product_base );
+ // Resolved inside the wc_switch_to_site_locale() window opened above, so the stored
+ // value is the same site-locale form settings() compares against.
+ $permalinks['product_base'] = $this->get_stored_product_base( $product_base, $posted_structure );
// Shop base may require verbose page rules if nesting pages.
$shop_page_id = wc_get_page_id( 'shop' );
- $shop_permalink = ( $shop_page_id > 0 && get_post( $shop_page_id ) ) ? get_page_uri( $shop_page_id ) : _x( 'shop', 'default-slug', 'woocommerce' );
+ $shop_permalink = $this->get_shop_base_slug( $shop_page_id );
if ( $shop_page_id && stristr( trim( $permalinks['product_base'], '/' ), $shop_permalink ) ) {
$permalinks['use_verbose_page_rules'] = true;
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 4c23b8cd9db..ed977d05612 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -2364,24 +2364,6 @@ parameters:
count: 1
path: includes/admin/class-wc-admin-permalink-settings.php
- -
- message: '#^Parameter \#1 \$str of function urldecode expects string, string\|false given\.$#'
- identifier: argument.type
- count: 1
- path: includes/admin/class-wc-admin-permalink-settings.php
-
- -
- message: '#^Parameter \#1 \$value of function trailingslashit expects string, string\|null given\.$#'
- identifier: argument.type
- count: 1
- path: includes/admin/class-wc-admin-permalink-settings.php
-
- -
- message: '#^Parameter \#1 \$value of function wc_sanitize_permalink expects string, array\|string\|null given\.$#'
- identifier: argument.type
- count: 1
- path: includes/admin/class-wc-admin-permalink-settings.php
-
-
message: '#^Method WC_Admin_Pointers\:\:create_product_tutorial\(\) has no return type specified\.$#'
identifier: missingType.return
diff --git a/plugins/woocommerce/tests/e2e/playwright.config.ts b/plugins/woocommerce/tests/e2e/playwright.config.ts
index cdb040c204a..e01eed00df5 100644
--- a/plugins/woocommerce/tests/e2e/playwright.config.ts
+++ b/plugins/woocommerce/tests/e2e/playwright.config.ts
@@ -171,6 +171,9 @@ const serialRunSpecs = [
// Mutate global WooCommerce settings (store address/currency/country, tax)
// that other workers' cart/checkout/storefront specs depend on.
'**/tests/settings/settings-general.spec.ts',
+ // Mutates the global woocommerce_permalinks option (product base and the
+ // derived use_verbose_page_rules flag) and restores it in teardown.
+ '**/tests/settings/product-permalinks.spec.ts',
'**/tests/settings/settings-tax.spec.ts',
// Toggles the global `settings-ui` feature flag and resets all e2e feature flags
// in afterAll.
diff --git a/plugins/woocommerce/tests/e2e/tests/settings/product-permalinks.spec.ts b/plugins/woocommerce/tests/e2e/tests/settings/product-permalinks.spec.ts
new file mode 100644
index 00000000000..f90cad040b0
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/settings/product-permalinks.spec.ts
@@ -0,0 +1,175 @@
+/**
+ * Internal dependencies
+ */
+import { expect, test } from '../../fixtures/fixtures';
+import { ADMIN_STATE_PATH } from '../../playwright.config';
+import { wpCLI } from '../../utils/cli';
+
+/**
+ * Wrap a value as a single shell argument.
+ *
+ * `wpCLI()` builds one command string and runs it through `exec()`, which hands it to a shell, so
+ * an interpolated value has to survive shell parsing. A permalink base can legitimately contain a
+ * single quote — `wc_sanitize_permalink()` leaves them intact, so a custom base of `shop's` is
+ * stored verbatim — and an unquoted one would break the command. `'\''` ends the quoted run,
+ * emits a literal quote, and opens the next one.
+ *
+ * @param value Value to pass as a single argument.
+ * @return The value quoted for the shell.
+ */
+const asShellArgument = ( value: string ) =>
+ `'${ value.replaceAll( "'", `'\\''` ) }'`;
+
+test.describe( 'Product permalink settings', () => {
+ test.use( { storageState: ADMIN_STATE_PATH } );
+
+ test( 'saved product permalink structures stay selected after a reload', async ( {
+ page,
+ } ) => {
+ // Every WP-CLI call in this spec is a plain database operation, so plugins and themes are
+ // skipped: earlier specs in the serial suite can leave heavyweight extensions installed
+ // (the onboarding wizard installs the default set), and booting them under WP-CLI can
+ // exhaust the CLI container's memory limit before the command runs.
+ const optionCliFlags = '--skip-plugins --skip-themes';
+ // Snapshot the whole option rather than the visible form state: the journey's Shop base
+ // saves also flip the derived `use_verbose_page_rules` flag, which no form field exposes,
+ // so a UI-driven restore could never put it back.
+ const readPermalinks = async () =>
+ (
+ await wpCLI(
+ `wp option get woocommerce_permalinks --format=json ${ optionCliFlags }`
+ )
+ ).stdout.trim();
+
+ // The snapshot only has to precede the first save, and spawning the WP-CLI container costs
+ // seconds, so overlap it with the page load rather than queueing behind it.
+ const [ , originalPermalinks ] = await Promise.all( [
+ page.goto( 'wp-admin/options-permalink.php' ),
+ readPermalinks(),
+ ] );
+
+ const productPermalinkRadios = page.locator(
+ 'input[name="product_permalink"]'
+ );
+ const customBase = page.locator( '#woocommerce_permalink_structure' );
+ const saveChanges = page.getByRole( 'button', {
+ name: 'Save Changes',
+ } );
+ // WordPress processes the POST and redirects back, so the assertions after a save have to
+ // run against the reloaded document. Waiting on the POST response alone would let them
+ // resolve against the pre-submit DOM, which still shows whatever was just checked — the
+ // reload this test exists to verify would never be observed.
+ const saveAndReload = async () => {
+ const reloaded = page.waitForEvent( 'load' );
+ await saveChanges.click();
+ await reloaded;
+ };
+
+ await expect( productPermalinkRadios ).toHaveCount( 4 );
+
+ try {
+ const defaultRadio = productPermalinkRadios.nth( 0 );
+ const shopBaseRadio = productPermalinkRadios.nth( 1 );
+ const shopCategoryRadio = productPermalinkRadios.nth( 2 );
+ const defaultRow = page
+ .getByRole( 'row' )
+ .filter( { has: defaultRadio } );
+ // Derive the base from the rendered preview rather than hardcoding a translated
+ // product slug. Matching the segment before `sample-product` off the end of the URL
+ // keeps this independent of the install layout, so a subdirectory install needs no
+ // separate site-path arithmetic.
+ const defaultPreview =
+ (
+ await defaultRow
+ .locator( 'code.non-default-example' )
+ .textContent()
+ )?.trim() ?? '';
+ const previewBase = defaultPreview.match(
+ /\/([^/]+)\/sample-product\/$/
+ );
+ expect(
+ previewBase,
+ `Unexpected Default preview: ${ defaultPreview }`
+ ).not.toBeNull();
+ const expectedBareSlug = previewBase?.[ 1 ] ?? '';
+ const expectedDefaultBase = `/${ expectedBareSlug }/`;
+
+ // Establish Default as the starting point rather than assuming the store already uses
+ // it — the preceding assertion about the Custom field only holds from a known state.
+ await defaultRadio.check();
+ await saveAndReload();
+ await expect( defaultRadio ).toBeChecked();
+ await expect( customBase ).toHaveValue( expectedDefaultBase );
+
+ // Shop base and Shop base with category post their structure verbatim; Default posts an
+ // empty value and relies on the data attribute. Issue #29050 reported all three
+ // reverting to Custom base, so each one round-trips through a real save here.
+ for ( const radio of [ shopBaseRadio, shopCategoryRadio ] ) {
+ const structure = await radio.inputValue();
+
+ await radio.check();
+ await expect( customBase ).toHaveValue( structure );
+
+ await saveAndReload();
+
+ await expect( radio ).toBeChecked();
+ await expect( customBase ).toHaveValue( structure );
+ }
+
+ await defaultRadio.check();
+ await expect( defaultRadio ).toHaveValue( '' );
+ await expect( customBase ).toHaveValue( expectedDefaultBase );
+
+ await saveAndReload();
+
+ await expect( defaultRadio ).toBeChecked();
+ await expect( defaultRadio ).toHaveValue( '' );
+ await expect( customBase ).toHaveValue( expectedDefaultBase );
+
+ // A single Tab from the checked radio lands on the Custom base field, because the
+ // radios share one tab stop. Focus alone must leave the checked radio where it is:
+ // flipping it there would move a keyboard user off the structure the store uses,
+ // undoing what this screen was fixed to report.
+ const customSelection = page.locator(
+ '#woocommerce_custom_selection'
+ );
+
+ await customBase.focus();
+
+ await expect( defaultRadio ).toBeChecked();
+ await expect( customSelection ).not.toBeChecked();
+ await expect( customBase ).toHaveValue( expectedDefaultBase );
+
+ // A real click does select Custom base, and leaves the prefilled Default structure in
+ // the field. Saving from there posts that structure through the custom branch, which
+ // the save path normalizes back to Default's bare slug: the slash-prefixed form it
+ // would otherwise store builds broken rewrite rules under index.php (PATHINFO)
+ // permalinks.
+ await customBase.click();
+
+ await expect( customSelection ).toBeChecked();
+ await expect( customBase ).toHaveValue( expectedDefaultBase );
+
+ await saveAndReload();
+
+ await expect( defaultRadio ).toBeChecked();
+ await expect( customBase ).toHaveValue( expectedDefaultBase );
+
+ const storedPermalinks = JSON.parse( await readPermalinks() );
+ expect( storedPermalinks.product_base ).toBe( expectedBareSlug );
+ } finally {
+ await wpCLI(
+ `wp option update woocommerce_permalinks ${ asShellArgument(
+ originalPermalinks
+ ) } --format=json ${ optionCliFlags }`
+ );
+ // The option alone does not rebuild the persisted rewrite rules the front end matches
+ // against. Emptying them makes WordPress regenerate on the next request with every
+ // plugin loaded — `wp rewrite flush` under --skip-plugins would persist a rule set
+ // missing all plugin rewrites.
+ await wpCLI(
+ `wp option update rewrite_rules '' ${ optionCliFlags }`
+ );
+ }
+ } );
+} );
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-permalink-settings-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-permalink-settings-test.php
new file mode 100644
index 00000000000..ca35dce2ee0
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-permalink-settings-test.php
@@ -0,0 +1,481 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for WC_Admin_Permalink_Settings.
+ *
+ * @package WooCommerce\Tests\Admin
+ */
+class WC_Admin_Permalink_Settings_Test extends WC_Unit_Test_Case {
+
+ /**
+ * Set up the admin context and load the class under test.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ set_current_screen( 'options-permalink' );
+ require_once WC_ABSPATH . 'includes/admin/wc-admin-functions.php';
+ require_once WC_ABSPATH . 'includes/admin/class-wc-admin-permalink-settings.php';
+ }
+
+ /**
+ * Ensure `wc_get_page_id( 'shop' )` resolves to a real, existing post.
+ *
+ * The install-time `woocommerce_shop_page_id` option can point at a page whose row was
+ * rolled back by an earlier test's DB transaction, leaving a stale ID with no matching post.
+ *
+ * @return int Shop page ID.
+ */
+ private function ensure_shop_page(): int {
+ return (int) wc_create_page( 'shop', 'woocommerce_shop_page_id', 'Shop' );
+ }
+
+ /**
+ * Create a Shop page nested under a parent, so its URI is multi-segment (`stores/shop`).
+ *
+ * The checked-state comparison runs both the rendered structure and the stored value through
+ * wc_sanitize_permalink(), which only holds up if a multi-segment URI survives it unchanged.
+ *
+ * @return int Shop page ID.
+ */
+ private function ensure_nested_shop_page(): int {
+ $parent_id = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_title' => 'Stores',
+ 'post_name' => 'stores',
+ 'post_status' => 'publish',
+ )
+ );
+ $shop_id = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_title' => 'Shop',
+ 'post_name' => 'shop',
+ 'post_status' => 'publish',
+ 'post_parent' => $parent_id,
+ )
+ );
+
+ update_option( 'woocommerce_shop_page_id', $shop_id );
+
+ return (int) $shop_id;
+ }
+
+ /**
+ * Translate the product slug to French, but only for requests running in fr_FR.
+ *
+ * Lets a test tell the two locales apart: the site-locale value stays `product`, so any
+ * `produit` that reaches the stored option or the comparison came from the request locale.
+ */
+ private function activate_french_product_slug_translation(): void {
+ $translate_product_slug = static function ( string $translation, string $text, string $context, string $domain ): string {
+ if ( 'woocommerce' === $domain && 'slug' === $context && 'product' === $text && 'fr_FR' === determine_locale() ) {
+ return 'produit';
+ }
+
+ return $translation;
+ };
+
+ add_filter( 'gettext_with_context', $translate_product_slug, 10, 4 );
+ }
+
+ /**
+ * Simulate an admin request whose user locale (fr_FR) diverges from the en_US site locale.
+ *
+ * This is the divergence the fix closes: WordPress resolves admin translations in the current
+ * user's language, while both the save path and the checked-state comparison must resolve the
+ * persisted slug in the site's language.
+ */
+ private function set_up_french_admin_user(): void {
+ $user_id = self::factory()->user->create(
+ array(
+ 'role' => 'administrator',
+ 'locale' => 'fr_FR',
+ )
+ );
+ wp_set_current_user( $user_id );
+
+ $this->assertSame( 'en_US', get_locale(), 'The site locale should remain English.' );
+ $this->assertSame( 'fr_FR', determine_locale(), 'The admin request should use the current user locale.' );
+ }
+
+ /**
+ * Save a product permalink choice through the real save path and render the settings HTML.
+ *
+ * WordPress's own Permalinks page redirects after processing the POST (see
+ * `wp-admin/options-permalink.php`), so a save and its resulting render never happen on the
+ * same `WC_Admin_Permalink_Settings` instance in production. Mirror that with two separate
+ * instantiations rather than reusing one.
+ *
+ * Both posted values are typed loosely on purpose: the fields are free-form request input, and
+ * some tests post arrays to exercise the non-string fallbacks.
+ *
+ * @param mixed $product_permalink Posted `product_permalink` radio value.
+ * @param mixed $product_permalink_structure Posted `product_permalink_structure` value, or null to leave the field out.
+ * @return string Rendered settings HTML.
+ */
+ private function save_and_render( $product_permalink, $product_permalink_structure = null ): string {
+ $_POST['permalink_structure'] = '';
+ $_POST['wc-permalinks-nonce'] = wp_create_nonce( 'wc-permalinks' );
+ $_POST['woocommerce_product_category_slug'] = 'product-category';
+ $_POST['woocommerce_product_tag_slug'] = 'product-tag';
+ $_POST['woocommerce_product_attribute_slug'] = '';
+ $_POST['product_permalink'] = $product_permalink;
+
+ if ( null !== $product_permalink_structure ) {
+ $_POST['product_permalink_structure'] = $product_permalink_structure;
+ } else {
+ unset( $_POST['product_permalink_structure'] );
+ }
+
+ // First request: the save-time instance persists the new structure and is discarded.
+ new WC_Admin_Permalink_Settings();
+
+ $_POST = array();
+
+ // Second request (post-redirect): a fresh instance reads back the now-persisted value.
+ return $this->render_settings();
+ }
+
+ /**
+ * Render the permalink settings section HTML from a fresh instance.
+ *
+ * @return string Rendered settings HTML.
+ */
+ private function render_settings(): string {
+ $sut = new WC_Admin_Permalink_Settings();
+
+ return (string) $this->capture_output_from( array( $sut, 'settings' ) );
+ }
+
+ /**
+ * Parse rendered settings HTML into an XPath query object.
+ *
+ * @param string $html Rendered settings HTML.
+ * @return DOMXPath Query object for the parsed document.
+ */
+ private function get_xpath( string $html ): DOMXPath {
+ $document = new DOMDocument();
+ $previous_state = libxml_use_internal_errors( true );
+ $loaded = $document->loadHTML( $html );
+ libxml_clear_errors();
+ libxml_use_internal_errors( $previous_state );
+
+ $this->assertTrue( $loaded, 'The permalink settings output should be valid enough for DOM parsing.' );
+
+ return new DOMXPath( $document );
+ }
+
+ /**
+ * Assert that exactly one of the four product permalink radios is checked, and that it's the expected one.
+ *
+ * @param string $html Rendered settings HTML.
+ * @param string $expected_id One of the $labels entries.
+ * @param string[] $labels Rows expected to render, in document order. Defaults to all four.
+ */
+ private function assert_only_radio_checked( string $html, string $expected_id, array $labels = array( 'default', 'shop_base', 'shop_base_category', 'custom' ) ): void {
+ $xpath = $this->get_xpath( $html );
+ $radios = $xpath->query( '//input[@name="product_permalink"]' );
+
+ $this->assertSame( count( $labels ), $radios->length, 'Unexpected number of product_permalink radios in the rendered markup.' );
+
+ $checked_labels = array();
+
+ foreach ( $radios as $index => $radio ) {
+ if ( $radio->hasAttribute( 'checked' ) ) {
+ $checked_labels[] = $labels[ $index ];
+ }
+ }
+
+ $this->assertSame(
+ array( $expected_id ),
+ $checked_labels,
+ "Expected only the '{$expected_id}' radio to be checked."
+ );
+ }
+
+ /**
+ * Issue #29050: every predefined structure reverted to "Custom base" on the next render,
+ * because the comparison used the raw radio values while the save path stored
+ * wc_sanitize_permalink() output — and mapped the Default radio's empty value to a slug.
+ *
+ * @testdox Should keep the saved structure checked, and store the same value as before, for every choice.
+ *
+ * @testWith ["default"]
+ * ["shop_base"]
+ * ["shop_base_category"]
+ * ["custom"]
+ *
+ * @param string $choice Which product permalink option to save.
+ */
+ public function test_saved_structure_stays_checked( string $choice ): void {
+ $base_slug = urldecode( get_page_uri( $this->ensure_shop_page() ) );
+
+ $cases = array(
+ 'default' => array( '', null, 'product' ),
+ 'shop_base' => array( '/' . trailingslashit( $base_slug ), null, '/' . $base_slug ),
+ 'shop_base_category' => array( '/' . trailingslashit( $base_slug ) . trailingslashit( '%product_cat%' ), null, '/' . $base_slug . '/%product_cat%' ),
+ 'custom' => array( 'custom', 'widgets', '/widgets' ),
+ );
+
+ list( $posted_base, $posted_structure, $expected_stored ) = $cases[ $choice ];
+
+ $html = $this->save_and_render( $posted_base, $posted_structure );
+
+ $this->assertSame( $expected_stored, get_option( 'woocommerce_permalinks' )['product_base'], 'The stored product base must not change.' );
+ $this->assert_only_radio_checked( $html, $choice );
+ }
+
+ /**
+ * settings_save() resolves the Default slug in the site locale; settings() has to compare
+ * against the same locale, or an administrator browsing the admin in their own language saves
+ * one translation and is shown the comparison against another.
+ *
+ * @testdox Should keep "Default" checked when the user and site locales differ.
+ */
+ public function test_default_structure_stays_checked_when_user_and_site_locales_differ(): void {
+ $this->ensure_shop_page();
+ $this->set_up_french_admin_user();
+ $this->activate_french_product_slug_translation();
+
+ $html = $this->save_and_render( '' );
+
+ $this->assertSame( 'product', get_option( 'woocommerce_permalinks' )['product_base'], 'The Default base should be stored in the site locale.' );
+ $this->assert_only_radio_checked( $html, 'default' );
+ }
+
+ /**
+ * The Default radio keeps posting an empty value, so the payload stays byte-identical to what
+ * every earlier version submitted; the resolved structure moves to a data attribute that only
+ * the Custom-base field reads.
+ *
+ * @testdox Should expose the Default structure without changing the value it submits.
+ */
+ public function test_default_radio_exposes_its_structure_without_changing_its_value(): void {
+ $this->ensure_shop_page();
+
+ $xpath = $this->get_xpath( $this->save_and_render( '' ) );
+ $default_radio = $xpath->query( '(//input[@name="product_permalink"])[1]' )->item( 0 );
+ $custom_input = $xpath->query( '//input[@id="woocommerce_permalink_structure"]' )->item( 0 );
+
+ $this->assertInstanceOf( DOMElement::class, $default_radio );
+ $this->assertInstanceOf( DOMElement::class, $custom_input );
+ $this->assertSame( '', $default_radio->getAttribute( 'value' ), 'The Default radio value must remain empty.' );
+ $this->assertSame( '/product/', $default_radio->getAttribute( 'data-permalink-structure' ) );
+ $this->assertSame( '/product/', $custom_input->getAttribute( 'value' ) );
+ }
+
+ /**
+ * The Custom base field is the next tab stop after the radio group, and focusing it selects
+ * Custom base — so a Tab keystroke is enough to post the field's prefilled Default structure
+ * through the custom branch. That branch prepends a slash, and the slash-prefixed form is not
+ * interchangeable with the bare slug the Default radio stores: under index.php (PATHINFO)
+ * permalinks it produces an `index.php//product/%product%` permastruct whose URLs do not
+ * resolve. The save path therefore converges the two, storing Default's bare form.
+ *
+ * @testdox Should store the bare Default base and keep "Default" checked when its own structure is saved through the Custom base field.
+ */
+ public function test_default_structure_saved_as_a_custom_base_is_normalized(): void {
+ $this->ensure_shop_page();
+
+ $xpath = $this->get_xpath( $this->render_settings() );
+ $custom_input = $xpath->query( '//input[@id="woocommerce_permalink_structure"]' )->item( 0 );
+ $this->assertInstanceOf( DOMElement::class, $custom_input );
+
+ $html = $this->save_and_render( 'custom', $custom_input->getAttribute( 'value' ) );
+
+ $this->assertSame( 'product', get_option( 'woocommerce_permalinks' )['product_base'], 'The Default-equivalent custom base should be normalized to the bare slug.' );
+ $this->assert_only_radio_checked( $html, 'default' );
+ }
+
+ /**
+ * A pre-existing slash-prefixed `/product` — persisted by versions that stored the Tab-saved
+ * Default structure verbatim — is reported honestly as a Custom base rather than as Default:
+ * under PATHINFO permalinks the stored form genuinely behaves differently, and nothing is
+ * rewritten on render. Saving any predefined choice from there converges the stored value.
+ *
+ * @testdox Should report a legacy slash-prefixed Default base as a Custom base until a save converges it.
+ */
+ public function test_legacy_slash_prefixed_default_base_shows_as_custom(): void {
+ $this->ensure_shop_page();
+
+ $permalinks = (array) get_option( 'woocommerce_permalinks', array() );
+ $permalinks['product_base'] = '/product';
+ update_option( 'woocommerce_permalinks', $permalinks );
+
+ $html = $this->render_settings();
+ $custom_input = $this->get_xpath( $html )->query( '//input[@id="woocommerce_permalink_structure"]' )->item( 0 );
+
+ $this->assertSame( '/product', get_option( 'woocommerce_permalinks' )['product_base'], 'The render must not rewrite the stored value.' );
+ $this->assertInstanceOf( DOMElement::class, $custom_input );
+ $this->assertSame( '/product/', $custom_input->getAttribute( 'value' ) );
+ $this->assert_only_radio_checked( $html, 'custom' );
+
+ // A save posting that same value through the custom branch converges it to the bare form.
+ $this->assert_only_radio_checked( $this->save_and_render( 'custom', '/product/' ), 'default' );
+ $this->assertSame( 'product', get_option( 'woocommerce_permalinks' )['product_base'] );
+ }
+
+ /**
+ * @testdox Should keep "Shop base" checked when the Shop page is nested under a parent.
+ */
+ public function test_shop_base_stays_checked_for_a_nested_shop_page(): void {
+ $base_slug = urldecode( get_page_uri( $this->ensure_nested_shop_page() ) );
+ $this->assertSame( 'stores/shop', $base_slug, 'The fixture should produce a multi-segment page URI.' );
+
+ $html = $this->save_and_render( '/' . trailingslashit( $base_slug ) );
+
+ $this->assertSame( '/stores/shop', get_option( 'woocommerce_permalinks' )['product_base'] );
+ $this->assert_only_radio_checked( $html, 'shop_base' );
+ }
+
+ /**
+ * A Shop page slug equal to the default product slug makes "Default" and "Shop base"
+ * indistinguishable once stored: both persist the bare default base, so the checked-state
+ * search — which maps a stored base back to whichever predefined choice would persist it —
+ * has two valid answers and reports the first.
+ *
+ * Reporting "Shop base" instead would only move the wrong label to the merchant who picked
+ * Default. The two forms cannot be told apart at storage either: the slashed `/product` that
+ * would distinguish them is the shape that breaks PATHINFO permalinks, which is why the base
+ * is normalized to the bare form in the first place.
+ *
+ * Nothing downstream depends on which label renders — the stored base is byte-identical, so
+ * the product URLs and the derived `use_verbose_page_rules` flag are too.
+ *
+ * @testdox Should report "Default" when the Shop slug makes both choices store the same base.
+ */
+ public function test_shop_base_equal_to_the_default_base_reports_as_default(): void {
+ $shop_id = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_title' => 'Product',
+ 'post_name' => 'product',
+ 'post_status' => 'publish',
+ )
+ );
+ update_option( 'woocommerce_shop_page_id', $shop_id );
+
+ $base_slug = urldecode( get_page_uri( $shop_id ) );
+ $this->assertSame( 'product', $base_slug, 'The fixture Shop page should share the default product slug.' );
+
+ $html = $this->save_and_render( '/' . trailingslashit( $base_slug ) );
+
+ $this->assertSame( 'product', get_option( 'woocommerce_permalinks' )['product_base'], 'Both choices store the bare default base.' );
+ $this->assert_only_radio_checked( $html, 'default' );
+ }
+
+
+ /**
+ * A custom base is normalized before it is stored: every `#` is removed so the base cannot
+ * open a URL fragment, and each run of slashes collapses into one.
+ *
+ * @testdox Should collapse repeated slashes and remove hashes from a custom base.
+ */
+ public function test_custom_base_repeated_slashes_and_hashes_are_normalized(): void {
+ $this->ensure_shop_page();
+
+ $html = $this->save_and_render( 'custom', '//widgets///gad#gets' );
+
+ $this->assertSame( '/widgets/gadgets', get_option( 'woocommerce_permalinks' )['product_base'] );
+ $this->assert_only_radio_checked( $html, 'custom' );
+ }
+
+ /**
+ * A base of nothing but the category token gives products the same URL shape as the category
+ * archives they sit under, so the two collide. The save path prefixes the default base rather
+ * than storing the token alone. Guard carried unchanged from #13374.
+ *
+ * @testdox Should prefix the default base when a custom base is nothing but the category token.
+ *
+ * @testWith ["%product_cat%"]
+ * ["/%product_cat%/"]
+ * ["//%product_cat%//"]
+ *
+ * @param string $posted_structure Custom base as posted by the form.
+ */
+ public function test_custom_base_of_only_the_category_token_is_prefixed( string $posted_structure ): void {
+ $this->ensure_shop_page();
+
+ $html = $this->save_and_render( 'custom', $posted_structure );
+
+ $this->assertSame( '/product/%product_cat%', get_option( 'woocommerce_permalinks' )['product_base'] );
+ $this->assert_only_radio_checked( $html, 'custom' );
+ }
+
+ /**
+ * The Shop rows are gated on wc_get_page_id( 'shop' ), which returns 0 when the
+ * woocommerce_get_shop_page_id filter yields a truthy non-numeric value. A stored base
+ * matching a hidden Shop row must fall back to Custom base — otherwise no rendered radio is
+ * checked at all.
+ *
+ * @testdox Should check "Custom base" when the stored structure's Shop row is not rendered.
+ */
+ public function test_shop_structure_falls_back_to_custom_when_shop_rows_are_hidden(): void {
+ $permalinks = (array) get_option( 'woocommerce_permalinks', array() );
+ $permalinks['product_base'] = '/shop';
+ update_option( 'woocommerce_permalinks', $permalinks );
+
+ add_filter( 'woocommerce_get_shop_page_id', static fn() => 'abc' );
+
+ // Only the Default and Custom base rows render without a Shop page.
+ $this->assert_only_radio_checked( $this->render_settings(), 'custom', array( 'default', 'custom' ) );
+ }
+
+ /**
+ * A custom base carrying no usable characters sanitizes down to the empty string, and an empty
+ * product_base never survives: wc_get_permalink_structure() drops it and refills the option
+ * from the request locale, outside any locale window, then writes it back. An administrator
+ * browsing in their own language therefore persisted that language's slug. Resolving to the
+ * site-locale default at save time keeps the stored value deterministic.
+ *
+ * The locales have to diverge for this to be observable: when they agree, the refill produces
+ * the same slug the fix stores and nothing looks wrong.
+ *
+ * @testdox Should store the site-locale Default base when the posted custom structure sanitizes to nothing.
+ *
+ * @testWith [""]
+ * [" "]
+ * ["###"]
+ * ["/"]
+ * ["///"]
+ *
+ * @param string $posted_structure Posted `product_permalink_structure` value.
+ */
+ public function test_custom_base_that_sanitizes_to_nothing_falls_back_to_the_default_base( string $posted_structure ): void {
+ $this->ensure_shop_page();
+ $this->set_up_french_admin_user();
+ $this->activate_french_product_slug_translation();
+
+ $html = $this->save_and_render( 'custom', $posted_structure );
+
+ $this->assertSame( 'product', get_option( 'woocommerce_permalinks' )['product_base'], 'An empty base must never reach the option, where the request locale would refill it.' );
+ $this->assert_only_radio_checked( $html, 'default' );
+ }
+
+ /**
+ * Both permalink fields are free-form request input; an array reaching trim() or
+ * wc_sanitize_permalink() is a fatal, since both are declared to take a string. The custom
+ * branch has its own path: a scalar 'custom' radio with an array structure field used to store
+ * '/', which wc_sanitize_permalink() collapses to '', leaving wc_get_permalink_structure() to
+ * refill the option in the next request's locale. Both now resolve to the default base
+ * directly, in the site locale.
+ *
+ * @testdox Should fall back to the Default base when a posted permalink field is not a string.
+ *
+ * @testWith [["custom"], ["widgets"]]
+ * ["custom", ["widgets"]]
+ *
+ * @param mixed $product_permalink Posted `product_permalink` value.
+ * @param mixed $product_permalink_structure Posted `product_permalink_structure` value.
+ */
+ public function test_non_string_posted_fields_fall_back_to_the_default_base( $product_permalink, $product_permalink_structure ): void {
+ $this->ensure_shop_page();
+
+ $html = $this->save_and_render( $product_permalink, $product_permalink_structure );
+
+ $this->assertSame( 'product', get_option( 'woocommerce_permalinks' )['product_base'] );
+ $this->assert_only_radio_checked( $html, 'default' );
+ }
+}