Commit cbcaa501889 for woocommerce
commit cbcaa501889dd76bddba1f268bf125f4715af6d9
Author: Ján Mikláš <neosinner@gmail.com>
Date: Wed Nov 11 16:28:30 2026 +0100
Fix language-dependent Shop subpage rewrites (#67938)
* Fix language-filtered Shop descendant queries
* Preserve the stored Shop root during rule generation
* Add changelog for Shop subpage rewrite fix
* Limit Shop descendant lookup and flush for the stored Shop page
Query Shop descendants one level at a time instead of reading every page,
keep the status set of the previous 'any' lookup, and flush rewrite rules
when a descendant of any Shop root used for rule generation is edited.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HvTz3BNu4Ya3tLEcqQudZ
* Fix Shop roots sharing a slug and simplify tests
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/wooplug-7509-shop-subpage-rewrite-languages b/plugins/woocommerce/changelog/wooplug-7509-shop-subpage-rewrite-languages
new file mode 100644
index 00000000000..2aac3785dfd
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-7509-shop-subpage-rewrite-languages
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Preserve Shop subpage URLs when rewrite rules are regenerated in another language.
diff --git a/plugins/woocommerce/includes/wc-core-functions.php b/plugins/woocommerce/includes/wc-core-functions.php
index d1967fa0857..6b285bce9e8 100644
--- a/plugins/woocommerce/includes/wc-core-functions.php
+++ b/plugins/woocommerce/includes/wc-core-functions.php
@@ -1011,6 +1011,8 @@ function wc_get_page_children( $page_id ) {
* @return void
*/
function flush_rewrite_rules_on_shop_page_save() {
+ global $wpdb;
+
$screen = get_current_screen();
$screen_id = $screen ? $screen->id : '';
@@ -1024,10 +1026,13 @@ function flush_rewrite_rules_on_shop_page_save() {
return;
}
- $post_id = intval( $_GET['post'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
- $shop_page_id = wc_get_page_id( 'shop' );
+ $post_id = intval( $_GET['post'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+
+ // Use the same Shop roots as wc_fix_rewrite_rules(), and walk up from the edited page so query filters cannot hide it.
+ $stored_shop_page_id = absint( $wpdb->get_var( "SELECT option_value FROM {$wpdb->options} WHERE option_name = 'woocommerce_shop_page_id'" ) );
+ $shop_page_ids = array( wc_get_page_id( 'shop' ), absint( get_option( 'woocommerce_shop_page_id' ) ), $stored_shop_page_id );
- if ( $shop_page_id === $post_id || in_array( $post_id, wc_get_page_children( $shop_page_id ), true ) ) {
+ if ( $post_id > 0 && array_intersect( array_merge( array( $post_id ), get_post_ancestors( $post_id ) ), $shop_page_ids ) ) {
do_action( 'woocommerce_flush_rewrite_rules' );
}
}
@@ -1041,7 +1046,7 @@ add_action( 'admin_footer', 'flush_rewrite_rules_on_shop_page_save' );
* @return array
*/
function wc_fix_rewrite_rules( $rules ) {
- global $wp_rewrite;
+ global $wp_rewrite, $wpdb;
$permalinks = wc_get_permalink_structure();
@@ -1054,46 +1059,55 @@ function wc_fix_rewrite_rules( $rules ) {
}
}
- $shop_page_id = wc_get_page_id( 'shop' );
- $product_base = trim( rawurldecode( $permalinks['product_base'] ), '/' );
- $uses_shop_as_base = false;
+ $shop_page_id = wc_get_page_id( 'shop' );
+ $product_base = trim( rawurldecode( $permalinks['product_base'] ), '/' );
+ $parent_ids = array();
// 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 ) );
+ // Read the stored ID directly too: get_option() can already return a translated Shop page.
+ $stored_shop_page_id = absint( $wpdb->get_var( "SELECT option_value FROM {$wpdb->options} WHERE option_name = 'woocommerce_shop_page_id'" ) );
+ $shop_page_ids = array_unique( array( absint( get_option( 'woocommerce_shop_page_id' ) ), $shop_page_id, $stored_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;
+ $parent_ids[] = $candidate_shop_page_id;
}
}
- if ( ! $uses_shop_as_base ) {
+ if ( empty( $parent_ids ) ) {
return $rules;
}
- if ( $shop_page_id ) {
- $page_rewrite_rules = array();
- $subpages = wc_get_page_children( $shop_page_id );
-
- // Subpage rules.
- foreach ( $subpages as $subpage ) {
- $uri = get_page_uri( $subpage );
- $page_rewrite_rules[ $uri . '/?$' ] = 'index.php?pagename=' . $uri;
- $wp_generated_rewrite_rules = $wp_rewrite->generate_rewrite_rules( $uri, EP_PAGES, true, true, false, false );
- foreach ( $wp_generated_rewrite_rules as $key => $value ) {
- $wp_generated_rewrite_rules[ $key ] = $value . '&pagename=' . $uri;
- }
- $page_rewrite_rules = array_merge( $page_rewrite_rules, $wp_generated_rewrite_rules );
+ // Read descendants directly so query filters cannot change which pages get persisted rules.
+ $statuses = get_post_stati( array( 'exclude_from_search' => false ) );
+ $status_format = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) );
+ $page_rewrite_rules = array();
+ $subpages = array();
+
+ // Start from the filtered Shop page and every root matching the persisted product base, one level per query.
+ $parent_ids = array_filter( array_unique( array_merge( array( $shop_page_id ), $parent_ids ) ), static fn( $page_id ) => $page_id > 0 );
+ while ( $parent_ids ) {
+ $parent_format = implode( ', ', array_fill( 0, count( $parent_ids ), '%d' ) );
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- The interpolated values are placeholder lists.
+ $page_ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'page' AND post_parent IN ( {$parent_format} ) AND post_status IN ( {$status_format} )", array_merge( $parent_ids, $statuses ) ) );
+ $parent_ids = array_diff( array_map( 'absint', $page_ids ), $subpages );
+ $subpages = array_merge( $subpages, $parent_ids );
+ }
+
+ // Subpage rules.
+ foreach ( $subpages as $subpage ) {
+ $uri = get_page_uri( $subpage );
+ $page_rewrite_rules[ $uri . '/?$' ] = 'index.php?pagename=' . $uri;
+ $wp_generated_rewrite_rules = $wp_rewrite->generate_rewrite_rules( $uri, EP_PAGES, true, true, false, false );
+ foreach ( $wp_generated_rewrite_rules as $key => $value ) {
+ $wp_generated_rewrite_rules[ $key ] = $value . '&pagename=' . $uri;
}
-
- // Merge with rules.
- $rules = array_merge( $page_rewrite_rules, $rules );
+ $page_rewrite_rules = array_merge( $page_rewrite_rules, $wp_generated_rewrite_rules );
}
- return $rules;
+ return array_merge( $page_rewrite_rules, $rules );
}
add_filter( 'rewrite_rules_array', 'wc_fix_rewrite_rules' );
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 426cfabed09..3583aab858f 100644
--- a/plugins/woocommerce/tests/php/includes/wc-core-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-core-functions-test.php
@@ -384,6 +384,135 @@ class WC_Core_Functions_Test extends \WC_Unit_Test_Case {
$this->assertSame( 'index.php?pagename=boutique/sale', $rules['boutique/sale/?$'] );
}
+ /**
+ * @testdox Shop subpage rules follow the persisted base despite language filters.
+ * @testWith ["", "shop", false]
+ * ["woocommerce_get_shop_page_id", "shop", true]
+ * ["option_woocommerce_shop_page_id", "shop", true]
+ * ["woocommerce_get_shop_page_id", "boutique", false]
+ * ["option_woocommerce_shop_page_id", "boutique", false]
+ *
+ * @param string $shop_filter Filter translating the Shop page ID.
+ * @param string $base Persisted product base.
+ * @param bool $expect_other_root Whether the other Shop tree should receive rules.
+ */
+ public function test_wc_fix_rewrite_rules_ignores_request_language( string $shop_filter, string $base, bool $expect_other_root ): void {
+ $shop_ids = array(
+ 'shop' => $this->create_shop_page_tree(),
+ 'boutique' => $this->create_shop_page_tree( 0, 'boutique' ),
+ );
+ $this->create_shop_page_tree( $shop_ids[ $base ], 'collection' );
+ update_option( 'woocommerce_shop_page_id', $shop_ids['shop'] );
+ update_option( 'woocommerce_permalinks', array( 'product_base' => '/' . $base . '/%product_cat%' ) );
+
+ if ( '' !== $shop_filter ) {
+ add_filter( $shop_filter, static fn() => $shop_ids['boutique'] );
+ }
+ add_action(
+ 'parse_query',
+ static function ( $query ) {
+ if ( 'page' === $query->get( 'post_type' ) ) {
+ $query->set( 'post__in', array( 0 ) );
+ }
+ }
+ );
+
+ $this->assertSame( array(), wc_get_page_children( $shop_ids[ $base ] ), 'The language filter should hide pages from the public page helper.' );
+ $rules = wc_fix_rewrite_rules( array() );
+ $this->assertSame( 'index.php?pagename=' . $base . '/sale', $rules[ $base . '/sale/?$' ] ?? null );
+ $this->assertSame( 'index.php?pagename=' . $base . '/collection/sale', $rules[ $base . '/collection/sale/?$' ] ?? null, 'Nested descendants need rules too.' );
+ $other_base = 'shop' === $base ? 'boutique' : 'shop';
+ $this->assertSame( $expect_other_root, isset( $rules[ $other_base . '/sale/?$' ] ), 'Keep filtered Shop rules without adding an unrelated stored Shop path.' );
+ }
+
+ /**
+ * @testdox Shop subpage rules include the stored Shop when its translation shares the same slug.
+ */
+ public function test_wc_fix_rewrite_rules_includes_shop_roots_with_the_same_slug(): void {
+ $this->create_shop_page_tree();
+ add_filter( 'pre_wp_unique_post_slug', static fn( $override, $slug ) => $slug, 10, 2 );
+ $translated_shop_page_id = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_name' => 'shop',
+ 'post_status' => 'publish',
+ )
+ );
+ update_option( 'woocommerce_permalinks', array( 'product_base' => '/shop/%product_cat%' ) );
+ add_filter( 'option_woocommerce_shop_page_id', static fn() => $translated_shop_page_id );
+
+ $this->assertSame( 'shop', get_page_uri( $translated_shop_page_id ), 'The translated Shop should share the stored Shop slug.' );
+ $rules = wc_fix_rewrite_rules( array() );
+ $this->assertSame( 'index.php?pagename=shop/sale', $rules['shop/sale/?$'] ?? null );
+ }
+
+ /**
+ * @testdox Shop subpage rules exclude hidden statuses and unrelated pages.
+ */
+ public function test_wc_fix_rewrite_rules_skips_excluded_statuses_and_unrelated_pages(): void {
+ register_post_status( 'wc_hidden', array( 'exclude_from_search' => true ) );
+ $this->create_shop_page_tree( 0, 'unrelated' );
+ $shop_page_id = $this->create_shop_page_tree();
+ self::factory()->post->create(
+ array(
+ 'post_parent' => $shop_page_id,
+ 'post_type' => 'page',
+ 'post_name' => 'members',
+ 'post_status' => 'wc_hidden',
+ )
+ );
+ update_option( 'woocommerce_permalinks', array( 'product_base' => '/shop' ) );
+
+ try {
+ $rules = wc_fix_rewrite_rules( array() );
+ } finally {
+ unset( $GLOBALS['wp_post_statuses']['wc_hidden'] );
+ }
+
+ $this->assertArrayHasKey( 'shop/sale/?$', $rules, 'Searchable Shop children should keep their rules.' );
+ $this->assertArrayNotHasKey( 'shop/members/?$', $rules, 'A status excluded from search should not receive a rule.' );
+ $this->assertArrayNotHasKey( 'unrelated/?$', $rules, 'An unrelated page should not receive a rule.' );
+ $this->assertArrayNotHasKey( 'unrelated/sale/?$', $rules, 'Unrelated descendants should not receive rules.' );
+ }
+
+ /**
+ * @testdox Editing a child of the stored Shop page flushes rewrite rules even when the Shop page is filtered.
+ * @testWith ["woocommerce_get_shop_page_id"]
+ * ["option_woocommerce_shop_page_id"]
+ *
+ * @param string $shop_filter Filter translating the Shop page ID.
+ */
+ public function test_flush_rewrite_rules_on_shop_page_save_uses_the_stored_shop_page( string $shop_filter ): void {
+ $boutique_page_id = $this->create_shop_page_tree( 0, 'boutique' );
+ $shop_page_id = $this->create_shop_page_tree();
+ $child_id = self::factory()->post->create(
+ array(
+ 'post_parent' => $shop_page_id,
+ 'post_type' => 'page',
+ )
+ );
+ $grandchild_id = self::factory()->post->create(
+ array(
+ 'post_parent' => $child_id,
+ 'post_type' => 'page',
+ )
+ );
+ $unrelated_id = self::factory()->post->create( array( 'post_type' => 'page' ) );
+ add_filter( $shop_filter, static fn() => $boutique_page_id );
+ remove_all_actions( 'woocommerce_flush_rewrite_rules' );
+ $flushes_before = did_action( 'woocommerce_flush_rewrite_rules' );
+ set_current_screen( 'page' );
+ $_GET['action'] = 'edit';
+
+ $_GET['post'] = $unrelated_id;
+ flush_rewrite_rules_on_shop_page_save();
+ $this->assertSame( $flushes_before, did_action( 'woocommerce_flush_rewrite_rules' ), 'An unrelated page should not flush rewrite rules.' );
+
+ $_GET['post'] = $grandchild_id;
+ flush_rewrite_rules_on_shop_page_save();
+ $this->assertSame( $flushes_before + 1, did_action( 'woocommerce_flush_rewrite_rules' ), 'A descendant of the stored Shop page should flush rewrite rules.' );
+ }
+
/**
* Test wc_ascii_uasort_comparison() function.
*/