diff --git a/inc/namespace.php b/inc/namespace.php
index 55f8917..e09f4b4 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,47 @@ 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;
+ $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 );
+}
+
+/**
+ * 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.
+ *
+ * @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..a6f2352
--- /dev/null
+++ b/tests/e2e/search-action.spec.js
@@ -0,0 +1,105 @@
+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( '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', () => {
+ 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' ] );
+ } );
+
+ 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 0e26049..04304d5 100644
--- a/tests/mu-plugins/register-test-content.php
+++ b/tests/mu-plugins/register-test-content.php
@@ -14,6 +14,47 @@
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 );
+}
+
+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.