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.
 	 */