Commit eaefb7d943a for woocommerce
commit eaefb7d943a8b34363fb4aae114a8f78138732ac
Author: Ján Mikláš <neosinner@gmail.com>
Date: Fri Sep 4 15:26:43 2026 +0200
Fix Shop-base verbose page rule detection (#68089)
* Fix Shop subpage rewrite rule detection
Derive Shop-path overlap when rules are generated so stale saved flags cannot add or suppress rules after permalink or page changes. Preserve filtered Shop pages used by multilingual integrations.
* Add changelog entry for Shop rewrite rules
* Stop writing the unused use_verbose_page_rules flag
wc_fix_rewrite_rules() now derives Shop subpage rules from the current
product base and Shop page path, so the stored flag has no reader left.
Remove the write in the permalinks screen rather than leaving a derived
value nothing consumes, and keep the option key in
wc_get_permalink_structure() so the returned array shape stays stable for
extensions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169i1KYNcjwBdCCUZ2cpX8h
* Cover non-Latin Shop paths in the rewrite rule tests
WordPress stores a non-Latin page slug percent-encoded, so a Cyrillic Shop page
reaches the boundary match as %d0%bc%d0%b0... while the product base is stored
as the merchant typed it. Only the rawurldecode() on both sides makes the two
line up, and nothing covered that.
Assert the Shop child rule is added for both the encoded and the decoded form of
the product base. Both cases fail without the decode.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0169i1KYNcjwBdCCUZ2cpX8h
* Document the retained use_verbose_page_rules key in the docblock
Move the note off the default value and into wc_get_permalink_structure()'s
docblock, so extension authors reading the function's contract see that the key
is retained for compatibility, is no longer written or read by core, and now
holds whatever the last permalink save left behind.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bgy1APutteP39D5xPcrkRW
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-verbose-page-rule-detection b/plugins/woocommerce/changelog/fix-verbose-page-rule-detection
new file mode 100644
index 00000000000..6a8a3fbe8f6
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-verbose-page-rule-detection
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep Shop subpage rewrite rules in sync with the product permalink base.
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 2897d3dc42b..b638dce9fad 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-permalink-settings.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-permalink-settings.php
@@ -318,14 +318,6 @@ class WC_Admin_Permalink_Settings {
// 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 = $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;
- }
-
update_option( 'woocommerce_permalinks', $permalinks );
wc_restore_locale();
}
diff --git a/plugins/woocommerce/includes/wc-core-functions.php b/plugins/woocommerce/includes/wc-core-functions.php
index cdbcc363972..18c760a04f3 100644
--- a/plugins/woocommerce/includes/wc-core-functions.php
+++ b/plugins/woocommerce/includes/wc-core-functions.php
@@ -1054,12 +1054,26 @@ function wc_fix_rewrite_rules( $rules ) {
}
}
- // If the shop page is used as the base, we need to handle shop page subpages to avoid 404s.
- if ( ! $permalinks['use_verbose_page_rules'] ) {
+ $shop_page_id = wc_get_page_id( 'shop' );
+ $product_base = trim( rawurldecode( $permalinks['product_base'] ), '/' );
+ $uses_shop_as_base = false;
+
+ // The product base is site-wide, while multilingual extensions can filter the Shop page for the current request.
+ $shop_page_ids = array_unique( array( absint( get_option( 'woocommerce_shop_page_id' ) ), $shop_page_id ) );
+ foreach ( $shop_page_ids as $candidate_shop_page_id ) {
+ $shop_page = $candidate_shop_page_id > 0 ? get_post( $candidate_shop_page_id ) : null;
+ $shop_base = $shop_page instanceof WP_Post && 'page' === $shop_page->post_type ? trim( rawurldecode( (string) get_page_uri( $candidate_shop_page_id ) ), '/' ) : '';
+
+ if ( '' !== $shop_base && ( $shop_base === $product_base || 0 === strpos( $product_base, $shop_base . '/' ) ) ) {
+ $uses_shop_as_base = true;
+ break;
+ }
+ }
+
+ if ( ! $uses_shop_as_base ) {
return $rules;
}
- $shop_page_id = wc_get_page_id( 'shop' );
if ( $shop_page_id ) {
$page_rewrite_rules = array();
$subpages = wc_get_page_children( $shop_page_id );
@@ -2137,6 +2151,10 @@ function wc_list_pluck( $list, $callback_or_field, $index_key = null ) {
*
* This is more inline with WP core behavior which does not localize slugs.
*
+ * The `use_verbose_page_rules` key is kept for backward compatibility only. WooCommerce no longer
+ * writes or reads it - wc_fix_rewrite_rules() derives Shop subpage rules from the current product
+ * base and Shop page path instead - so its stored value is whatever the last save left behind.
+ *
* @since 3.0.0
* @return array
*/
diff --git a/plugins/woocommerce/tests/e2e/playwright.config.ts b/plugins/woocommerce/tests/e2e/playwright.config.ts
index dd88e435cbe..ae0a5bec63e 100644
--- a/plugins/woocommerce/tests/e2e/playwright.config.ts
+++ b/plugins/woocommerce/tests/e2e/playwright.config.ts
@@ -179,8 +179,8 @@ 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.
+ // Mutates the global woocommerce_permalinks option (the product base) 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
diff --git a/plugins/woocommerce/tests/e2e/tests/settings/product-permalinks.spec.ts b/plugins/woocommerce/tests/e2e/tests/settings/product-permalinks.spec.ts
index f90cad040b0..32c20d30282 100644
--- a/plugins/woocommerce/tests/e2e/tests/settings/product-permalinks.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/settings/product-permalinks.spec.ts
@@ -31,9 +31,9 @@ test.describe( 'Product permalink settings', () => {
// (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.
+ // Snapshot the whole option rather than the visible form state: the saved product base is
+ // normalized on the way in, so replaying the form values would not always restore the
+ // bytes the option started with.
const readPermalinks = async () =>
(
await wpCLI(
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
index ca35dce2ee0..fa3bf2a458c 100644
--- 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
@@ -342,7 +342,7 @@ class WC_Admin_Permalink_Settings_Test extends WC_Unit_Test_Case {
* 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.
+ * the product URLs are too.
*
* @testdox Should report "Default" when the Shop slug makes both choices store the same base.
*/
diff --git a/plugins/woocommerce/tests/php/includes/wc-core-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-core-functions-test.php
index ea8c23bc615..15578fdec2e 100644
--- a/plugins/woocommerce/tests/php/includes/wc-core-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-core-functions-test.php
@@ -10,6 +10,170 @@
*/
class WC_Core_Functions_Test extends \WC_Unit_Test_Case {
+ /**
+ * Create a Shop page with one child.
+ *
+ * @param int $parent_id Shop page parent ID.
+ * @param string $shop_slug Shop page slug.
+ * @return int Shop page ID.
+ */
+ private function create_shop_page_tree( int $parent_id = 0, string $shop_slug = 'shop' ): int {
+ $shop_page_id = self::factory()->post->create(
+ array(
+ 'post_parent' => $parent_id,
+ 'post_type' => 'page',
+ 'post_name' => $shop_slug,
+ 'post_status' => 'publish',
+ )
+ );
+ self::factory()->post->create(
+ array(
+ 'post_parent' => $shop_page_id,
+ 'post_type' => 'page',
+ 'post_name' => 'sale',
+ 'post_status' => 'publish',
+ )
+ );
+
+ update_option( 'woocommerce_shop_page_id', $shop_page_id );
+
+ return $shop_page_id;
+ }
+
+ /**
+ * @testdox Shop subpage rules should follow the current paths instead of the stored derived flag.
+ *
+ * @testWith ["/shop", true]
+ * ["/shop/%product_cat%", true]
+ * ["/webshop", false]
+ * ["/shopper", false]
+ * ["/catalog/shop", false]
+ *
+ * @param string $product_base Product permalink base.
+ * @param bool $expected Whether the Shop child rule should be added.
+ */
+ public function test_wc_fix_rewrite_rules_derives_shop_subpage_rules_from_paths( string $product_base, bool $expected ): void {
+ $this->create_shop_page_tree();
+ update_option(
+ 'woocommerce_permalinks',
+ array(
+ 'product_base' => $product_base,
+ 'use_verbose_page_rules' => ! $expected,
+ )
+ );
+
+ $original_rules = array( 'fallback/?$' => 'index.php?fallback=1' );
+ $rules = wc_fix_rewrite_rules( $original_rules );
+
+ if ( $expected ) {
+ $this->assertSame( 'shop/sale/?$', array_key_first( $rules ), 'The Shop child rule should take priority over product rules.' );
+ $this->assertSame( 'index.php?pagename=shop/sale', $rules['shop/sale/?$'] );
+ } else {
+ $this->assertSame( $original_rules, $rules, 'Unrelated product paths should not add Shop child rules.' );
+ }
+ }
+
+ /**
+ * @testdox Shop subpage rules should match a non-Latin Shop path in either encoded or decoded form.
+ *
+ * @testWith ["encoded"]
+ * ["decoded"]
+ *
+ * @param string $product_base_form Form the product base is stored in.
+ */
+ public function test_wc_fix_rewrite_rules_matches_a_non_latin_shop_path( string $product_base_form ): void {
+ // WordPress stores a non-Latin page slug percent-encoded, while the product base is stored
+ // as the merchant typed it, so the two only line up once both have been decoded.
+ $shop_page_id = $this->create_shop_page_tree( 0, 'магазин' );
+ $shop_uri = (string) get_page_uri( $shop_page_id );
+ $this->assertStringContainsString( '%d0%', $shop_uri, 'WordPress should store the non-Latin Shop slug percent-encoded.' );
+
+ $product_base = 'encoded' === $product_base_form ? $shop_uri : rawurldecode( $shop_uri );
+ update_option(
+ 'woocommerce_permalinks',
+ array(
+ 'product_base' => '/' . $product_base . '/%product_cat%',
+ 'use_verbose_page_rules' => false,
+ )
+ );
+
+ $rules = wc_fix_rewrite_rules( array( 'fallback/?$' => 'index.php?fallback=1' ) );
+
+ // Only the presence of the rule is asserted. WP_Rewrite::generate_rewrite_rules() reads the
+ // `%d0%` byte pairs of a percent-encoded slug as rewrite tags, so the target it builds for
+ // one is malformed. That predates deriving the match from the current paths.
+ $this->assertArrayHasKey(
+ $shop_uri . '/sale/?$',
+ $rules,
+ 'A non-Latin Shop path should still add the Shop child rule.'
+ );
+ }
+
+ /**
+ * @testdox Shop subpage rules should follow a changed Shop page path.
+ */
+ public function test_wc_fix_rewrite_rules_follows_shop_page_reparenting(): void {
+ $catalog_page_id = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_name' => 'catalog',
+ 'post_status' => 'publish',
+ )
+ );
+ $shop_page_id = $this->create_shop_page_tree();
+ update_option(
+ 'woocommerce_permalinks',
+ array(
+ 'product_base' => '/catalog/shop',
+ 'use_verbose_page_rules' => false,
+ )
+ );
+
+ $original_rules = array( 'fallback/?$' => 'index.php?fallback=1' );
+ $this->assertSame( $original_rules, wc_fix_rewrite_rules( $original_rules ) );
+
+ wp_update_post(
+ array(
+ 'ID' => $shop_page_id,
+ 'post_parent' => $catalog_page_id,
+ )
+ );
+
+ $rules = wc_fix_rewrite_rules( $original_rules );
+
+ $this->assertSame( 'catalog/shop/sale/?$', array_key_first( $rules ), 'The new Shop child path should take priority over product rules.' );
+ $this->assertSame( 'index.php?pagename=catalog/shop/sale', $rules['catalog/shop/sale/?$'] );
+ }
+
+ /**
+ * @testdox Shop subpage rules should keep using a filtered Shop page after matching the configured Shop path.
+ */
+ public function test_wc_fix_rewrite_rules_preserves_filtered_shop_page_rules(): void {
+ $configured_shop_page_id = $this->create_shop_page_tree();
+ $filtered_shop_page_id = $this->create_shop_page_tree( 0, 'boutique' );
+ update_option( 'woocommerce_shop_page_id', $configured_shop_page_id );
+ update_option(
+ 'woocommerce_permalinks',
+ array(
+ 'product_base' => '/shop/%product_cat%',
+ 'use_verbose_page_rules' => true,
+ )
+ );
+
+ $filter_shop_page_id = static function () use ( $filtered_shop_page_id ) {
+ return $filtered_shop_page_id;
+ };
+ add_filter( 'woocommerce_get_shop_page_id', $filter_shop_page_id );
+
+ try {
+ $rules = wc_fix_rewrite_rules( array() );
+ } finally {
+ remove_filter( 'woocommerce_get_shop_page_id', $filter_shop_page_id );
+ }
+
+ $this->assertSame( 'index.php?pagename=boutique/sale', $rules['boutique/sale/?$'] );
+ }
+
/**
* Test wc_ascii_uasort_comparison() function.
*/