+ { __(
+ '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();
+ }
}