diff --git a/src/wp-includes/class-wp-script-modules.php b/src/wp-includes/class-wp-script-modules.php index abbbd6383fec6..627ccbd0f424e 100644 --- a/src/wp-includes/class-wp-script-modules.php +++ b/src/wp-includes/class-wp-script-modules.php @@ -88,31 +88,31 @@ class WP_Script_Modules { * @since 6.5.0 * @since 6.9.0 Added the $args parameter. * - * @param string $id The identifier of the script module. Should be unique. It will be used in the - * final import map. - * @param string $src Optional. Full URL of the script module, or path of the script module relative - * to the WordPress root directory. If it is provided and the script module has - * not been registered yet, it will be registered. - * @param array $deps { - * Optional. List of dependencies. - * - * @type string|array ...$0 { - * An array of script module identifiers of the dependencies of this script - * module. The dependencies can be strings or arrays. If they are arrays, - * they need an `id` key with the script module identifier, and can contain - * an `import` key with either `static` or `dynamic`. By default, - * dependencies that don't contain an `import` key are considered static. - * - * @type string $id The script module identifier. - * @type string $import Optional. Import type. May be either `static` or - * `dynamic`. Defaults to `static`. - * } - * } - * @param string|false|null $version Optional. String specifying the script module version number. Defaults to false. - * It is added to the URL as a query string for cache busting purposes. If $version - * is set to false, the version number is the currently installed WordPress version. - * If $version is set to null, no version is added. - * @param array $args { + * @param string $id The identifier of the script module. Should be unique. It will be used in the + * final import map. + * @param string $src Optional. Full URL of the script module, or path of the script module relative + * to the WordPress root directory. If it is provided and the script module has + * not been registered yet, it will be registered. + * @param array> $deps { + * Optional. List of dependencies. + * + * @type string|array ...$0 { + * An array of script module identifiers of the dependencies of this script + * module. The dependencies can be strings or arrays. If they are arrays, + * they need an `id` key with the script module identifier, and can contain + * an `import` key with either `static` or `dynamic`. By default, + * dependencies that don't contain an `import` key are considered static. + * + * @type string $id The script module identifier. + * @type string $import Optional. Import type. May be either `static` or + * `dynamic`. Defaults to `static`. + * } + * } + * @param string|false|null $version Optional. String specifying the script module version number. Defaults to false. + * It is added to the URL as a query string for cache busting purposes. If $version + * is set to false, the version number is the currently installed WordPress version. + * If $version is set to null, no version is added. + * @param array $args { * Optional. An array of additional args. Default empty array. * * @type bool $in_footer Whether to print the script module in the footer. Only relevant to block themes. Default 'false'. Optional. @@ -260,31 +260,31 @@ public function set_in_footer( string $id, bool $in_footer ): bool { * @since 6.5.0 * @since 6.9.0 Added the $args parameter. * - * @param string $id The identifier of the script module. Should be unique. It will be used in the - * final import map. - * @param string $src Optional. Full URL of the script module, or path of the script module relative - * to the WordPress root directory. If it is provided and the script module has - * not been registered yet, it will be registered. - * @param array $deps { - * Optional. List of dependencies. - * - * @type string|array ...$0 { - * An array of script module identifiers of the dependencies of this script - * module. The dependencies can be strings or arrays. If they are arrays, - * they need an `id` key with the script module identifier, and can contain - * an `import` key with either `static` or `dynamic`. By default, - * dependencies that don't contain an `import` key are considered static. - * - * @type string $id The script module identifier. - * @type string $import Optional. Import type. May be either `static` or - * `dynamic`. Defaults to `static`. - * } - * } - * @param string|false|null $version Optional. String specifying the script module version number. Defaults to false. - * It is added to the URL as a query string for cache busting purposes. If $version - * is set to false, the version number is the currently installed WordPress version. - * If $version is set to null, no version is added. - * @param array $args { + * @param string $id The identifier of the script module. Should be unique. It will be used in the + * final import map. + * @param string $src Optional. Full URL of the script module, or path of the script module relative + * to the WordPress root directory. If it is provided and the script module has + * not been registered yet, it will be registered. + * @param array> $deps { + * Optional. List of dependencies. + * + * @type string|array ...$0 { + * An array of script module identifiers of the dependencies of this script + * module. The dependencies can be strings or arrays. If they are arrays, + * they need an `id` key with the script module identifier, and can contain + * an `import` key with either `static` or `dynamic`. By default, + * dependencies that don't contain an `import` key are considered static. + * + * @type string $id The script module identifier. + * @type string $import Optional. Import type. May be either `static` or + * `dynamic`. Defaults to `static`. + * } + * } + * @param string|false|null $version Optional. String specifying the script module version number. Defaults to false. + * It is added to the URL as a query string for cache busting purposes. If $version + * is set to false, the version number is the currently installed WordPress version. + * If $version is set to null, no version is added. + * @param array $args { * Optional. An array of additional args. Default empty array. * * @type bool $in_footer Whether to print the script module in the footer. Only relevant to block themes. Default 'false'. Optional. @@ -533,13 +533,87 @@ 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 $module ) { + if ( is_string( $module ) ) { + $id = $module; + } elseif ( is_array( $module ) && isset( $module['id'] ) && is_string( $module['id'] ) ) { + $id = $module['id']; + } else { + // Invalid module dependency was supplied by direct manipulation of the extra data. + // Normally, this error scenario would be caught when WP_Scripts::add_data() is called. + continue; + } + + 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..bb17c08bb52cb 100644 --- a/src/wp-includes/class-wp-scripts.php +++ b/src/wp-includes/class-wp-scripts.php @@ -920,6 +920,48 @@ 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 $module ) { + if ( + is_string( $module ) || + ( is_array( $module ) && isset( $module['id'] ) && is_string( $module['id'] ) ) + ) { + $sanitized_value[] = $module; + } else { + $has_invalid_ids = true; + } + } + + 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 invalid.' ), + $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..59e4e54a1a1ad 100644 --- a/src/wp-includes/functions.wp-scripts.php +++ b/src/wp-includes/functions.wp-scripts.php @@ -68,6 +68,49 @@ 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 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( 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 ) ) { + $trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 2 ); + $function_name = ( $trace[1]['class'] ?? '' ) . ( $trace[1]['type'] ?? '' ) . $trace[1]['function']; + _doing_it_wrong( + $function_name, + sprintf( + /* translators: 1: $args, 2: List of unrecognized keys, 3: List of supported keys. */ + __( 'Unrecognized key(s) in the %1$s param: %2$s. Supported keys: %3$s' ), + '$args', + implode( wp_get_list_item_separator(), $unknown_keys ), + implode( wp_get_list_item_separator(), $allowed_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 +202,25 @@ 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 array> $module_dependencies Optional. IDs for module dependencies loaded via dynamic import. Default empty array. + * For the full data format, see the `$deps` param of {@see wp_register_script_module()}. * } * @return bool Whether the script has been registered. True on success, false on failure. */ @@ -189,15 +235,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( $wp_scripts, $handle, $args ); + return $registered; } @@ -345,22 +384,25 @@ 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 array> $module_dependencies Optional. IDs for module dependencies loaded via dynamic import. Default empty array. + * For the full data format, see the `$deps` param of {@see wp_register_script_module()}. * } */ function wp_enqueue_script( $handle, $src = '', $deps = array(), $ver = false, $args = array() ) { @@ -379,14 +421,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( $wp_scripts, $_handle[0], $args ); } } diff --git a/src/wp-includes/script-modules.php b/src/wp-includes/script-modules.php index f851d41bf21f2..ee91ee4361a7d 100644 --- a/src/wp-includes/script-modules.php +++ b/src/wp-includes/script-modules.php @@ -37,31 +37,31 @@ function wp_script_modules(): WP_Script_Modules { * @since 6.5.0 * @since 6.9.0 Added the $args parameter. * - * @param string $id The identifier of the script module. Should be unique. It will be used in the - * final import map. - * @param string $src Optional. Full URL of the script module, or path of the script module relative - * to the WordPress root directory. If it is provided and the script module has - * not been registered yet, it will be registered. - * @param array $deps { - * Optional. List of dependencies. - * - * @type string|array ...$0 { - * An array of script module identifiers of the dependencies of this script - * module. The dependencies can be strings or arrays. If they are arrays, - * they need an `id` key with the script module identifier, and can contain - * an `import` key with either `static` or `dynamic`. By default, - * dependencies that don't contain an `import` key are considered static. - * - * @type string $id The script module identifier. - * @type string $import Optional. Import type. May be either `static` or - * `dynamic`. Defaults to `static`. - * } - * } - * @param string|false|null $version Optional. String specifying the script module version number. Defaults to false. - * It is added to the URL as a query string for cache busting purposes. If $version - * is set to false, the version number is the currently installed WordPress version. - * If $version is set to null, no version is added. - * @param array $args { + * @param string $id The identifier of the script module. Should be unique. It will be used in the + * final import map. + * @param string $src Optional. Full URL of the script module, or path of the script module relative + * to the WordPress root directory. If it is provided and the script module has + * not been registered yet, it will be registered. + * @param array> $deps { + * Optional. List of dependencies. + * + * @type string|array ...$0 { + * An array of script module identifiers of the dependencies of this script + * module. The dependencies can be strings or arrays. If they are arrays, + * they need an `id` key with the script module identifier, and can contain + * an `import` key with either `static` or `dynamic`. By default, + * dependencies that don't contain an `import` key are considered static. + * + * @type string $id The script module identifier. + * @type string $import Optional. Import type. May be either `static` or + * `dynamic`. Defaults to `static`. + * } + * } + * @param string|false|null $version Optional. String specifying the script module version number. Defaults to false. + * It is added to the URL as a query string for cache busting purposes. If $version + * is set to false, the version number is the currently installed WordPress version. + * If $version is set to null, no version is added. + * @param array $args { * Optional. An array of additional args. Default empty array. * * @type bool $in_footer Whether to print the script module in the footer. Only relevant to block themes. Default 'false'. Optional. @@ -81,31 +81,31 @@ function wp_register_script_module( string $id, string $src, array $deps = array * @since 6.5.0 * @since 6.9.0 Added the $args parameter. * - * @param string $id The identifier of the script module. Should be unique. It will be used in the - * final import map. - * @param string $src Optional. Full URL of the script module, or path of the script module relative - * to the WordPress root directory. If it is provided and the script module has - * not been registered yet, it will be registered. - * @param array $deps { - * Optional. List of dependencies. - * - * @type string|array ...$0 { - * An array of script module identifiers of the dependencies of this script - * module. The dependencies can be strings or arrays. If they are arrays, - * they need an `id` key with the script module identifier, and can contain - * an `import` key with either `static` or `dynamic`. By default, - * dependencies that don't contain an `import` key are considered static. - * - * @type string $id The script module identifier. - * @type string $import Optional. Import type. May be either `static` or - * `dynamic`. Defaults to `static`. - * } - * } - * @param string|false|null $version Optional. String specifying the script module version number. Defaults to false. - * It is added to the URL as a query string for cache busting purposes. If $version - * is set to false, the version number is the currently installed WordPress version. - * If $version is set to null, no version is added. - * @param array $args { + * @param string $id The identifier of the script module. Should be unique. It will be used in the + * final import map. + * @param string $src Optional. Full URL of the script module, or path of the script module relative + * to the WordPress root directory. If it is provided and the script module has + * not been registered yet, it will be registered. + * @param array> $deps { + * Optional. List of dependencies. + * + * @type string|array ...$0 { + * An array of script module identifiers of the dependencies of this script + * module. The dependencies can be strings or arrays. If they are arrays, + * they need an `id` key with the script module identifier, and can contain + * an `import` key with either `static` or `dynamic`. By default, + * dependencies that don't contain an `import` key are considered static. + * + * @type string $id The script module identifier. + * @type string $import Optional. Import type. May be either `static` or + * `dynamic`. Defaults to `static`. + * } + * } + * @param string|false|null $version Optional. String specifying the script module version number. Defaults to false. + * It is added to the URL as a query string for cache busting purposes. If $version + * is set to false, the version number is the currently installed WordPress version. + * If $version is set to null, no version is added. + * @param array $args { * Optional. An array of additional args. Default empty array. * * @type bool $in_footer Whether to print the script module in the footer. Only relevant to block themes. Default 'false'. Optional. diff --git a/tests/phpunit/tests/dependencies/scripts.php b/tests/phpunit/tests/dependencies/scripts.php index 846cbb125d26c..995d46ad6ae61 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 key(s) in the $args param: unrecognized_key, another_bad_key. Supported keys: strategy, in_footer, fetchpriority, module_dependencies', + ), + '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 key(s) in the $args param: invalid_key. Supported keys: strategy, in_footer, fetchpriority, module_dependencies', + ), + ); + } + /** * 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>|null}> + */ + 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(), array( 'id' => 'valid2' ) ), + 'message' => 'has one or more of its script module dependencies ("module_dependencies") which are invalid', + 'expected' => true, + 'stored' => array( 'valid', array( 'id' => 'valid2' ) ), + ), + ); + } + /** * Data provider. * diff --git a/tests/phpunit/tests/script-modules/wpScriptModules.php b/tests/phpunit/tests/script-modules/wpScriptModules.php index 1bd8b5c1663d3..3b7de73c71b63 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 $original_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->original_wp_scripts = $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->original_wp_scripts; } /** @@ -1985,6 +1981,174 @@ 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' ) ); + $this->script_modules->register( 'example2', '/example2.js' ); + + // 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', + array( + 'id' => 'example2', + ), + ), + ) + ); + + $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', 'example2', '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. *