diff --git a/src/wp-includes/class-wp-script-modules.php b/src/wp-includes/class-wp-script-modules.php index abbbd6383fec6..56bc3c5d34444 100644 --- a/src/wp-includes/class-wp-script-modules.php +++ b/src/wp-includes/class-wp-script-modules.php @@ -533,13 +533,77 @@ public function print_import_map() { * Returns the import map array. * * @since 6.5.0 + * @since 7.0.0 Script module dependencies ('module_dependencies') of classic scripts are now included. + * + * @global WP_Scripts $wp_scripts * * @return array> Array with an `imports` key mapping to an array of script module * identifiers and their respective URLs, including the version query. */ private function get_import_map(): array { + global $wp_scripts; + $imports = array(); - foreach ( array_keys( $this->get_dependencies( $this->queue ) ) as $id ) { + + // Identify script modules that are dependencies of classic scripts. + $classic_script_module_dependencies = array(); + if ( $wp_scripts instanceof WP_Scripts ) { + $handles = array_merge( + $wp_scripts->queue, + $wp_scripts->to_do, + $wp_scripts->done + ); + + $processed = array(); + while ( ! empty( $handles ) ) { + $handle = array_pop( $handles ); + if ( isset( $processed[ $handle ] ) || ! isset( $wp_scripts->registered[ $handle ] ) ) { + continue; + } + $processed[ $handle ] = true; + + $module_dependencies = $wp_scripts->get_data( $handle, 'module_dependencies' ); + if ( is_array( $module_dependencies ) ) { + $missing_module_dependencies = array(); + foreach ( $module_dependencies as $id ) { + if ( ! isset( $this->registered[ $id ] ) ) { + $missing_module_dependencies[] = $id; + } else { + $classic_script_module_dependencies[] = $id; + } + } + + if ( count( $missing_module_dependencies ) > 0 ) { + _doing_it_wrong( + 'WP_Scripts::add_data', + sprintf( + /* translators: 1: Script handle, 2: 'module_dependencies', 3: List of missing dependency IDs. */ + __( 'The script with the handle "%1$s" was enqueued with script module dependencies ("%2$s") that are not registered: %3$s.' ), + $handle, + 'module_dependencies', + implode( wp_get_list_item_separator(), $missing_module_dependencies ) + ), + '7.0.0' + ); + } + } + + foreach ( $wp_scripts->registered[ $handle ]->deps as $dep ) { + if ( ! isset( $processed[ $dep ] ) ) { + $handles[] = $dep; + } + } + } + } + + // Note: the script modules in $this->queue are not included in the importmap because they get printed as scripts. + $ids = array_unique( + array_merge( + $classic_script_module_dependencies, + array_keys( $this->get_dependencies( array_merge( $this->queue, $classic_script_module_dependencies ) ) ) + ) + ); + foreach ( $ids as $id ) { $src = $this->get_src( $id ); if ( '' !== $src ) { $imports[ $id ] = $src; diff --git a/src/wp-includes/class-wp-scripts.php b/src/wp-includes/class-wp-scripts.php index 0ed7087a93f4c..fcc4fe0e5a04c 100644 --- a/src/wp-includes/class-wp-scripts.php +++ b/src/wp-includes/class-wp-scripts.php @@ -920,6 +920,45 @@ public function add_data( $handle, $key, $value ) { ); return false; } + } elseif ( 'module_dependencies' === $key ) { + if ( ! is_array( $value ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: 'module_dependencies', 2: Script handle. */ + __( 'The value for "%1$s" must be an array for the "%2$s" script.' ), + 'module_dependencies', + $handle + ), + '7.0.0' + ); + return false; + } + + $sanitized_value = array(); + $has_invalid_ids = false; + foreach ( $value as $id ) { + if ( ! is_string( $id ) ) { + $has_invalid_ids = true; + } else { + $sanitized_value[] = $id; + } + } + + if ( $has_invalid_ids ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: Script handle, 2: 'module_dependencies' */ + __( 'The script handle "%1$s" has one or more of its script module dependencies ("%2$s") which are not strings.' ), + $handle, + 'module_dependencies' + ), + '7.0.0' + ); + } + + $value = $sanitized_value; } return parent::add_data( $handle, $key, $value ); } diff --git a/src/wp-includes/functions.wp-scripts.php b/src/wp-includes/functions.wp-scripts.php index f1a9b2afd6b7c..23ed882517ba7 100644 --- a/src/wp-includes/functions.wp-scripts.php +++ b/src/wp-includes/functions.wp-scripts.php @@ -68,6 +68,47 @@ function _wp_scripts_maybe_doing_it_wrong( $function_name, $handle = '' ) { ); } +/** + * Adds the data for the recognized args and warns for unrecognized args. + * + * @ignore + * @since 7.0.0 + * + * @param string $function_name Function name. + * @param WP_Scripts $wp_scripts WP_Scripts instance. + * @param string $handle Script handle. + * @param array $args Array of extra args for the script. + */ +function _wp_scripts_add_args_data( string $function_name, WP_Scripts $wp_scripts, string $handle, array $args ) { + $allowed_keys = array( 'strategy', 'in_footer', 'fetchpriority', 'module_dependencies' ); + $unknown_keys = array_diff( array_keys( $args ), $allowed_keys ); + if ( ! empty( $unknown_keys ) ) { + _doing_it_wrong( + $function_name, + sprintf( + /* translators: 1: $args, 2: List of unrecognized keys. */ + __( 'Unrecognized keys in the %1$s array: %2$s.' ), + '$args', + implode( wp_get_list_item_separator(), $unknown_keys ) + ), + '7.0.0' + ); + } + + if ( ! empty( $args['in_footer'] ) ) { + $wp_scripts->add_data( $handle, 'group', 1 ); + } + if ( ! empty( $args['strategy'] ) ) { + $wp_scripts->add_data( $handle, 'strategy', $args['strategy'] ); + } + if ( ! empty( $args['fetchpriority'] ) ) { + $wp_scripts->add_data( $handle, 'fetchpriority', $args['fetchpriority'] ); + } + if ( ! empty( $args['module_dependencies'] ) ) { + $wp_scripts->add_data( $handle, 'module_dependencies', $args['module_dependencies'] ); + } +} + /** * Prints scripts in document head that are in the $handles queue. * @@ -159,22 +200,24 @@ function wp_add_inline_script( $handle, $data, $position = 'after' ) { * @since 4.3.0 A return value was added. * @since 6.3.0 The $in_footer parameter of type boolean was overloaded to be an $args parameter of type array. * @since 6.9.0 The $fetchpriority parameter of type string was added to the $args parameter of type array. - * - * @param string $handle Name of the script. Should be unique. - * @param string|false $src Full URL of the script, or path of the script relative to the WordPress root directory. - * If source is set to false, script is an alias of other scripts it depends on. - * @param string[] $deps Optional. An array of registered script handles this script depends on. Default empty array. - * @param string|bool|null $ver Optional. String specifying script version number, if it has one, which is added to the URL - * as a query string for cache busting purposes. If version is set to false, a version - * number is automatically added equal to current installed WordPress version. - * If set to null, no version is added. - * @param array|bool $args { - * Optional. An array of additional script loading strategies. Default empty array. + * @since 7.0.0 The $module_dependencies parameter of type string[] was added to the $args parameter of type array. + * + * @param string $handle Name of the script. Should be unique. + * @param string|false $src Full URL of the script, or path of the script relative to the WordPress root directory. + * If source is set to false, script is an alias of other scripts it depends on. + * @param string[] $deps Optional. An array of registered script handles this script depends on. Default empty array. + * @param string|bool|null $ver Optional. String specifying script version number, if it has one, which is added to the URL + * as a query string for cache busting purposes. If version is set to false, a version + * number is automatically added equal to current installed WordPress version. + * If set to null, no version is added. + * @param array|bool $args { + * Optional. An array of extra args for the script. Default empty array. * Otherwise, it may be a boolean in which case it determines whether the script is printed in the footer. Default false. * - * @type string $strategy Optional. If provided, may be either 'defer' or 'async'. - * @type bool $in_footer Optional. Whether to print the script in the footer. Default 'false'. - * @type string $fetchpriority Optional. The fetch priority for the script. Default 'auto'. + * @type string $strategy Optional. If provided, may be either 'defer' or 'async'. + * @type bool $in_footer Optional. Whether to print the script in the footer. Default 'false'. + * @type string $fetchpriority Optional. The fetch priority for the script. Default 'auto'. + * @type string[] $module_dependencies Optional. IDs for module dependencies loaded via dynamic import. Default empty array. * } * @return bool Whether the script has been registered. True on success, false on failure. */ @@ -189,15 +232,8 @@ function wp_register_script( $handle, $src, $deps = array(), $ver = false, $args $wp_scripts = wp_scripts(); $registered = $wp_scripts->add( $handle, $src, $deps, $ver ); - if ( ! empty( $args['in_footer'] ) ) { - $wp_scripts->add_data( $handle, 'group', 1 ); - } - if ( ! empty( $args['strategy'] ) ) { - $wp_scripts->add_data( $handle, 'strategy', $args['strategy'] ); - } - if ( ! empty( $args['fetchpriority'] ) ) { - $wp_scripts->add_data( $handle, 'fetchpriority', $args['fetchpriority'] ); - } + _wp_scripts_add_args_data( __FUNCTION__, $wp_scripts, $handle, $args ); + return $registered; } @@ -345,22 +381,24 @@ function wp_deregister_script( $handle ) { * @since 2.1.0 * @since 6.3.0 The $in_footer parameter of type boolean was overloaded to be an $args parameter of type array. * @since 6.9.0 The $fetchpriority parameter of type string was added to the $args parameter of type array. - * - * @param string $handle Name of the script. Should be unique. - * @param string $src Full URL of the script, or path of the script relative to the WordPress root directory. - * Default empty. - * @param string[] $deps Optional. An array of registered script handles this script depends on. Default empty array. - * @param string|bool|null $ver Optional. String specifying script version number, if it has one, which is added to the URL - * as a query string for cache busting purposes. If version is set to false, a version - * number is automatically added equal to current installed WordPress version. - * If set to null, no version is added. - * @param array|bool $args { - * Optional. An array of additional script loading strategies. Default empty array. + * @since 7.0.0 The $module_dependencies parameter of type string[] was added to the $args parameter of type array. + * + * @param string $handle Name of the script. Should be unique. + * @param string $src Full URL of the script, or path of the script relative to the WordPress root directory. + * Default empty. + * @param string[] $deps Optional. An array of registered script handles this script depends on. Default empty array. + * @param string|bool|null $ver Optional. String specifying script version number, if it has one, which is added to the URL + * as a query string for cache busting purposes. If version is set to false, a version + * number is automatically added equal to current installed WordPress version. + * If set to null, no version is added. + * @param array|bool $args { + * Optional. An array of extra args for the script. Default empty array. * Otherwise, it may be a boolean in which case it determines whether the script is printed in the footer. Default false. * - * @type string $strategy Optional. If provided, may be either 'defer' or 'async'. - * @type bool $in_footer Optional. Whether to print the script in the footer. Default 'false'. - * @type string $fetchpriority Optional. The fetch priority for the script. Default 'auto'. + * @type string $strategy Optional. If provided, may be either 'defer' or 'async'. + * @type bool $in_footer Optional. Whether to print the script in the footer. Default 'false'. + * @type string $fetchpriority Optional. The fetch priority for the script. Default 'auto'. + * @type string[] $module_dependencies Optional. IDs for module dependencies loaded via dynamic import. Default empty array. * } */ function wp_enqueue_script( $handle, $src = '', $deps = array(), $ver = false, $args = array() ) { @@ -379,14 +417,8 @@ function wp_enqueue_script( $handle, $src = '', $deps = array(), $ver = false, $ if ( $src ) { $wp_scripts->add( $_handle[0], $src, $deps, $ver ); } - if ( ! empty( $args['in_footer'] ) ) { - $wp_scripts->add_data( $_handle[0], 'group', 1 ); - } - if ( ! empty( $args['strategy'] ) ) { - $wp_scripts->add_data( $_handle[0], 'strategy', $args['strategy'] ); - } - if ( ! empty( $args['fetchpriority'] ) ) { - $wp_scripts->add_data( $_handle[0], 'fetchpriority', $args['fetchpriority'] ); + if ( ! empty( $args ) ) { + _wp_scripts_add_args_data( __FUNCTION__, $wp_scripts, $_handle[0], $args ); } } diff --git a/tests/phpunit/tests/dependencies/scripts.php b/tests/phpunit/tests/dependencies/scripts.php index 846cbb125d26c..f979e2320396c 100644 --- a/tests/phpunit/tests/dependencies/scripts.php +++ b/tests/phpunit/tests/dependencies/scripts.php @@ -105,6 +105,73 @@ public function get_script_html() { $this->assertEqualHTML( $expected, $processor->get_script_html(), '', $message ); } + /** + * Tests that scripts trigger _doing_it_wrong for unrecognized keys in the $args array. + * + * @ticket 63486 + * + * @covers ::wp_register_script + * @covers ::wp_enqueue_script + * @covers ::_wp_scripts_add_args_data + * + * @dataProvider data_unrecognized_keys_in_args + * + * @param string $function_name Function name to call. + * @param array $args Arguments to pass to the function. + * @param string $expected_msg Expected error message substring. + */ + public function test_unrecognized_keys_in_args( string $function_name, array $args, string $expected_msg ) { + $this->setExpectedIncorrectUsage( $function_name ); + + call_user_func_array( $function_name, $args ); + + $this->assertStringContainsString( + $expected_msg, + $this->caught_doing_it_wrong[ $function_name ] + ); + } + + /** + * Data provider for test_unrecognized_keys_in_args. + * + * @return array + */ + public function data_unrecognized_keys_in_args(): array { + return array( + 'register_script' => array( + 'function_name' => 'wp_register_script', + 'args' => array( + 'unrecognized-key-register', + '/script.js', + array(), + null, + array( + 'unrecognized_key' => 'value', + 'another_bad_key' => 'value', + ), + ), + 'expected_msg' => 'Unrecognized keys in the $args array: unrecognized_key, another_bad_key', + ), + 'enqueue_script' => array( + 'function_name' => 'wp_enqueue_script', + 'args' => array( + 'unrecognized-key-enqueue', + '/script.js', + array(), + null, + array( + 'strategy' => 'defer', + 'in_footer' => true, + 'fetchpriority' => 'high', + 'module_dependencies' => array( 'foo' ), + 'invalid_key' => 'bar', + ), + ), + 'expected_msg' => 'Unrecognized keys in the $args array: invalid_key', + ), + ); + } + /** * Test versioning * @@ -1277,6 +1344,58 @@ public function test_invalid_fetchpriority_on_alias() { $this->assertArrayNotHasKey( 'fetchpriority', wp_scripts()->registered['alias']->extra ); } + /** + * Tests validation of module_dependencies in WP_Scripts::add_data(). + * + * @ticket 61500 + * + * @covers WP_Scripts::add_data + * + * @dataProvider data_add_data_module_dependencies_validation + * + * @param mixed $data Data to add. + * @param string $message Expected error message. + * @param bool $expected Expected return value. + * @param array|null $stored Expected stored value. + */ + public function test_add_data_module_dependencies_validation( $data, string $message, bool $expected, ?array $stored ) { + wp_register_script( 'test-script', '/test.js' ); + + $expected_incorrect_usage = 'WP_Scripts::add_data'; + $this->setExpectedIncorrectUsage( $expected_incorrect_usage ); + + $this->assertSame( $expected, wp_scripts()->add_data( 'test-script', 'module_dependencies', $data ) ); + $this->assertStringContainsString( $message, $this->caught_doing_it_wrong[ $expected_incorrect_usage ] ); + + if ( null === $stored ) { + $this->assertFalse( wp_scripts()->get_data( 'test-script', 'module_dependencies' ) ); + } else { + $this->assertSame( $stored, wp_scripts()->get_data( 'test-script', 'module_dependencies' ) ); + } + } + + /** + * Data provider. + * + * @return array + */ + public function data_add_data_module_dependencies_validation(): array { + return array( + 'non-array' => array( + 'data' => 'not-an-array', + 'message' => 'The value for "module_dependencies" must be an array', + 'expected' => false, + 'stored' => null, + ), + 'bad-items' => array( + 'data' => array( 'valid', 123, true, array() ), + 'message' => 'has one or more of its script module dependencies ("module_dependencies") which are not strings', + 'expected' => true, + 'stored' => array( 'valid' ), + ), + ); + } + /** * Data provider. * diff --git a/tests/phpunit/tests/script-modules/wpScriptModules.php b/tests/phpunit/tests/script-modules/wpScriptModules.php index 1bd8b5c1663d3..f34c32b7d96d9 100644 --- a/tests/phpunit/tests/script-modules/wpScriptModules.php +++ b/tests/phpunit/tests/script-modules/wpScriptModules.php @@ -11,43 +11,39 @@ */ class Tests_Script_Modules_WpScriptModules extends WP_UnitTestCase { - /** - * @var WP_Script_Modules - */ - protected $original_script_modules; + protected WP_Script_Modules $original_script_modules; - /** - * @var string - */ - protected $original_wp_version; + protected string $original_wp_version; - /** - * Instance of WP_Script_Modules. - * - * @var WP_Script_Modules - */ - protected $script_modules; + protected ?WP_Scripts $old_wp_scripts; + + protected WP_Script_Modules $script_modules; /** * Set up. */ public function set_up() { - global $wp_script_modules, $wp_version; + global $wp_script_modules, $wp_scripts, $wp_version; parent::set_up(); $this->original_script_modules = $wp_script_modules; $this->original_wp_version = $wp_version; + $this->old_wp_scripts = $GLOBALS['wp_scripts'] ?? null; $wp_script_modules = null; $this->script_modules = wp_script_modules(); + + $wp_scripts = new WP_Scripts(); + $wp_scripts->default_version = get_bloginfo( 'version' ); } /** * Tear down. */ public function tear_down() { - global $wp_script_modules, $wp_version; - parent::tear_down(); + global $wp_script_modules, $wp_scripts, $wp_version; $wp_script_modules = $this->original_script_modules; $wp_version = $this->original_wp_version; + $wp_scripts = $this->old_wp_scripts; + parent::tear_down(); } /** @@ -1985,6 +1981,168 @@ public static function data_invalid_script_module_data(): array { ); } + /** + * Tests that script modules identified as dependencies of classic scripts are included in the import map. + * + * @ticket 61500 + * + * @covers WP_Script_Modules::get_import_map + */ + public function test_included_module_appears_in_importmap() { + $this->script_modules->register( 'dependency', '/dep.js' ); + $this->script_modules->register( 'example', '/example.js', array( 'dependency' ) ); + + // Nothing printed now. + $this->assertSame( array(), $this->get_enqueued_script_modules(), 'Initial enqueued script modules was wrong.' ); + $this->assertSame( array(), $this->get_preloaded_script_modules(), 'Initial module preloads was wrong.' ); + $this->assertSame( array(), $this->get_import_map(), 'Initial import map was wrong.' ); + + // Enqueuing a script with a module dependency should add it to the import map. + wp_enqueue_script( + 'classic', + '/classic.js', + array( 'classic-dependency' ), + false, + array( + 'module_dependencies' => array( 'example' ), + ) + ); + + $this->assertSame( array(), $this->get_enqueued_script_modules(), 'Final enqueued script modules was wrong.' ); + $this->assertSame( array(), $this->get_preloaded_script_modules(), 'Final module preloads was wrong.' ); + $this->assertEqualSets( + array( 'example', 'dependency' ), + array_keys( $this->get_import_map() ), + 'Import map keys were wrong.' + ); + } + + /** + * Tests that dynamic dependencies of enqueued script modules are included in the import map. + * + * @ticket 61500 + * + * @covers WP_Script_Modules::get_import_map + */ + public function test_import_map_includes_dynamic_dependencies_of_enqueued_modules() { + $this->script_modules->register( 'dependency-of-enqueued', '/dependency-of-enqueued.js' ); + $this->script_modules->enqueue( + 'enqueued', + '/enqueued.js', + array( + array( + 'id' => 'dependency-of-enqueued', + 'import' => 'dynamic', + ), + ) + ); + + $enqueued = $this->get_enqueued_script_modules(); + $this->assertCount( 1, $enqueued, 'Enqueue count was wrong.' ); + $this->assertArrayHasKey( 'enqueued', $enqueued, 'Missing "enqueued" script module enqueue.' ); + $this->assertCount( 0, $this->get_preloaded_script_modules(), 'Module preload count was wrong.' ); + $this->assertEqualSets( + array( 'dependency-of-enqueued' ), + array_keys( $this->get_import_map() ), + 'Import map keys were wrong.' + ); + } + + /** + * Tests that script module dependencies of enqueued classic scripts (including transitive ones) are included in the import map. + * + * @ticket 61500 + * + * @covers WP_Script_Modules::get_import_map + */ + public function test_import_map_includes_dependencies_of_classic_scripts_recursive() { + $this->script_modules->register( 'classic-transitive-dependency', '/classic-transitive-dependency.js' ); + $this->script_modules->register( 'dependency-of-not-enqueued', '/dependency-of-not-enqueued.js' ); + $this->script_modules->register( 'not-enqueued', '/not-enqueued.js', array( 'dependency-of-not-enqueued' ) ); + + // Enqueuing a script with a module dependency should add it to the import map. + wp_register_script( + 'classic-transitive-dep', + '/classic-transitive-dep.js', + array(), + false, + array( + 'module_dependencies' => array( 'classic-transitive-dependency' ), + ) + ); + wp_enqueue_script( + 'classic', + '/classic.js', + array( 'classic-transitive-dep' ), + false, + array( + 'module_dependencies' => array( 'not-enqueued' ), + ) + ); + + $enqueued = $this->get_enqueued_script_modules(); + $this->assertCount( 0, $enqueued, 'Enqueue count was wrong.' ); + $this->assertCount( 0, $this->get_preloaded_script_modules(), 'Module preload count was wrong.' ); + $this->assertEqualSets( + array( + 'classic-transitive-dependency', + 'not-enqueued', + 'dependency-of-not-enqueued', + ), + array_keys( $this->get_import_map() ), + 'Import map keys were wrong.' + ); + } + + /** + * Tests that WP_Scripts emits a _doing_it_wrong() notice for missing script module dependencies. + * + * @ticket 61500 + * @ticket 64229 + * @covers WP_Script_Modules::get_import_map + */ + public function test_wp_scripts_doing_it_wrong_for_missing_script_module_dependencies() { + $expected_incorrect_usage = 'WP_Scripts::add_data'; + $this->setExpectedIncorrectUsage( $expected_incorrect_usage ); + + wp_enqueue_script( + 'registered-dep', + '/registered-dep.js', + array(), + null, + array( + 'module_dependencies' => array( 'does-not-exist' ), + ) + ); + + $import_map = $this->get_import_map(); + $this->assertSame( array(), $import_map, 'Expected importmap to be empty.' ); + $markup = get_echo( 'wp_print_scripts' ); + + /* + * In the future, we may want to have missing script module dependencies for classic scripts to cause the + * classic script to not be printed. This would align the behavior with script modules that have missing + * script module dependencies, and classic scripts that have missing classic script dependencies. Nevertheless, + * since script module dependencies rely on dynamic imports, the dependency may not be as strong. This means + * the classic script may still work or have a fallback in case the script module fails to dynamically import. + * This same change could be made for script modules as well, where if a script module has a missing dynamic + * script module dependency, this might similarly not be sufficient reason to omit printing the dependent script module. + */ + $this->assertStringContainsString( 'registered-dep.js', $markup, 'Expected script to be present, even though it has a missing script module dependency.' ); + + $this->assertArrayHasKey( + $expected_incorrect_usage, + $this->caught_doing_it_wrong, + "Expected $expected_incorrect_usage to trigger a _doing_it_wrong() notice for missing dependency." + ); + + $this->assertStringContainsString( + 'The script with the handle "registered-dep" was enqueued with script module dependencies ("module_dependencies") that are not registered: does-not-exist', + $this->caught_doing_it_wrong[ $expected_incorrect_usage ], + 'Expected _doing_it_wrong() notice to indicate missing script module dependencies for enqueued script.' + ); + } + /** * Tests various ways of printing and dependency ordering of script modules. *