From 79f2d328070daccbb15e4ed907bb82ef7f2f1f0a Mon Sep 17 00:00:00 2001 From: goldenapples Date: Wed, 28 Oct 2026 10:28:56 -0400 Subject: [PATCH 1/3] Keep query-bound search on the current page Build the search URL from the current location rather than the form action, run the search render filter after plugins such as Polylang that rewrite search actions, and carry current parameters as hidden inputs for no-JS submissions. Fixes #73. Co-Authored-By: Claude Opus 5.5 --- inc/namespace.php | 46 ++++++++++++- src/taxonomy/view.js | 48 +++++++++----- tests/e2e/search-action.spec.js | 76 ++++++++++++++++++++++ tests/mu-plugins/register-test-content.php | 23 +++++++ 4 files changed, 175 insertions(+), 18 deletions(-) create mode 100644 tests/e2e/search-action.spec.js diff --git a/inc/namespace.php b/inc/namespace.php index 55f8917..536ed71 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -36,7 +36,10 @@ function bootstrap() : void { add_action( 'init', __NAMESPACE__ . '\\register_blocks' ); // Search. - add_filter( 'render_block_core/search', __NAMESPACE__ . '\\render_block_search', 10, 3 ); + // Late, so a search block inside a Query Loop posts back to the loop even when + // another plugin rewrites search form actions at the default priority (as + // Polylang does, pointing every search form at the language's home page). + add_filter( 'render_block_core/search', __NAMESPACE__ . '\\render_block_search', 20, 3 ); // Query. add_filter( 'render_block_core/query', __NAMESPACE__ . '\\render_block_query', 10, 3 ); @@ -801,7 +804,10 @@ function render_block_search( string $block_content, array $block, \WP_Block $in $block_content->set_attribute( 'data-wp-interactive', 'query-filter' ); $block_content->set_attribute( 'data-wp-on--submit', 'actions.search' ); // Scope search to block context so multiple searchable query loops may coexist. - $block_content->set_attribute( 'data-wp-context', wp_json_encode( [ 'searchValue' => $search_value ] ) ); + $block_content->set_attribute( 'data-wp-context', wp_json_encode( [ + 'searchValue' => $search_value, + 'pageParam' => empty( $instance->context['query']['inherit'] ) ? $page_var : null, + ] ) ); $block_content->next_tag( [ 'tag_name' => 'input', 'class_name' => 'wp-block-search__input' ] ); $block_content->set_attribute( 'name', $query_var ); $block_content->set_attribute( 'inputmode', 'search' ); @@ -809,7 +815,41 @@ function render_block_search( string $block_content, array $block, \WP_Block $in $block_content->set_attribute( 'data-wp-bind--value', 'context.searchValue' ); $block_content->set_attribute( 'data-wp-on--input', 'actions.search' ); - return (string) $block_content; + return add_filter_hidden_inputs( (string) $block_content, [ $query_var, $page_var ] ); +} + +/** + * Carry the request's other parameters through a search form submitted without JavaScript. + * + * A GET form replaces the query string of its action with its own fields, so the + * filters already applied would otherwise be lost when the search is submitted. + * + * @param string $block_content Search block markup. + * @param string[] $exclude Parameters the form sets or resets itself. + * @return string Search block markup with a hidden input per carried parameter. + */ +function add_filter_hidden_inputs( string $block_content, array $exclude ) : string { + $inputs = ''; + + // Only echoed back, escaped, as the current URL already is in the form action. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + foreach ( wp_unslash( $_GET ) as $key => $value ) { + if ( in_array( (string) $key, $exclude, true ) || ! is_scalar( $value ) ) { + continue; + } + + $inputs .= sprintf( + '', + esc_attr( (string) $key ), + esc_attr( sanitize_text_field( (string) $value ) ) + ); + } + + if ( $inputs === '' ) { + return $block_content; + } + + return preg_replace( '##i', $inputs . '', $block_content, 1 ); } /** diff --git a/src/taxonomy/view.js b/src/taxonomy/view.js index ddd5940..7a976b8 100644 --- a/src/taxonomy/view.js +++ b/src/taxonomy/view.js @@ -17,14 +17,38 @@ import { */ const NAVIGATION_TIMEOUT = 30000; -const updateURL = async ( action, value, name ) => { - const url = new URL( action ); +/** + * Build the URL for a search from the page being viewed. + * + * The form's action is not used: other plugins filter search form actions + * (Polylang points them at the language's home page), and the URL may have + * moved on through client-side navigation since the form was rendered. + * + * @param {string} name Search parameter. + * @param {string} value Search term. + * @param {string|null} pageParam The query's page parameter, or null when it + * inherits the main query and pages by path. + * @return {Promise} Resolves once navigated. + */ +const updateURL = async ( name, value, pageParam ) => { + const { actions } = await import( '@wordpress/interactivity-router' ); + const { state: routerState } = store( 'core/router' ); + const url = new URL( routerState?.url || window.location.href ); + + // A search is a new set of results, so it starts on the first page. + if ( pageParam ) { + url.searchParams.delete( pageParam ); + } else { + url.searchParams.delete( 'paged' ); + url.pathname = url.pathname.replace( /\/page\/\d+\/?$/, '/' ); + } + if ( value || name === 's' ) { url.searchParams.set( name, value ); } else { url.searchParams.delete( name ); } - const { actions } = await import( '@wordpress/interactivity-router' ); + await actions.navigate( url.toString(), { timeout: NAVIGATION_TIMEOUT } ); }; @@ -52,24 +76,18 @@ store( 'query-filter', { // Scope search to block context so multiple searchable query loops may coexist. const context = getContext(); const { ref } = getElement(); - let action, name, value; - if ( ref.tagName === 'FORM' ) { - const input = ref.querySelector( 'input[type="search"]' ); - action = ref.action; - name = input.name; - value = input.value; - } else { - action = ref.closest( 'form' ).action; - name = ref.name; - value = ref.value; - } + const input = + ref.tagName === 'FORM' + ? ref.querySelector( 'input[type="search"]' ) + : ref; + const { name, value } = input; // Don't navigate if the search didn't really change. if ( value === context.searchValue ) return; context.searchValue = value; - yield updateURL( action, value, name ); + yield updateURL( name, value, context.pageParam ); } ), }, } ); diff --git a/tests/e2e/search-action.spec.js b/tests/e2e/search-action.spec.js new file mode 100644 index 0000000..66a538e --- /dev/null +++ b/tests/e2e/search-action.spec.js @@ -0,0 +1,76 @@ +const { test, expect, POSTS } = require( './fixtures' ); + +test.describe( 'Search action ownership', () => { + test( 'a search block whose action another plugin rewrote still searches the loop', async ( { + page, + loop, + } ) => { + // The test mu-plugin rewrites search actions to the home page at + // priority 10 when `qf_hijack` is set, as Polylang does. + await page.goto( '/post-type-filter/?qf_hijack=1' ); + + const form = page.locator( 'form.wp-block-search' ); + await expect( form ).toHaveAttribute( 'action', /post-type-filter/ ); + + await page.locator( '.wp-block-search__input' ).fill( 'Alpha' ); + + await page.waitForURL( /\/post-type-filter\/.*query-3-s=Alpha/ ); + await loop.expectTitles( POSTS.alpha ); + } ); + + test( 'the URL is built from the page, not the rendered action', async ( { + page, + loop, + } ) => { + await page.goto( '/post-type-filter/' ); + + const form = page.locator( 'form.wp-block-search' ); + await form.evaluate( ( el ) => + el.setAttribute( 'action', 'https://example.org/' ) + ); + + await page.locator( '.wp-block-search__input' ).fill( 'Beta' ); + + await page.waitForURL( /\/post-type-filter\/.*query-3-s=Beta/ ); + await loop.expectTitles( POSTS.beta ); + } ); + + test( 'the filters applied are carried as hidden inputs, without the page', async ( { + page, + } ) => { + await page.goto( + '/search-pagination/?query-5-page=2&query-5-post_type=post&query-5-s=x' + ); + + const form = page.locator( 'form.wp-block-search' ); + await expect( + form.locator( 'input[type="hidden"][name="query-5-post_type"]' ) + ).toHaveValue( 'post' ); + await expect( + form.locator( 'input[type="hidden"][name="query-5-page"]' ) + ).toHaveCount( 0 ); + await expect( + form.locator( 'input[type="hidden"][name="query-5-s"]' ) + ).toHaveCount( 0 ); + } ); +} ); + +test.describe( 'Search without JavaScript', () => { + test.use( { javaScriptEnabled: false } ); + + test( 'submitting keeps the filters already applied', async ( { + page, + loop, + } ) => { + await page.goto( '/post-type-filter/?query-3-post_type=qf_doc' ); + await loop.expectTitles( POSTS.docs ); + + const input = page.locator( '.wp-block-search__input' ); + await input.fill( 'One' ); + await input.press( 'Enter' ); + + await page.waitForURL( /query-3-s=One/ ); + expect( page.url() ).toContain( 'query-3-post_type=qf_doc' ); + await loop.expectTitles( [ 'Doc One' ] ); + } ); +} ); diff --git a/tests/mu-plugins/register-test-content.php b/tests/mu-plugins/register-test-content.php index 0e26049..7e17804 100644 --- a/tests/mu-plugins/register-test-content.php +++ b/tests/mu-plugins/register-test-content.php @@ -14,6 +14,29 @@ add_action( 'init', __NAMESPACE__ . '\\register_test_content' ); add_filter( 'get_block_templates', __NAMESPACE__ . '\\replace_search_template', 10, 2 ); add_filter( 'get_block_templates', __NAMESPACE__ . '\\replace_archive_template', 10, 2 ); +// Added once plugins have loaded, so it runs after this plugin's filter would at +// the same priority, as Polylang's does. +add_action( 'plugins_loaded', function () { + add_filter( 'render_block_core/search', __NAMESPACE__ . '\\hijack_search_action', 10 ); +} ); + +/** + * Point every search form at the home page, as Polylang does, when asked to. + * + * Registered at the default priority, as Polylang's own filter is, so the tests + * can prove the plugin still owns the action of a search inside a Query Loop. + * + * @param string $block_content Search block markup. + * @return string Search block markup. + */ +function hijack_search_action( string $block_content ) : string { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( empty( $_GET['qf_hijack'] ) ) { + return $block_content; + } + + return preg_replace( '#action="[^"]*"#', 'action="' . esc_url( home_url( '/' ) ) . '"', $block_content ); +} /** * Register the test post types and taxonomies. From 6140768e1a31889e315d2f1e724a0e15368feaa1 Mon Sep 17 00:00:00 2001 From: Nathaniel Taintor Date: Wed, 28 Oct 2026 18:17:29 -0400 Subject: [PATCH 2/3] Update inc/namespace.php Co-authored-by: K Adam White --- inc/namespace.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/namespace.php b/inc/namespace.php index 536ed71..dbd2231 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -819,7 +819,7 @@ function render_block_search( string $block_content, array $block, \WP_Block $in } /** - * Carry the request's other parameters through a search form submitted without JavaScript. + * Add hidden inputs for the current query params so they still work in non-JS search submissions. * * A GET form replaces the query string of its action with its own fields, so the * filters already applied would otherwise be lost when the search is submitted. From d595417dc6c3b902d01a17c6a437dddf2e219bdf Mon Sep 17 00:00:00 2001 From: goldenapples Date: Wed, 28 Oct 2026 18:23:00 -0400 Subject: [PATCH 3/3] Recognize "paged" as the no-js query param for an inherited query --- inc/namespace.php | 8 +++++- tests/e2e/search-action.spec.js | 29 ++++++++++++++++++++++ tests/mu-plugins/register-test-content.php | 18 ++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/inc/namespace.php b/inc/namespace.php index dbd2231..e09f4b4 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -815,7 +815,13 @@ function render_block_search( string $block_content, array $block, \WP_Block $in $block_content->set_attribute( 'data-wp-bind--value', 'context.searchValue' ); $block_content->set_attribute( 'data-wp-on--input', 'actions.search' ); - return add_filter_hidden_inputs( (string) $block_content, [ $query_var, $page_var ] ); + $exclude = [ $query_var, $page_var ]; + if ( ! empty( $instance->context['query']['inherit'] ) ) { + // Core pages an inherited loop with `paged` (`?paged=N` on plain permalinks), not `page`. + $exclude[] = 'paged'; + } + + return add_filter_hidden_inputs( (string) $block_content, $exclude ); } /** diff --git a/tests/e2e/search-action.spec.js b/tests/e2e/search-action.spec.js index 66a538e..a6f2352 100644 --- a/tests/e2e/search-action.spec.js +++ b/tests/e2e/search-action.spec.js @@ -53,6 +53,22 @@ test.describe( 'Search action ownership', () => { form.locator( 'input[type="hidden"][name="query-5-s"]' ) ).toHaveCount( 0 ); } ); + + test( 'an inherited loop does not carry its page as a hidden input', async ( { + page, + } ) => { + // An inherited loop pages with `paged`, which is how its links read on plain + // permalinks. One post per page puts page 2 in range. + await page.goto( '/?s=&paged=2&qf_per_page=1' ); + + const form = page.locator( '.wp-block-query form.wp-block-search' ); + await expect( + form.locator( 'input[type="hidden"][name="qf_per_page"]' ) + ).toHaveValue( '1' ); + await expect( + form.locator( 'input[type="hidden"][name="paged"]' ) + ).toHaveCount( 0 ); + } ); } ); test.describe( 'Search without JavaScript', () => { @@ -73,4 +89,17 @@ test.describe( 'Search without JavaScript', () => { expect( page.url() ).toContain( 'query-3-post_type=qf_doc' ); await loop.expectTitles( [ 'Doc One' ] ); } ); + + test( 'a search from a later page of an inherited loop starts on page 1', async ( { + page, + } ) => { + await page.goto( '/?s=&paged=2&qf_per_page=1' ); + + const input = page.locator( '.wp-block-query .wp-block-search__input' ); + await input.fill( 'Alpha' ); + await input.press( 'Enter' ); + + await page.waitForURL( /[?&]s=Alpha/ ); + expect( new URL( page.url() ).searchParams.has( 'paged' ) ).toBe( false ); + } ); } ); diff --git a/tests/mu-plugins/register-test-content.php b/tests/mu-plugins/register-test-content.php index 7e17804..04304d5 100644 --- a/tests/mu-plugins/register-test-content.php +++ b/tests/mu-plugins/register-test-content.php @@ -38,6 +38,24 @@ function hijack_search_action( string $block_content ) : string { return preg_replace( '#action="[^"]*"#', 'action="' . esc_url( home_url( '/' ) ) . '"', $block_content ); } +add_action( 'pre_get_posts', __NAMESPACE__ . '\\set_main_query_page_size' ); + +/** + * Shrink the main query's page size when asked to, so a few fixtures span several pages. + * + * @param \WP_Query $query Query being prepared. + * @return void + */ +function set_main_query_page_size( \WP_Query $query ) : void { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! $query->is_main_query() || empty( $_GET['qf_per_page'] ) ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $query->set( 'posts_per_page', absint( $_GET['qf_per_page'] ) ); +} + /** * Register the test post types and taxonomies. *