diff --git a/package-lock.json b/package-lock.json index a51d425d4f..55c1874b95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "devDependencies": { "@octokit/rest": "^19.0.5", "@wordpress/env": "^9.6.0", + "@wordpress/interactivity": "6.0.0", "@wordpress/scripts": "^26.19.0", "commander": "^9.4.1", "copy-webpack-plugin": "^12.0.2", @@ -3097,6 +3098,32 @@ "integrity": "sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==", "dev": true }, + "node_modules/@preact/signals": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@preact/signals/-/signals-1.2.3.tgz", + "integrity": "sha512-M2DXse3Wi8HwjI1d2vQWOLJ3lHogvqTsJYvl4ofXRXgMFQzJ7kmlZvlt5i8x5S5VwgZu0ghru4HkLqOoFfU2JQ==", + "dev": true, + "dependencies": { + "@preact/signals-core": "^1.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact": "10.x" + } + }, + "node_modules/@preact/signals-core": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.6.0.tgz", + "integrity": "sha512-O/XGxwP85h1F7+ouqTMOIZ3+V1whfaV9ToIVcuyGriD4JkSD00cQo54BKdqjvBJxbenvp7ynfqRHEwI6e+NIhw==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@puppeteer/browsers": { "version": "1.4.6", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.4.6.tgz", @@ -5001,6 +5028,21 @@ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "dev": true }, + "node_modules/@wordpress/interactivity": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/interactivity/-/interactivity-6.0.0.tgz", + "integrity": "sha512-s86Bj54jaUpY6lvwAqLLI7H9DhpYC470Rv0kEigL0S1bwtejOUE4diftSRbp4RGt2aFiDtPE3tlQihVUzf2e6A==", + "dev": true, + "dependencies": { + "@preact/signals": "^1.2.2", + "deepsignal": "^1.4.0", + "preact": "^10.19.3" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "node_modules/@wordpress/jest-console": { "version": "7.19.0", "resolved": "https://registry.npmjs.org/@wordpress/jest-console/-/jest-console-7.19.0.tgz", @@ -7922,6 +7964,32 @@ "node": ">=0.10.0" } }, + "node_modules/deepsignal": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/deepsignal/-/deepsignal-1.5.0.tgz", + "integrity": "sha512-bFywDpBUUWMs576H2dgLFLLFuQ/UWXbzHfKD98MZTfGsl7+twIzvz4ihCNrRrZ/Emz3kqJaNIAp5eBWUEWhnAw==", + "dev": true, + "peerDependencies": { + "@preact/signals": "^1.1.4", + "@preact/signals-core": "^1.5.1", + "@preact/signals-react": "^1.3.8 || ^2.0.0", + "preact": "^10.16.0" + }, + "peerDependenciesMeta": { + "@preact/signals": { + "optional": true + }, + "@preact/signals-core": { + "optional": true + }, + "@preact/signals-react": { + "optional": true + }, + "preact": { + "optional": true + } + } + }, "node_modules/default-gateway": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", @@ -16255,6 +16323,16 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "dev": true }, + "node_modules/preact": { + "version": "10.22.0", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.22.0.tgz", + "integrity": "sha512-RRurnSjJPj4rp5K6XoP45Ui33ncb7e4H7WiOHVpjbkvqvA3U+N8Z6Qbo0AE6leGYBV66n8EhEaFixvIu3SkxFw==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index 022acc12af..fbe6196465 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "devDependencies": { "@octokit/rest": "^19.0.5", "@wordpress/env": "^9.6.0", + "@wordpress/interactivity": "6.0.0", "@wordpress/scripts": "^26.19.0", "commander": "^9.4.1", "copy-webpack-plugin": "^12.0.2", diff --git a/plugins/speculation-rules/helper.php b/plugins/speculation-rules/helper.php index bb2ab962cf..beddc62289 100644 --- a/plugins/speculation-rules/helper.php +++ b/plugins/speculation-rules/helper.php @@ -22,19 +22,8 @@ * @return array>> Associative array of speculation rules by type. */ function plsr_get_speculation_rules(): array { - $option = get_option( 'plsr_speculation_rules' ); - - /* - * This logic is only relevant for edge-cases where the setting may not be registered, - * a.k.a. defensive coding. - */ - if ( ! $option || ! is_array( $option ) ) { - $option = plsr_get_setting_default(); - } else { - $option = array_merge( plsr_get_setting_default(), $option ); - } - - $mode = (string) $option['mode']; + $option = plsr_get_setting(); + $mode = $option['mode']; $eagerness = $option['eagerness']; $prefixer = new PLSR_URL_Pattern_Prefixer(); @@ -123,3 +112,13 @@ static function ( string $href_exclude_path ) use ( $prefixer ): string { return array( $mode => $rules ); } + +/** + * Adds config for the speculative-loading search form. + * + * @since n.e.x.t + */ +function plsr_add_search_form_config(): void { + $setting = plsr_get_setting(); + wp_interactivity_config( 'speculationRules', array( 'mode' => $setting['mode'] ) ); +} diff --git a/plugins/speculation-rules/hooks.php b/plugins/speculation-rules/hooks.php index b5731232cc..febcfcd53b 100644 --- a/plugins/speculation-rules/hooks.php +++ b/plugins/speculation-rules/hooks.php @@ -58,3 +58,75 @@ function plsr_render_generator_meta_tag(): void { echo '' . "\n"; } add_action( 'wp_head', 'plsr_render_generator_meta_tag' ); + +/** + * Filters the HTML output of the search form to inject speculative loading interactivity. + * + * @since n.e.x.t + * + * @param string|mixed $form The search form HTML output. + * @return string Filtered HTML. + */ +function plsr_filter_searchform( $form ): string { + if ( ! is_string( $form ) ) { + return ''; + } + + $namespace = 'speculationRules'; + $directive_prefix = 'data-wp-on'; // TODO: Use data-wp-on-async when available. + + $p = new WP_HTML_Tag_Processor( $form ); + while ( $p->next_tag() ) { + if ( 'FORM' === $p->get_tag() ) { + if ( ! $p->get_attribute( 'data-wp-interactive' ) ) { + $p->set_attribute( 'data-wp-interactive', $namespace ); + } + // Create context if not already present. + // TODO: Should there be namespaced context? + if ( ! $p->get_attribute( 'data-wp-context' ) ) { + $p->set_attribute( 'data-wp-context', '{}' ); + } + // Note: This directive only subscribes to the properties that are accessed during its execution. + $p->set_attribute( "data-wp-watch--{$namespace}", "{$namespace}::callbacks.doSpeculativeLoad" ); + $p->set_attribute( "data-wp-on--submit--{$namespace}", "{$namespace}::actions.handleFormSubmit" ); + + $p->set_attribute( "{$directive_prefix}--change--{$namespace}", "{$namespace}::actions.updateSpeculativeLoadUrl" ); + + wp_enqueue_script_module( 'speculation-rules-search-form' ); + plsr_add_search_form_config(); + } elseif ( + ( 'INPUT' === $p->get_tag() || 'BUTTON' === $p->get_tag() ) + && + 'submit' === $p->get_attribute( 'type' ) + ) { + $p->set_attribute( "{$directive_prefix}--focus--{$namespace}", "{$namespace}::actions.updateSpeculativeLoadUrl" ); + $p->set_attribute( "{$directive_prefix}--pointerover--{$namespace}", "{$namespace}::actions.updateSpeculativeLoadUrl" ); + } elseif ( + 'INPUT' === $p->get_tag() + && + 's' === $p->get_attribute( 'name' ) + ) { + $p->set_attribute( "{$directive_prefix}--keydown--{$namespace}", "{$namespace}::actions.handleInputKeydown" ); + } + } + + return $p->get_updated_html(); +} +add_filter( 'get_search_form', 'plsr_filter_searchform' ); +add_filter( 'render_block_core/search', 'plsr_filter_searchform' ); + +/** + * Registers script module for the speculatively loading search form. + * + * @since n.e.x.t + */ +function plsr_register_script_module(): void { + wp_register_script_module( + 'speculation-rules-search-form', + plugin_dir_url( __FILE__ ) . 'search-form.js', + array( + array( 'id' => '@wordpress/interactivity' ), + ) + ); +} +add_action( 'wp_enqueue_scripts', 'plsr_register_script_module' ); diff --git a/plugins/speculation-rules/search-form.js b/plugins/speculation-rules/search-form.js new file mode 100644 index 0000000000..8e373a4965 --- /dev/null +++ b/plugins/speculation-rules/search-form.js @@ -0,0 +1,74 @@ +import { + store, + getConfig, + getContext, + getElement, +} from '@wordpress/interactivity'; + +const { actions } = store( 'speculationRules', { + callbacks: { + doSpeculativeLoad: () => { + /** + * @type {Object} + * @property {string} [speculativeLoadUrl] Speculative load URL. + */ + const context = getContext(); + const scriptId = 'speculation-rules-search-form'; + const existingScript = document.getElementById( scriptId ); + if ( ! context.speculativeLoadUrl ) { + if ( existingScript ) { + existingScript.remove(); + } + } else { + const script = document.createElement( 'script' ); + script.type = 'speculationrules'; + script.id = scriptId; + const rules = { + [ getConfig().mode ]: [ + { + source: 'list', + urls: [ context.speculativeLoadUrl ], + }, + ], + }; + script.textContent = JSON.stringify( rules ); + + if ( existingScript ) { + existingScript.replaceWith( script ); + } else { + document.body.appendChild( script ); + } + } + }, + }, + actions: { + // TODO: Is this really actually callback? + updateSpeculativeLoadUrl: () => { + const context = getContext(); + const { ref } = getElement(); + const form = ref.closest( 'form' ); + const formData = new FormData( form ); + if ( ! formData.get( 's' ) ) { + context.speculativeLoadUrl = null; + } else { + const url = new URL( form.action ); + url.search = new URLSearchParams( formData ).toString(); + context.speculativeLoadUrl = url.href; + } + }, + handleInputKeydown: ( event ) => { + // Eke out a few milliseconds when hitting enter on the input to submit. + if ( event.key === 'Enter' ) { + actions.updateSpeculativeLoadUrl(); + } + }, + handleFormSubmit: ( event ) => { + event.preventDefault(); + const form = event.target; + const formData = new FormData( form ); + const url = new URL( form.action ); + url.search = new URLSearchParams( formData ).toString(); + location.href = url.href; + }, + }, +} ); diff --git a/plugins/speculation-rules/settings.php b/plugins/speculation-rules/settings.php index ae0c6ae818..cb2ae4c661 100644 --- a/plugins/speculation-rules/settings.php +++ b/plugins/speculation-rules/settings.php @@ -45,6 +45,7 @@ function plsr_get_eagerness_labels(): array { * * @since 1.0.0 * + * @phpstan-return array{ mode: 'prerender', eagerness: 'moderate' } * @return array { * Default setting value. * @@ -59,12 +60,59 @@ function plsr_get_setting_default(): array { ); } +/** + * Gets setting. + * + * @since n.e.x.t + * + * @phpstan-return array{ + * mode: 'prerender'|'prefetch', + * eagerness: 'conservative'|'moderate'|'eager' + * } + * @return array { + * Setting value. + * + * @type string $mode Mode. + * @type string $eagerness Eagerness. + * } + */ +function plsr_get_setting(): array { + $setting = get_option( 'plsr_speculation_rules' ); + + /* + * This logic is only relevant for edge-cases where the setting may not be registered, + * a.k.a. defensive coding. + */ + if ( ! $setting || ! is_array( $setting ) ) { + $setting = plsr_get_setting_default(); + } else { + $setting = array_merge( plsr_get_setting_default(), $setting ); + } + + /** + * Validated setting. + * + * The value is validated by {@see plsr_sanitize_setting()}. + * + * @var array{ + * mode: 'prerender'|'prefetch', + * eagerness: 'conservative'|'moderate'|'eager' + * } $setting + */ + return $setting; +} + /** * Sanitizes the setting for Speculative Loading configuration. * * @since 1.0.0 * * @param mixed $input Setting to sanitize. + + * @phpstan-return array{ + * mode: 'prerender'|'prefetch', + * eagerness: 'conservative'|'moderate'|'eager' + * } * @return array { * Sanitized setting. * diff --git a/plugins/speculation-rules/tests/test-speculation-rules-settings.php b/plugins/speculation-rules/tests/test-speculation-rules-settings.php index 5f10ed11e4..8ff9967508 100644 --- a/plugins/speculation-rules/tests/test-speculation-rules-settings.php +++ b/plugins/speculation-rules/tests/test-speculation-rules-settings.php @@ -101,6 +101,26 @@ public function data_plsr_sanitize_setting(): array { ); } + /** + * @covers ::plsr_get_setting + */ + public function test_plsr_get_setting_not_set(): void { + delete_option( 'plsr_speculation_rules' ); + $this->assertSame( plsr_get_setting_default(), plsr_get_setting() ); + } + + /** + * @covers ::plsr_get_setting + * @dataProvider data_plsr_sanitize_setting + * + * @param mixed $input Input. + * @param array $expected Expected. + */ + public function test_plsr_get_setting( $input, array $expected ): void { + update_option( 'plsr_speculation_rules', $input ); + $this->assertSame( $expected, plsr_get_setting() ); + } + /** * @covers ::plsr_add_settings_action_link */