Commit 2c4061231ff for woocommerce
commit 2c4061231ffdfd4939a38d0793e7efe44be79b13
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Wed May 13 11:36:02 2026 +0300
Hide the WC page title on the Review Order page (#64825)
* Hide the WC page title on the Review Order page
The Review Order page is hosted on a wp_posts entry that
wc_create_pages() seeds during the 10.8.0 update. Themes render the
page's title in the chrome -- block themes through the core/post-title
block, classic themes through the_title in the page template. The
shortcode body already prints its own <h1> ("Review your order" or the
empty-state heading), so visitors saw the title twice and screen
readers landed on two H1s.
Endpoint::gate_request() now flips a `suppress_title` flag once the
request is confirmed to be a real, authorised review-order render. Two
filters wired in init() bail unless that flag is on:
- the_title is emptied for the Review Order page id when the call is
inside the main loop, so the page heading slot is dropped but
wp_get_document_title() (which reads the post title outside the loop)
keeps the <title> tag meaningful. Nav menu items pointing at the
page also stay intact because they aren't in the main loop.
- render_block drops the markup for core/post-title blocks for the
rest of the request, covering block themes whose page title is
rendered as a block rather than through the_title.
Editor previews and direct visits to the host page without an order
id never set the flag, so admins still see the page title in the
chrome there.
* Add changefile(s) from automation for the following project(s): woocommerce
* Scope the post-title suppression to the Review Order page only
Both CodeRabbit (major) and Ferdev flagged that the previous
`render_block` filter blanked every `core/post-title` block for the
duration of the request — that hits unrelated post-title blocks in
Query Loops, related-post template parts, footer "recent posts"
panels, etc.
Switch to the block-specific filter `render_block_core/post-title`
(only fires for that block, not all blocks) and use the third arg,
the `WP_Block` instance, to read `context['postId']`. Only return ''
when the block is bound to the Review Order host page id. Otherwise
return the block markup unchanged.
`maybe_hide_page_title()` (classic themes) already had the same
narrow scope via `in_the_loop() && is_main_query()` + post_id check.
Tests reshuffled to match: the new make_block_instance() helper
produces a WP_Block mock carrying context['postId']. New cases cover
the bound-to-page path, the different-post-context path (Query Loop
iteration), the flag-off path, and a defensive guard for when the
third arg isn't a WP_Block.
* Address Ferdev's remaining review comments
- Drop the $suppress_title property entirely. The filters now register
inside gate_request() once auth passes; their presence on the hook
list IS the gate. Avoids the per-instance flag concern and skips the
filter cost on every unrelated page render.
- Remove the redundant $page_id <= 0 check in maybe_hide_page_title()
— gate_request() already verified the page exists by the time the
filter is registered.
- Tighten the docblocks: dedupe the "after gate_request() has confirmed
the request" explanation by referencing registration once instead of
repeating it on every method; switch the post-id wording from "on a
different request" to "within the same render" to match the actual
scope; add @since 10.9.0 to both methods.
- Test coverage:
- New test_gate_request_registers_title_filters_after_authorisation
drives gate_request() with the required globals staged and asserts
has_filter() for both hooks (covers the line Ferdev pointed at).
- Split the old "leaves out-of-loop titles" case into two: one for
in_the_loop() === false with is_main_query() === true (document
title path), one for is_main_query() === false with in_the_loop()
=== true (secondary-query loop).
- Drop the now-obsolete "leaves title when flag off" cases — the
flag no longer exists; the equivalent behaviour is that the
filter isn't on the hook list at all on unrelated renders, which
the new gate_request test covers.
* Tag the title-suppression methods @since 10.8.0 (release target)
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/64825-wooplug-6686-hide-the-wc-page-title-on-the-review-order-page b/plugins/woocommerce/changelog/64825-wooplug-6686-hide-the-wc-page-title-on-the-review-order-page
new file mode 100644
index 00000000000..5bcfbcaed85
--- /dev/null
+++ b/plugins/woocommerce/changelog/64825-wooplug-6686-hide-the-wc-page-title-on-the-review-order-page
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+Hide the duplicate page title heading on the Customer Review Order page so the shortcode body's heading is the only H1 visitors and screen readers encounter.
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php b/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
index 6007decc9fe..8d780b945bf 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
@@ -58,7 +58,10 @@ class Endpoint {
/**
* Wire the endpoint into WordPress.
*
- * Auto-called by the WC dependency container after instantiation.
+ * Auto-called by the WC dependency container after instantiation. The
+ * title-suppression filters are deliberately NOT registered here; they
+ * land inside `gate_request()` once the request is confirmed to be an
+ * authorised review-order render, so they never run on unrelated pages.
*
* @internal
*/
@@ -102,6 +105,81 @@ class Endpoint {
);
}
+ /**
+ * Suppress the theme-rendered page title for classic themes on the
+ * Review Order page.
+ *
+ * The page body (`templates/order/customer-review-order.php` and the
+ * empty-state template) already prints its own `<h1>`, so the chrome
+ * heading would duplicate the text both visually and for screen readers.
+ *
+ * `gate_request()` registers this filter only after the request passes
+ * the auth check, so on any unrelated render it isn't even on the hook.
+ * Two in-method guards narrow the scope to the page title slot of the
+ * Review Order render itself:
+ *
+ * - The post id must match the Review Order page id, so within the same
+ * render a nav menu item or "recent posts" widget pointing at another
+ * post stays intact.
+ * - `in_the_loop() && is_main_query()` keeps the filter scoped to the
+ * actual page title slot. WP's `wp_get_document_title()` reads the
+ * post title outside the loop, so the `<title>` tag stays meaningful.
+ *
+ * @since 10.8.0
+ *
+ * @param string|mixed $title Title being rendered.
+ * @param int|mixed $post_id Post id the title belongs to.
+ * @return string|mixed
+ */
+ public function maybe_hide_page_title( $title, $post_id = 0 ) {
+ $page_id = (int) wc_get_page_id( self::PAGE_KEY );
+ if ( (int) $post_id !== $page_id ) {
+ return $title;
+ }
+ if ( ! in_the_loop() || ! is_main_query() ) {
+ return $title;
+ }
+ return '';
+ }
+
+ /**
+ * Suppress the `core/post-title` block on block themes when it is bound
+ * to the Review Order page itself.
+ *
+ * Block themes render the page title through `core/post-title` rather
+ * than `the_title`, so the classic-theme filter above doesn't catch it.
+ * Two guards keep the suppression narrow (registration is gated by
+ * `gate_request()` so the filter isn't even on the hook for unrelated
+ * renders):
+ *
+ * - The hook is `render_block_core/post-title` so unrelated block types
+ * (headings, paragraphs, navigation, etc.) never reach this method.
+ * - The block's resolved `context['postId']` must match the Review Order
+ * page id, so a `core/post-title` rendered inside a Query Loop, a
+ * related-posts template part, or a footer "recent posts" panel for a
+ * different post on the same render is untouched.
+ *
+ * @since 10.8.0
+ *
+ * @param string|mixed $block_content Block markup.
+ * @param array<string,mixed> $block Parsed block (unused but kept for filter signature).
+ * @param \WP_Block|mixed|null $instance Rendering instance carrying context.
+ * @return string|mixed
+ */
+ public function maybe_hide_post_title_block( $block_content, $block, $instance = null ) {
+ unset( $block );
+
+ if ( ! $instance instanceof \WP_Block ) {
+ return $block_content;
+ }
+ $page_id = (int) wc_get_page_id( self::PAGE_KEY );
+ $block_postid = isset( $instance->context['postId'] ) ? (int) $instance->context['postId'] : 0;
+ if ( $block_postid !== $page_id ) {
+ return $block_content;
+ }
+ return '';
+ }
+
/**
* Keep the Review Order page out of nav menus that have "Auto add new
* top-level pages" enabled.
@@ -253,6 +331,17 @@ class Endpoint {
exit;
}
+ // Register the page-title suppression filters now that the request
+ // is fully authorised. Doing this here instead of `init()` keeps the
+ // filters out of every unrelated page render and removes the need
+ // for a per-instance "is this an authorised render" boolean.
+ add_filter( 'the_title', array( $this, 'maybe_hide_page_title' ), 10, 2 );
+ // Block-specific filter so only `core/post-title` is touched —
+ // `render_block` would fire for every block on the page. The third
+ // arg is the `WP_Block` instance carrying `context['postId']`, used
+ // to scope to the host page.
+ add_filter( 'render_block_core/post-title', array( $this, 'maybe_hide_post_title_block' ), 10, 3 );
+
if ( $order instanceof WC_Order ) {
$this->maybe_mark_no_actionable_rows( $order );
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php
index dd149d3f192..31c7545d821 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php
@@ -60,6 +60,7 @@ class EndpointTest extends WC_Unit_Test_Case {
if ( $wp_query instanceof WP_Query ) {
$wp_query->is_404 = false;
}
+ wp_reset_postdata();
wp_set_current_user( 0 );
parent::tearDown();
}
@@ -239,6 +240,213 @@ class EndpointTest extends WC_Unit_Test_Case {
$this->assertStringContainsString( 'woocommerce-review-order', $html );
}
+ /**
+ * Build a minimal `WP_Block` stand-in carrying the given `postId` context.
+ * Avoids constructing a real `WP_Block`, which would require fully-parsed
+ * block + registry plumbing.
+ *
+ * @param int $post_id Value to expose at `$instance->context['postId']`.
+ * @return \WP_Block
+ */
+ private function make_block_instance( int $post_id ): \WP_Block {
+ $instance = $this->getMockBuilder( \WP_Block::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $instance->context = array( 'postId' => $post_id );
+ return $instance;
+ }
+
+ /**
+ * @testdox A successful gate_request() registers the title-suppression filters so they fire on the rest of the request.
+ */
+ public function test_gate_request_registers_title_filters_after_authorisation(): void {
+ $page_id = (int) wc_get_page_id( Endpoint::PAGE_KEY );
+
+ $order = OrderHelper::create_order();
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ // Stage the globals gate_request() reads: `is_page( review_order_page_id )`
+ // for the early return, `$wp->query_vars[ QUERY_VAR ]` for the order id,
+ // and `$_GET['key']` for the order key.
+ global $wp, $wp_query, $wp_the_query;
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: singular page query so is_page() returns true.
+ $wp_query = new WP_Query( array( 'page_id' => $page_id ) );
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: matching main query.
+ $wp_the_query = $wp_query;
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: stage a fresh WP instance carrying our query var.
+ $wp = new \WP();
+ $wp->query_vars[ Endpoint::QUERY_VAR ] = (string) $order->get_id();
+ $_GET = array( 'key' => $order->get_order_key() );
+
+ // Both filters must be absent before gate runs.
+ $this->assertFalse(
+ has_filter( 'the_title', array( $this->endpoint, 'maybe_hide_page_title' ) )
+ );
+ $this->assertFalse(
+ has_filter( 'render_block_core/post-title', array( $this->endpoint, 'maybe_hide_post_title_block' ) )
+ );
+
+ $this->endpoint->gate_request();
+
+ $this->assertNotFalse(
+ has_filter( 'the_title', array( $this->endpoint, 'maybe_hide_page_title' ) )
+ );
+ $this->assertNotFalse(
+ has_filter( 'render_block_core/post-title', array( $this->endpoint, 'maybe_hide_post_title_block' ) )
+ );
+
+ remove_filter( 'the_title', array( $this->endpoint, 'maybe_hide_page_title' ), 10 );
+ remove_filter( 'render_block_core/post-title', array( $this->endpoint, 'maybe_hide_post_title_block' ), 10 );
+ }
+
+ /**
+ * @testdox maybe_hide_page_title() empties the title for the Review Order page when iterating the main loop.
+ */
+ public function test_maybe_hide_page_title_empties_review_order_page_title_in_main_loop(): void {
+ $page_id = (int) wc_get_page_id( Endpoint::PAGE_KEY );
+
+ // Stage a main query so in_the_loop() + is_main_query() both pass.
+ global $wp_query, $wp_the_query;
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: drive a fake main query.
+ $wp_query = new WP_Query( array( 'page_id' => $page_id ) );
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: drive a fake main query.
+ $wp_the_query = $wp_query;
+ $wp_query->the_post();
+
+ $this->assertSame(
+ '',
+ $this->endpoint->maybe_hide_page_title( 'Review your order', $page_id )
+ );
+ }
+
+ /**
+ * @testdox maybe_hide_page_title() leaves titles for other posts alone (e.g. a nav link on the same render).
+ */
+ public function test_maybe_hide_page_title_leaves_other_post_titles(): void {
+ $other_id = (int) wp_insert_post(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ 'post_title' => 'Sample Page',
+ )
+ );
+
+ global $wp_query, $wp_the_query;
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: drive a fake main query on a different page.
+ $wp_query = new WP_Query( array( 'page_id' => $other_id ) );
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: drive a fake main query on a different page.
+ $wp_the_query = $wp_query;
+ $wp_query->the_post();
+
+ $this->assertSame(
+ 'Sample Page',
+ $this->endpoint->maybe_hide_page_title( 'Sample Page', $other_id )
+ );
+ }
+
+ /**
+ * @testdox maybe_hide_page_title() leaves the title alone when the request is outside any loop (e.g. wp_get_document_title()).
+ */
+ public function test_maybe_hide_page_title_leaves_title_when_not_in_loop(): void {
+ $page_id = (int) wc_get_page_id( Endpoint::PAGE_KEY );
+
+ // Main query exists and `is_main_query()` is true, but `the_post()`
+ // has not been called so `in_the_loop()` returns false. This is the
+ // state `wp_get_document_title()` reads the post title in.
+ global $wp_query, $wp_the_query;
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: main query without the_post().
+ $wp_query = new WP_Query( array( 'page_id' => $page_id ) );
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: keep is_main_query() true.
+ $wp_the_query = $wp_query;
+
+ $this->assertFalse( in_the_loop() );
+ $this->assertTrue( is_main_query() );
+
+ $this->assertSame(
+ 'Review your order',
+ $this->endpoint->maybe_hide_page_title( 'Review your order', $page_id )
+ );
+ }
+
+ /**
+ * @testdox maybe_hide_page_title() leaves the title alone when the loop belongs to a secondary query (is_main_query() is false).
+ */
+ public function test_maybe_hide_page_title_leaves_title_when_not_main_query(): void {
+ $page_id = (int) wc_get_page_id( Endpoint::PAGE_KEY );
+
+ // `$wp_the_query` is some other query (an empty one is enough), so
+ // is_main_query() returns false even though we are inside `$wp_query`'s loop.
+ global $wp_query, $wp_the_query;
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: separate main query.
+ $wp_the_query = new WP_Query();
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: secondary query iterating the page.
+ $wp_query = new WP_Query( array( 'page_id' => $page_id ) );
+ $wp_query->the_post();
+
+ $this->assertTrue( in_the_loop() );
+ $this->assertFalse( is_main_query() );
+
+ $this->assertSame(
+ 'Review your order',
+ $this->endpoint->maybe_hide_page_title( 'Review your order', $page_id )
+ );
+ }
+
+ /**
+ * @testdox maybe_hide_post_title_block() empties `core/post-title` markup when bound to the Review Order page.
+ */
+ public function test_maybe_hide_post_title_block_empties_when_bound_to_review_order_page(): void {
+ $page_id = (int) wc_get_page_id( Endpoint::PAGE_KEY );
+
+ $this->assertSame(
+ '',
+ $this->endpoint->maybe_hide_post_title_block(
+ '<h1 class="wp-block-post-title">Review your order</h1>',
+ array( 'blockName' => 'core/post-title' ),
+ $this->make_block_instance( $page_id )
+ )
+ );
+ }
+
+ /**
+ * @testdox maybe_hide_post_title_block() leaves the title alone when the block is bound to a different post (e.g. inside a Query Loop).
+ */
+ public function test_maybe_hide_post_title_block_leaves_other_post_context(): void {
+ $other_post_id = (int) wp_insert_post(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ 'post_title' => 'Another page',
+ )
+ );
+
+ $markup = '<h1 class="wp-block-post-title">Another page</h1>';
+ $this->assertSame(
+ $markup,
+ $this->endpoint->maybe_hide_post_title_block(
+ $markup,
+ array( 'blockName' => 'core/post-title' ),
+ $this->make_block_instance( $other_post_id )
+ )
+ );
+ }
+
+ /**
+ * @testdox maybe_hide_post_title_block() leaves the title alone when the third arg is not a WP_Block (defensive guard).
+ */
+ public function test_maybe_hide_post_title_block_leaves_title_when_instance_missing(): void {
+ $markup = '<h1 class="wp-block-post-title">Review your order</h1>';
+ $this->assertSame(
+ $markup,
+ $this->endpoint->maybe_hide_post_title_block(
+ $markup,
+ array( 'blockName' => 'core/post-title' ),
+ null
+ )
+ );
+ }
+
/**
* @testdox Loading the page when no actionable rows remain stamps the completed-at meta.
*/