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' );
+	}
+}