diff --git a/projects/plugins/boost/app/assets/src/js/features/render-blocking-js/render-blocking-js-meta.module.scss b/projects/plugins/boost/app/assets/src/js/features/render-blocking-js/render-blocking-js-meta.module.scss new file mode 100644 index 000000000000..cc9034558229 --- /dev/null +++ b/projects/plugins/boost/app/assets/src/js/features/render-blocking-js/render-blocking-js-meta.module.scss @@ -0,0 +1,66 @@ +@use "$css/main/variables"; + +.wrapper { + font-size: 14px; + line-height: 22px; + + :global(button ~ button) { + margin-left: 20px !important; + } + + .summary { + flex-grow: 1; + } + + .section { + margin-top: 16px; + margin-bottom: 16px; + padding: 16px; + border-radius: 4px; + background-color: var(--gray-0); + + .title { + margin-bottom: 16px; + font-size: 16px; + line-height: 1; + font-weight: 600; + } + } + + .description { + margin-top: 8px; + font-size: 14px; + line-height: 1.5; + color: var(--gray-60); + font-weight: 400; + } + + .button { + margin-top: 16px; + } + + .manage-excludes label { + display: block; + margin-bottom: 16px; + line-height: 1.5; + } + + .manage-excludes input[type="text"] { + width: 100%; + padding: 10px; + border-radius: 4px; + border: 1px solid variables.$gray_10; + } + + .edit-button svg { + fill: var(--wp-admin-theme-color); + } + + .sub-header { + font-size: 16px; + line-height: 1; + color: var(--gray-60); + font-weight: 400; + padding-bottom: 2px; + } +} diff --git a/projects/plugins/boost/app/assets/src/js/features/render-blocking-js/render-blocking-js-meta.tsx b/projects/plugins/boost/app/assets/src/js/features/render-blocking-js/render-blocking-js-meta.tsx new file mode 100644 index 000000000000..811db823a19b --- /dev/null +++ b/projects/plugins/boost/app/assets/src/js/features/render-blocking-js/render-blocking-js-meta.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react'; +import { Button } from '@automattic/jetpack-components'; +import { useDataSync } from '@automattic/jetpack-react-data-sync-client'; +import { createInterpolateElement } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { z } from 'zod'; +import { recordBoostEvent } from '$lib/utils/analytics'; +import styles from './render-blocking-js-meta.module.scss'; +import CollapsibleMeta from '$features/ui/collapsible-meta/collapsible-meta'; +import { useNotices } from '$features/notice/context'; + +const datasyncKey = 'render_blocking_js_excludes'; + +const useExcludesQuery = ( onSuccess?: ( newState: string[] ) => void ) => { + const [ { data }, { mutate } ] = useDataSync( + 'jetpack_boost_ds', + datasyncKey, + z.array( z.string() ) + ); + + function updateValues( text: string ) { + mutate( + text.split( ',' ).map( item => item.trim() ), + { + onSuccess: newState => { + // Run the passed on callbacks after the mutation has been applied + onSuccess?.( newState ); + }, + } + ); + } + + return [ data || [], updateValues ] as const; +}; + +const RenderBlockingJsMeta = () => { + const noticeId = `render-blocking-js-meta-${ datasyncKey }`; + + const [ values, updateValues ] = useExcludesQuery( newState => { + setInputValue( newState.join( ', ' ) ); + setNotice( { + id: noticeId, + type: 'success', + message: __( 'Changes saved', 'jetpack-boost' ), + } ); + } ); + const [ inputValue, setInputValue ] = useState( () => values.join( ', ' ) ); + const { setNotice } = useNotices(); + + const onToggleHandler = ( isExpanded: boolean ) => { + if ( ! isExpanded ) { + setInputValue( values.join( ', ' ) ); + } + }; + + function save() { + recordBoostEvent( 'defer_js_exceptions_save_clicked', {} ); + + // Show saving notice + setNotice( { + id: noticeId, + type: 'pending', + message: __( 'Saving…', 'jetpack-boost' ), + } ); + + updateValues( inputValue ); + } + + const htmlId = `jb-render-blocking-js-meta-${ datasyncKey }`; + + // Be explicit about this because the optimizer breaks the linter otherwise. + let summary; + if ( values.length > 0 ) { + /* Translators: %s refers to the list of excluded items. */ + summary = sprintf( __( 'Except: %s', 'jetpack-boost' ), values.join( ', ' ) ); + } + + if ( values.length === 0 ) { + summary = __( 'No exceptions.', 'jetpack-boost' ); + } + + const content = ( +
+
+
{ __( 'Exceptions', 'jetpack-boost' ) }
+
+ + setInputValue( e.target.value ) } + onKeyDown={ e => { + if ( e.key === 'Enter' || e.key === 'NumpadEnter' ) { + save(); + } + } } + /> +
+ { __( + 'JavaScript will not be deferred on pages matching these URL patterns. Use a comma (,) to separate the patterns. Use (.*) to address multiple URLs under a given path.', + 'jetpack-boost' + ) } +
+ { createInterpolateElement( + __( + 'To keep a single script in place on every page instead, add the data-jetpack-boost="ignore" attribute to its script tag.', + 'jetpack-boost' + ), + { + code: , + } + ) } +
+ +
+
+
+ ); + + return ( +
+ + { content } + +
+ ); +}; + +export default RenderBlockingJsMeta; diff --git a/projects/plugins/boost/app/assets/src/js/pages/index/index.tsx b/projects/plugins/boost/app/assets/src/js/pages/index/index.tsx index ef692583fee9..2e97d298db3c 100644 --- a/projects/plugins/boost/app/assets/src/js/pages/index/index.tsx +++ b/projects/plugins/boost/app/assets/src/js/pages/index/index.tsx @@ -9,6 +9,7 @@ import MinifyJs from '$features/minify-js/minify-js'; import { useSingleModuleState } from '$features/module/lib/stores'; import Module from '$features/module/module'; import PageCacheModule from '$features/page-cache/page-cache'; +import RenderBlockingJsMeta from '$features/render-blocking-js/render-blocking-js-meta'; import PremiumTooltip from '$features/premium-tooltip/premium-tooltip'; import Upgraded from '$features/ui/upgraded/upgraded'; import InterstitialModalCTA from '$features/upgrade-cta/interstitial-modal-cta'; @@ -153,7 +154,9 @@ const Index = () => { ) }

} - > + > + + register( + 'render_blocking_js_excludes', + Schema::as_array( Schema::as_string() )->fallback( array() ), + new Minify_Excludes_State_Entry( 'render_blocking_js_excludes' ) + ); + } + + /** + * Cached pages need to be invalidated when the exclusion list changes. + * + * @return string[] Action names fired when the exclusion list is updated. + */ + public static function get_change_output_action_names() { + return array( 'update_option_' . JETPACK_BOOST_DATASYNC_NAMESPACE . '_render_blocking_js_excludes' ); + } + /** * Set up an output filtering callback. * @@ -157,6 +185,13 @@ public function start_output_filtering() { return; } + // Disable on URLs excluded by the user. + if ( $this->is_current_request_excluded() ) { + // Leave the page output completely untouched, as if the module was off. + remove_filter( 'do_shortcode_tag', array( $this, 'add_ignore_attribute' ) ); + return; + } + // Print the filtered script tags to the very end of the page. add_filter( 'jetpack_boost_output_filtering_last_buffer', array( $this, 'append_script_tags' ), 10, 1 ); @@ -368,6 +403,122 @@ public function is_opened_script( $buffer ) { return $opening_tags_count > $closing_tags_count; } + /** + * Checks if the current request URL matches one of the exclusion patterns + * configured by the user. + * + * Runs at template_redirect time, when REQUEST_URI is available. + * + * @return bool + */ + private function is_current_request_excluded() { + if ( ! isset( $_SERVER['REQUEST_URI'] ) ) { + return false; + } + + $patterns = function_exists( 'jetpack_boost_ds_get' ) ? jetpack_boost_ds_get( 'render_blocking_js_excludes' ) : array(); + if ( empty( $patterns ) || ! is_array( $patterns ) ) { + return false; + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only used for comparison. + return self::is_url_excluded( wp_unslash( $_SERVER['REQUEST_URI'] ), $patterns ); + } + + /** + * Checks whether a request URI matches any of the given exclusion patterns. + * + * Patterns follow the semantics documented for Page Cache bypass patterns: + * they are compared against the URL path (query strings are ignored), + * a `(.*)` or `*` wildcard matches any part of the path, trailing slashes + * are optional and the comparison is case-insensitive. Anything else in a + * pattern is treated literally. + * + * @param string $request_uri The request URI to check. + * @param array $patterns List of URL patterns. + * + * @return bool + */ + public static function is_url_excluded( $request_uri, $patterns ) { + $path = self::normalize_url_path( $request_uri ); + + foreach ( $patterns as $pattern ) { + $regex = self::get_exclusion_regex( $pattern ); + if ( null !== $regex && preg_match( $regex, $path ) ) { + return true; + } + } + + return false; + } + + /** + * Extracts a normalized path from a URL or request URI. + * + * Drops the query string, ensures a leading slash and removes trailing + * slashes (except for the root path). + * + * @param string $url URL or request URI. + * + * @return string + */ + private static function normalize_url_path( $url ) { + $path = (string) wp_parse_url( $url, PHP_URL_PATH ); + $path = '/' . ltrim( $path, '/' ); + + if ( '/' !== $path ) { + $path = rtrim( $path, '/' ); + } + + return $path; + } + + /** + * Turns a single exclusion pattern into an anchored regular expression. + * + * @param mixed $pattern A user-provided URL pattern. + * + * @return string|null The regular expression, or null if the pattern is empty. + */ + private static function get_exclusion_regex( $pattern ) { + if ( ! is_string( $pattern ) ) { + return null; + } + + $pattern = trim( $pattern ); + if ( '' === $pattern ) { + return null; + } + + // Allow full URLs by stripping the home URL prefix (both secure and non-secure). + $home_url = home_url( '/' ); + $pattern = str_ireplace( + array( + $home_url, + str_replace( 'http:', 'https:', $home_url ), + ), + '/', + $pattern + ); + + $pattern = self::normalize_url_path( $pattern ); + + // Convert wildcard tokens to regex groups, treating the rest of the pattern literally. + $tokens = preg_split( '/\(\.\*\)|\(\*\)|\.\*|\*/', $pattern ); + if ( false === $tokens ) { + return null; + } + + $quoted = array_map( + function ( $token ) { + return preg_quote( $token, '~' ); + }, + $tokens + ); + + return '~^' . implode( '(.*)', $quoted ) . '/?$~i'; + } + public static function get_slug() { return 'render_blocking_js'; } diff --git a/projects/plugins/boost/changelog/add-defer-js-url-excludes b/projects/plugins/boost/changelog/add-defer-js-url-excludes new file mode 100644 index 000000000000..35dc4cf1ec60 --- /dev/null +++ b/projects/plugins/boost/changelog/add-defer-js-url-excludes @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Defer JS: add an exclusion list so specific pages can be excluded by URL pattern without disabling the feature site-wide. diff --git a/projects/plugins/boost/tests/bootstrap.php b/projects/plugins/boost/tests/bootstrap.php index b60178e0ac1a..351e35f60d45 100644 --- a/projects/plugins/boost/tests/bootstrap.php +++ b/projects/plugins/boost/tests/bootstrap.php @@ -18,6 +18,23 @@ */ require_once __DIR__ . '/../vendor/autoload.php'; +// PHP 8.0 polyfill: WordPress core polyfills str_contains() at runtime (WP 5.9+), +// but the unit suite runs without WordPress, so PHP <= 7.4 needs it here for the +// production code paths under test that call it. +if ( ! function_exists( 'str_contains' ) ) { + /** + * Polyfill for PHP 8.0's str_contains(). + * + * @param string $haystack String to search in. + * @param string $needle Substring to search for. + * @return bool Whether $haystack contains $needle. + * @suppress PhanRedefineFunctionInternal -- Guarded polyfill for PHP < 8.0. + */ + function str_contains( $haystack, $needle ) { + return '' === $needle || false !== strpos( $haystack, $needle ); + } +} + // Additional functions that brain/monkey doesn't currently define. if ( ! function_exists( 'wp_unslash' ) ) { /** diff --git a/projects/plugins/boost/tests/php/modules/optimizations/render-blocking-js/Render_Blocking_JS_Test.php b/projects/plugins/boost/tests/php/modules/optimizations/render-blocking-js/Render_Blocking_JS_Test.php index 2d660d92f39b..ca8548673f3c 100644 --- a/projects/plugins/boost/tests/php/modules/optimizations/render-blocking-js/Render_Blocking_JS_Test.php +++ b/projects/plugins/boost/tests/php/modules/optimizations/render-blocking-js/Render_Blocking_JS_Test.php @@ -9,6 +9,8 @@ use Automattic\Jetpack_Boost\Modules\Optimizations\Render_Blocking_JS\Render_Blocking_JS; use Brain\Monkey; +use Brain\Monkey\Filters; +use Brain\Monkey\Functions; use Mockery\Adapter\Phpunit\MockeryTestCase; /** @@ -52,10 +54,42 @@ protected function setUp(): void { * Tear down test environment. */ protected function tearDown(): void { + unset( $_SERVER['REQUEST_URI'] ); Monkey\tearDown(); parent::tearDown(); } + /** + * Stub the WordPress URL helpers used by the exclusion matching. + */ + private function stub_url_functions() { + Functions\when( 'home_url' )->alias( + function ( $path = '' ) { + return 'http://example.com' . $path; + } + ); + + Functions\when( 'wp_parse_url' )->alias( + function ( $url, $component = -1 ) { + return parse_url( $url, $component ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url + } + ); + } + + /** + * Stub the WordPress request-context functions used by start_output_filtering(). + */ + private function stub_request_context() { + $this->stub_url_functions(); + + Functions\when( 'is_customize_preview' )->justReturn( false ); + Functions\when( 'is_feed' )->justReturn( false ); + Functions\when( 'wp_doing_ajax' )->justReturn( false ); + Functions\when( 'wp_doing_cron' )->justReturn( false ); + Functions\when( 'wp_is_xml_request' )->justReturn( false ); + Functions\when( 'get_query_var' )->justReturn( '' ); + } + /** * Test that an empty buffer returns false. */ @@ -200,4 +234,90 @@ public function test_ignored_pair_with_literal_closing_in_string() { // unreported — same trade-off as get_script_tags(). $this->assertFalse( $this->instance->is_opened_script( $buffer ) ); } + + /** + * Test the URL exclusion pattern matching semantics. + */ + public function test_is_url_excluded_matching_semantics() { + $this->stub_url_functions(); + + $cases = array( + 'exact path match' => array( '/checkout/', array( 'checkout' ), true ), + 'trailing slash in pattern' => array( '/checkout', array( 'checkout/' ), true ), + 'leading slash in pattern' => array( '/checkout/', array( '/checkout' ), true ), + 'query string is ignored' => array( '/checkout/?step=2&cart=1', array( 'checkout' ), true ), + 'case-insensitive match' => array( '/Checkout/', array( 'checkout' ), true ), + 'full URL pattern (http home url)' => array( '/checkout/', array( 'http://example.com/checkout' ), true ), + 'full URL pattern (https home url)' => array( '/checkout/', array( 'https://example.com/checkout' ), true ), + 'wildcard (.*) matches sub-paths' => array( '/gallery/holiday-2024/', array( 'gallery/(.*)' ), true ), + 'wildcard * matches sub-paths' => array( '/gallery/holiday-2024/', array( 'gallery/*' ), true ), + 'wildcard .* matches sub-paths' => array( '/gallery/holiday-2024/', array( 'gallery/.*' ), true ), + 'wildcard in the middle of a pattern' => array( '/shop/blue-shirt/reviews/', array( 'shop/*/reviews' ), true ), + 'wildcard does not match the parent page' => array( '/gallery/', array( 'gallery/(.*)' ), false ), + 'no match on a different page' => array( '/about-us/', array( 'checkout', 'gallery/(.*)' ), false ), + 'pattern is not a partial match' => array( '/checkout-success/', array( 'checkout' ), false ), + 'root pattern matches the homepage' => array( '/', array( '/' ), true ), + 'root pattern does not match sub-pages' => array( '/about-us/', array( '/' ), false ), + 'regex characters are treated literally' => array( '/pageXhtml/', array( 'page.html' ), false ), + 'literal dot matches itself' => array( '/page.html', array( 'page.html' ), true ), + 'empty pattern list' => array( '/checkout/', array(), false ), + 'empty string patterns are ignored' => array( '/checkout/', array( '', ' ' ), false ), + 'non-string patterns are ignored' => array( '/checkout/', array( 42, null, array( 'checkout' ) ), false ), + 'second pattern in the list matches' => array( '/checkout/', array( 'cart', 'checkout' ), true ), + ); + + foreach ( $cases as $description => $case ) { + list( $request_uri, $patterns, $expected ) = $case; + $this->assertSame( + $expected, + Render_Blocking_JS::is_url_excluded( $request_uri, $patterns ), + 'Failed case: ' . $description + ); + } + } + + /** + * When the current request matches an exclusion pattern, output filtering + * must not be set up and the shortcode filter must be removed, leaving the + * page output byte-identical to defer-disabled output. + */ + public function test_output_filtering_bails_on_excluded_url() { + $_SERVER['REQUEST_URI'] = '/excluded-page/?foo=bar'; + $this->stub_request_context(); + Functions\when( 'jetpack_boost_ds_get' )->justReturn( array( 'excluded-page' ) ); + + Filters\expectAdded( 'jetpack_boost_output_filtering_last_buffer' )->never(); + Filters\expectAdded( 'script_loader_tag' )->never(); + Filters\expectRemoved( 'do_shortcode_tag' )->once(); + + $initial_ob_level = ob_get_level(); + + $this->instance->setup(); + $this->instance->start_output_filtering(); + + // No output buffer should have been opened. + $this->assertSame( $initial_ob_level, ob_get_level() ); + } + + /** + * When the current request does not match any exclusion pattern, output + * filtering proceeds as usual. + */ + public function test_output_filtering_proceeds_on_non_excluded_url() { + $_SERVER['REQUEST_URI'] = '/regular-page/'; + $this->stub_request_context(); + Functions\when( 'jetpack_boost_ds_get' )->justReturn( array( 'excluded-page' ) ); + + Filters\expectAdded( 'jetpack_boost_output_filtering_last_buffer' )->once(); + Filters\expectAdded( 'script_loader_tag' )->once(); + + $initial_ob_level = ob_get_level(); + + $this->instance->setup(); + $this->instance->start_output_filtering(); + + // Output filtering opens an output buffer; close it again. + $this->assertSame( $initial_ob_level + 1, ob_get_level() ); + ob_end_clean(); + } }