From aa0294bcc7f6e4edf93e1f472d99cd7cfbec5529 Mon Sep 17 00:00:00 2001 From: gunjanjaswal Date: Sat, 26 Sep 2026 14:21:39 +0530 Subject: [PATCH 1/3] Count script dependencies toward the enqueued size total Enqueued_Scripts_Size_Check only measured scripts whose src started with the plugin's own URL, so any dependency served from elsewhere (jQuery, a shared vendor library) was ignored even though it loads because of the plugin and adds to the page weight. Walk each plugin script's dependency graph, de-duplicated, and resolve every handle's src to a local file with a load_script_textdomain-style mapper (plugin dir, wp-content, wp-includes, site root). Dependencies count toward the page total; scripts served from another host, such as a CDN, are skipped since their size can't be read locally. Warnings still point only at the plugin's own files. Fixes #74. --- .../Enqueued_Scripts_Size_Check.php | 187 +++++++++++++++--- .../load.php | 24 ++- .../Enqueued_Scripts_Size_Check_Tests.php | 18 ++ 3 files changed, 198 insertions(+), 31 deletions(-) diff --git a/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php b/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php index a7d4071d8..3a4cd0ec0 100644 --- a/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php +++ b/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php @@ -198,58 +198,189 @@ protected function check_url( Check_Result $result, $url ) { wp_scripts()->do_footer_items(); ob_get_clean(); - $plugin_scripts = array(); - $plugin_script_size = 0; + $wp_scripts = wp_scripts(); + $plugin_url = $result->plugin()->url(); + $plugin_path = $result->plugin()->path(); - foreach ( wp_scripts()->done as $handle ) { - $script = wp_scripts()->registered[ $handle ]; + $plugin_scripts = array(); + $measured = array(); + $seen = array(); + $total_size = 0; - if ( ! $script->src || strpos( $script->src, $result->plugin()->url() ) !== 0 ) { + foreach ( $wp_scripts->done as $handle ) { + $script = isset( $wp_scripts->registered[ $handle ] ) ? $wp_scripts->registered[ $handle ] : null; + + if ( ! $script || ! $script->src || strpos( $script->src, $plugin_url ) !== 0 ) { continue; } - // Get size of script src. - $script_path = str_replace( $result->plugin()->url(), $result->plugin()->path(), $script->src ); - $script_size = function_exists( 'wp_filesize' ) ? wp_filesize( $script_path ) : filesize( $script_path ); + $plugin_scripts[ $handle ] = true; - // Get size of additional inline scripts. - if ( ! empty( $script->extra['after'] ) ) { - foreach ( $script->extra['after'] as $extra ) { - $script_size += ( is_string( $extra ) ) ? mb_strlen( $extra, '8bit' ) : 0; + // Count the plugin's own script and every dependency it pulls in (jQuery, shared vendor + // libraries and so on). Those load because of the plugin, so they add to the page weight + // even when they are served from outside the plugin directory. + foreach ( $this->get_handle_with_dependencies( $handle, $wp_scripts, $seen ) as $dep_handle ) { + if ( isset( $measured[ $dep_handle ] ) ) { + continue; } - } - if ( ! empty( $script->extra['before'] ) ) { - foreach ( $script->extra['before'] as $extra ) { - $script_size += ( is_string( $extra ) ) ? mb_strlen( $extra, '8bit' ) : 0; - } - } + $dep_script = $wp_scripts->registered[ $dep_handle ]; + $dep_path = $this->get_path_from_src( $dep_script->src, $plugin_url, $plugin_path ); + $dep_size = $dep_path ? ( $this->get_file_size( $dep_path ) + $this->get_inline_size( $dep_script ) ) : 0; - $plugin_scripts[] = array( - 'path' => $script_path, - 'size' => $script_size, - ); - $plugin_script_size += $script_size; + $measured[ $dep_handle ] = array( + 'path' => $dep_path, + 'size' => $dep_size, + ); + $total_size += $dep_size; + } } - if ( $plugin_script_size > $this->threshold_size ) { - foreach ( $plugin_scripts as $plugin_script ) { + if ( $total_size > $this->threshold_size ) { + foreach ( array_keys( $plugin_scripts ) as $handle ) { + // Only surface warnings for the plugin's own files; dependencies are counted toward + // the total but are not something the plugin author edits directly. + if ( empty( $measured[ $handle ]['path'] ) ) { + continue; + } + $this->add_result_warning_for_file( $result, sprintf( - /* translators: 1: style file size. 2: tested URL. 3: threshold file size. */ + /* translators: 1: script file size. 2: tested URL. 3: threshold file size. */ __( 'This script has a size of %1$s which in combination with the other scripts enqueued on %2$s exceeds the script size threshold of %3$s.', 'plugin-check' ), - size_format( $plugin_script['size'] ), + size_format( $measured[ $handle ]['size'] ), $url, size_format( $this->threshold_size ) ), 'EnqueuedScriptsSize.ScriptSizeGreaterThanThreshold', - $plugin_script['path'] + $measured[ $handle ]['path'] ); } } } + /** + * Returns a script handle together with its dependencies, resolved recursively and de-duplicated. + * + * @since 1.6.0 + * + * @param string $handle Script handle to start from. + * @param \WP_Scripts $wp_scripts The scripts registry. + * @param array $seen Handles already collected, passed by reference to de-duplicate. + * @return string[] The handle followed by every dependency handle, each appearing once. + */ + private function get_handle_with_dependencies( $handle, $wp_scripts, array &$seen ) { + if ( isset( $seen[ $handle ] ) || ! isset( $wp_scripts->registered[ $handle ] ) ) { + return array(); + } + + $seen[ $handle ] = true; + $handles = array( $handle ); + + foreach ( $wp_scripts->registered[ $handle ]->deps as $dep ) { + $handles = array_merge( $handles, $this->get_handle_with_dependencies( $dep, $wp_scripts, $seen ) ); + } + + return $handles; + } + + /** + * Resolves a script src to a local file path, or false when it can't be measured. + * + * Handles the plugin's own assets as well as dependencies served from elsewhere on the site + * (core scripts under wp-includes, other plugins, the uploads or content directories). Scripts + * served from another host, such as a CDN, return false since their size can't be read locally. + * + * @since 1.6.0 + * + * @param string $src The script src. + * @param string $plugin_url The plugin's base URL. + * @param string $plugin_path The plugin's base path. + * @return string|false The local file path, or false if it can't be resolved to a readable file. + */ + private function get_path_from_src( $src, $plugin_url, $plugin_path ) { + if ( ! $src ) { + return false; + } + + // The plugin's own assets map directly, which also handles symlinked or mu-plugin locations. + if ( strpos( $src, $plugin_url ) === 0 ) { + $path = strtok( str_replace( $plugin_url, $plugin_path, $src ), '?' ); + + return ( $path && file_exists( $path ) ) ? $path : false; + } + + $src = strtok( $src, '?' ); + + if ( strpos( $src, '//' ) === 0 ) { + $src = ( is_ssl() ? 'https:' : 'http:' ) . $src; + } + + // Core registers its scripts with a root-relative src, e.g. /wp-includes/js/jquery/jquery.min.js. + if ( strpos( $src, '/' ) === 0 ) { + $src = site_url( $src ); + } + + // Map known local URL roots to their filesystem paths, most specific first. + $roots = array( + array( plugins_url(), WP_PLUGIN_DIR ), + array( content_url(), WP_CONTENT_DIR ), + array( includes_url(), ABSPATH . WPINC . '/' ), + array( site_url( '/' ), ABSPATH ), + ); + + foreach ( $roots as $root ) { + list( $url_root, $dir_root ) = $root; + + if ( $url_root && strpos( $src, $url_root ) === 0 ) { + $path = wp_normalize_path( $dir_root . substr( $src, strlen( $url_root ) ) ); + + return ( file_exists( $path ) && is_readable( $path ) ) ? $path : false; + } + } + + return false; + } + + /** + * Returns the byte size of a file, treating an unreadable file as zero. + * + * @since 1.6.0 + * + * @param string $path Absolute file path. + * @return int Size in bytes. + */ + private function get_file_size( $path ) { + $size = function_exists( 'wp_filesize' ) ? wp_filesize( $path ) : filesize( $path ); + + return is_int( $size ) ? $size : (int) $size; + } + + /** + * Returns the combined byte size of a script's inline before/after additions. + * + * @since 1.6.0 + * + * @param object $script The registered script object. + * @return int Size in bytes. + */ + private function get_inline_size( $script ) { + $size = 0; + + foreach ( array( 'before', 'after' ) as $position ) { + if ( empty( $script->extra[ $position ] ) ) { + continue; + } + + foreach ( $script->extra[ $position ] as $extra ) { + $size += is_string( $extra ) ? mb_strlen( $extra, '8bit' ) : 0; + } + } + + return $size; + } + /** * Returns an array of viewable post types. * diff --git a/tests/phpunit/testdata/plugins/test-plugin-enqueued-script-size-check/load.php b/tests/phpunit/testdata/plugins/test-plugin-enqueued-script-size-check/load.php index ae88c603d..55a4a2ce5 100644 --- a/tests/phpunit/testdata/plugins/test-plugin-enqueued-script-size-check/load.php +++ b/tests/phpunit/testdata/plugins/test-plugin-enqueued-script-size-check/load.php @@ -6,10 +6,29 @@ add_action( 'wp_enqueue_scripts', function() { - // Script size is 21 bytes. + // A dependency served from outside the plugin directory, created on the fly so the test + // does not rely on a particular core script being present. In the wild this stands in for + // something like jQuery or a shared vendor library that a plugin script depends on. + $uploads = wp_upload_dir(); + $dep_file = trailingslashit( $uploads['basedir'] ) . 'plugin-check-external-dependency.js'; + + if ( ! file_exists( $dep_file ) ) { + file_put_contents( $dep_file, str_repeat( 'a', 1000 ) ); + } + + wp_register_script( + 'plugin_check_external_dependency', + trailingslashit( $uploads['baseurl'] ) . 'plugin-check-external-dependency.js', + array(), + '1.0.0' + ); + + // Script size is 21 bytes. Depends on the external script above, which lives outside the + // plugin directory, to exercise dependency size accounting. wp_enqueue_script( 'plugin_check_test_script', - plugin_dir_url( __FILE__ ) . 'test-script.js' + plugin_dir_url( __FILE__ ) . 'test-script.js', + array( 'plugin_check_external_dependency' ) ); wp_add_inline_script( @@ -18,4 +37,3 @@ function() { ); } ); - diff --git a/tests/phpunit/tests/Checker/Checks/Enqueued_Scripts_Size_Check_Tests.php b/tests/phpunit/tests/Checker/Checks/Enqueued_Scripts_Size_Check_Tests.php index 9461665ab..8b11a5e5f 100644 --- a/tests/phpunit/tests/Checker/Checks/Enqueued_Scripts_Size_Check_Tests.php +++ b/tests/phpunit/tests/Checker/Checks/Enqueued_Scripts_Size_Check_Tests.php @@ -115,4 +115,22 @@ public function test_run_with_errors_for_inline_script() { $this->assertEquals( 0, $results->get_error_count() ); $this->assertEquals( 4, $results->get_warning_count() ); } + + public function test_run_counts_external_dependencies() { + // Load the test plugin, whose script depends on a 1000 byte script served from outside + // the plugin directory. + require UNIT_TESTS_PLUGIN_DIR . 'test-plugin-enqueued-script-size-check/load.php'; + + // A threshold above the plugin's own script but below its size once the external + // dependency is counted. Warnings only appear if dependencies are included in the total. + $check = new Enqueued_Scripts_Size_Check( 500 ); + $context = $this->get_context( WP_PLUGIN_CHECK_MAIN_FILE ); + $results = $this->run_check( $check, $context ); + + $this->assertEmpty( $results->get_errors() ); + $this->assertNotEmpty( $results->get_warnings() ); + + $this->assertEquals( 0, $results->get_error_count() ); + $this->assertEquals( 4, $results->get_warning_count() ); + } } From b9cf12bf29fc40d962ea8f0510adee6f593de831 Mon Sep 17 00:00:00 2001 From: gunjanjaswal Date: Sun, 27 Sep 2026 10:45:18 +0530 Subject: [PATCH 2/3] Fix PHPStan, count inline payload on unresolvable deps, widen tests - Use ABSPATH . 'wp-includes/' instead of the WPINC constant, which the PHPStan config doesn't have stubbed. - Count a dependency's inline before/after code even when its file can't be resolved locally (e.g. a CDN script); WordPress still prints it. - Exercise a transitive dependency and a remote (skipped) dependency in the fixture, and clean up the fixture's uploads files on tear down. --- .../Enqueued_Scripts_Size_Check.php | 7 +++- .../load.php | 40 ++++++++++++++----- .../Enqueued_Scripts_Size_Check_Tests.php | 24 ++++++++--- 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php b/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php index 3a4cd0ec0..93669c2ba 100644 --- a/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php +++ b/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php @@ -226,7 +226,10 @@ protected function check_url( Check_Result $result, $url ) { $dep_script = $wp_scripts->registered[ $dep_handle ]; $dep_path = $this->get_path_from_src( $dep_script->src, $plugin_url, $plugin_path ); - $dep_size = $dep_path ? ( $this->get_file_size( $dep_path ) + $this->get_inline_size( $dep_script ) ) : 0; + + // The file itself may be unmeasurable (e.g. a CDN-hosted dependency), but any inline + // before/after code attached to the handle is still printed, so count it regardless. + $dep_size = ( $dep_path ? $this->get_file_size( $dep_path ) : 0 ) + $this->get_inline_size( $dep_script ); $measured[ $dep_handle ] = array( 'path' => $dep_path, @@ -326,7 +329,7 @@ private function get_path_from_src( $src, $plugin_url, $plugin_path ) { $roots = array( array( plugins_url(), WP_PLUGIN_DIR ), array( content_url(), WP_CONTENT_DIR ), - array( includes_url(), ABSPATH . WPINC . '/' ), + array( includes_url(), ABSPATH . 'wp-includes/' ), array( site_url( '/' ), ABSPATH ), ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-enqueued-script-size-check/load.php b/tests/phpunit/testdata/plugins/test-plugin-enqueued-script-size-check/load.php index 55a4a2ce5..d08b0f48c 100644 --- a/tests/phpunit/testdata/plugins/test-plugin-enqueued-script-size-check/load.php +++ b/tests/phpunit/testdata/plugins/test-plugin-enqueued-script-size-check/load.php @@ -6,29 +6,51 @@ add_action( 'wp_enqueue_scripts', function() { - // A dependency served from outside the plugin directory, created on the fly so the test - // does not rely on a particular core script being present. In the wild this stands in for + // Dependencies served from outside the plugin directory, created on the fly so the test + // does not rely on any particular core script being present. In the wild these stand in for // something like jQuery or a shared vendor library that a plugin script depends on. - $uploads = wp_upload_dir(); - $dep_file = trailingslashit( $uploads['basedir'] ) . 'plugin-check-external-dependency.js'; + $uploads = wp_upload_dir(); + $basedir = trailingslashit( $uploads['basedir'] ); + $baseurl = trailingslashit( $uploads['baseurl'] ); - if ( ! file_exists( $dep_file ) ) { - file_put_contents( $dep_file, str_repeat( 'a', 1000 ) ); + $dependency = $basedir . 'plugin-check-external-dependency.js'; + $transitive_dependency = $basedir . 'plugin-check-external-transitive-dependency.js'; + + if ( ! file_exists( $dependency ) ) { + file_put_contents( $dependency, str_repeat( 'a', 1000 ) ); + } + if ( ! file_exists( $transitive_dependency ) ) { + file_put_contents( $transitive_dependency, str_repeat( 'b', 500 ) ); } + // A transitive dependency: the direct dependency (1000 bytes) itself depends on this one (500 bytes). + wp_register_script( + 'plugin_check_external_transitive_dependency', + $baseurl . 'plugin-check-external-transitive-dependency.js', + array(), + '1.0.0' + ); wp_register_script( 'plugin_check_external_dependency', - trailingslashit( $uploads['baseurl'] ) . 'plugin-check-external-dependency.js', + $baseurl . 'plugin-check-external-dependency.js', + array( 'plugin_check_external_transitive_dependency' ), + '1.0.0' + ); + + // A remote dependency whose size can't be read locally; it must be skipped, not fatal. + wp_register_script( + 'plugin_check_remote_dependency', + 'https://cdn.example.com/remote-dependency.js', array(), '1.0.0' ); - // Script size is 21 bytes. Depends on the external script above, which lives outside the + // Script size is 21 bytes. Depends on the external scripts above, which live outside the // plugin directory, to exercise dependency size accounting. wp_enqueue_script( 'plugin_check_test_script', plugin_dir_url( __FILE__ ) . 'test-script.js', - array( 'plugin_check_external_dependency' ) + array( 'plugin_check_external_dependency', 'plugin_check_remote_dependency' ) ); wp_add_inline_script( diff --git a/tests/phpunit/tests/Checker/Checks/Enqueued_Scripts_Size_Check_Tests.php b/tests/phpunit/tests/Checker/Checks/Enqueued_Scripts_Size_Check_Tests.php index 8b11a5e5f..855ac8daa 100644 --- a/tests/phpunit/tests/Checker/Checks/Enqueued_Scripts_Size_Check_Tests.php +++ b/tests/phpunit/tests/Checker/Checks/Enqueued_Scripts_Size_Check_Tests.php @@ -11,6 +11,18 @@ class Enqueued_Scripts_Size_Check_Tests extends Runtime_Check_UnitTestCase { + public function tear_down() { + // Remove the dependency files the size-check fixture writes to the uploads directory. + $basedir = trailingslashit( wp_upload_dir()['basedir'] ); + foreach ( array( 'plugin-check-external-dependency.js', 'plugin-check-external-transitive-dependency.js' ) as $file ) { + if ( file_exists( $basedir . $file ) ) { + unlink( $basedir . $file ); + } + } + + parent::tear_down(); + } + public function test_get_shared_preparations() { $check = new Enqueued_Scripts_Size_Check(); $preparations = $check->get_shared_preparations(); @@ -117,13 +129,15 @@ public function test_run_with_errors_for_inline_script() { } public function test_run_counts_external_dependencies() { - // Load the test plugin, whose script depends on a 1000 byte script served from outside - // the plugin directory. + // The fixture's script depends on a 1000 byte script served from the uploads directory, + // which in turn depends on a 500 byte transitive one, plus a remote (CDN) script that + // can't be measured locally and must be skipped rather than fataling. require UNIT_TESTS_PLUGIN_DIR . 'test-plugin-enqueued-script-size-check/load.php'; - // A threshold above the plugin's own script but below its size once the external - // dependency is counted. Warnings only appear if dependencies are included in the total. - $check = new Enqueued_Scripts_Size_Check( 500 ); + // The threshold sits above the plugin's own script plus its direct dependency (~1053 bytes) + // but below the total once the transitive dependency is also counted (~1553 bytes), so the + // warning only appears if the dependency graph is walked recursively. + $check = new Enqueued_Scripts_Size_Check( 1200 ); $context = $this->get_context( WP_PLUGIN_CHECK_MAIN_FILE ); $results = $this->run_check( $check, $context ); From 3e35f2907855e6ba6ac3a8d4fca09dbda5d5ce88 Mon Sep 17 00:00:00 2001 From: gunjanjaswal Date: Sun, 27 Sep 2026 11:09:57 +0530 Subject: [PATCH 3/3] Split get_path_from_src to keep NPath complexity under the PHPMD threshold --- .../Enqueued_Scripts_Size_Check.php | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php b/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php index 93669c2ba..27449e669 100644 --- a/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php +++ b/includes/Checker/Checks/Performance/Enqueued_Scripts_Size_Check.php @@ -325,6 +325,19 @@ private function get_path_from_src( $src, $plugin_url, $plugin_path ) { $src = site_url( $src ); } + return $this->get_local_path_from_roots( $src ); + } + + /** + * Maps a fully-qualified local URL to a readable filesystem path, or false when + * it maps to no known local root (e.g. a script served from another host). + * + * @since 1.6.0 + * + * @param string $src Fully-qualified script URL (query string already stripped). + * @return string|false The local file path, or false if it can't be resolved to a readable file. + */ + private function get_local_path_from_roots( $src ) { // Map known local URL roots to their filesystem paths, most specific first. $roots = array( array( plugins_url(), WP_PLUGIN_DIR ), @@ -336,11 +349,13 @@ private function get_path_from_src( $src, $plugin_url, $plugin_path ) { foreach ( $roots as $root ) { list( $url_root, $dir_root ) = $root; - if ( $url_root && strpos( $src, $url_root ) === 0 ) { - $path = wp_normalize_path( $dir_root . substr( $src, strlen( $url_root ) ) ); - - return ( file_exists( $path ) && is_readable( $path ) ) ? $path : false; + if ( ! $url_root || strpos( $src, $url_root ) !== 0 ) { + continue; } + + $path = wp_normalize_path( $dir_root . substr( $src, strlen( $url_root ) ) ); + + return ( file_exists( $path ) && is_readable( $path ) ) ? $path : false; } return false;