diff --git a/src/wp-includes/functions.wp-scripts.php b/src/wp-includes/functions.wp-scripts.php index 59e4e54a1a1ad..96b48ef33169c 100644 --- a/src/wp-includes/functions.wp-scripts.php +++ b/src/wp-includes/functions.wp-scripts.php @@ -108,6 +108,32 @@ function _wp_scripts_add_args_data( WP_Scripts $wp_scripts, string $handle, arra } if ( ! empty( $args['module_dependencies'] ) ) { $wp_scripts->add_data( $handle, 'module_dependencies', $args['module_dependencies'] ); + + /* + * A classic script with module dependencies must either be printed in the + * footer or use the 'defer' loading strategy. Otherwise, the script may be + * evaluated before the script modules import map is printed, causing + * dynamic imports to fail with a "Failed to resolve module specifier" error. + */ + $is_in_footer = ! empty( $args['in_footer'] ); + $is_deferred = isset( $args['strategy'] ) && 'defer' === $args['strategy']; + if ( ! $is_in_footer && ! $is_deferred ) { + $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: 'module_dependencies', 2: Script handle, 3: 'in_footer', 4: 'strategy', 5: 'defer'. */ + __( 'When the %1$s arg is provided, the "%2$s" script must either be printed in the footer (%3$s set to true) or use a deferred loading %4$s (%5$s) so that the import map is printed before the script is evaluated.' ), + 'module_dependencies', + $handle, + 'in_footer', + 'strategy', + 'defer' + ), + '7.0.0' + ); + } } } @@ -221,6 +247,10 @@ function wp_add_inline_script( $handle, $data, $position = 'after' ) { * @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()}. + * When provided, the script must either be printed in the footer (with + * `in_footer` set to true) or use a deferred loading `strategy` (`defer`), + * so that the script modules import map is printed before the script + * is evaluated. Otherwise dynamic imports may fail to resolve. * } * @return bool Whether the script has been registered. True on success, false on failure. */ @@ -403,6 +433,10 @@ function wp_deregister_script( $handle ) { * @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()}. + * When provided, the script must either be printed in the footer (with + * `in_footer` set to true) or use a deferred loading `strategy` (`defer`), + * so that the script modules import map is printed before the script + * is evaluated. Otherwise dynamic imports may fail to resolve. * } */ function wp_enqueue_script( $handle, $src = '', $deps = array(), $ver = false, $args = array() ) { diff --git a/tests/phpunit/tests/dependencies/scripts.php b/tests/phpunit/tests/dependencies/scripts.php index 5f1c30fe4cf47..abd0b8f4881d7 100644 --- a/tests/phpunit/tests/dependencies/scripts.php +++ b/tests/phpunit/tests/dependencies/scripts.php @@ -1396,6 +1396,203 @@ public function data_add_data_module_dependencies_validation(): array { ); } + /** + * Tests that registering a script with `module_dependencies` triggers `_doing_it_wrong` + * when the script is not printed in the footer and does not use the `defer` strategy. + * + * @ticket 65165 + * + * @covers ::wp_register_script + * @covers ::wp_enqueue_script + * @covers ::_wp_scripts_add_args_data + * + * @dataProvider data_module_dependencies_require_footer_or_defer + * + * @param string $function_name Function name to call. + * @param array $args Arguments to pass to the function. + * @param bool $should_warn Whether the call is expected to trigger a `_doing_it_wrong` warning. + */ + public function test_module_dependencies_require_footer_or_defer( string $function_name, array $args, bool $should_warn ) { + if ( $should_warn ) { + $this->setExpectedIncorrectUsage( $function_name ); + } + + call_user_func_array( $function_name, $args ); + + if ( $should_warn ) { + $this->assertStringContainsString( + 'module_dependencies', + $this->caught_doing_it_wrong[ $function_name ], + 'The _doing_it_wrong message should reference module_dependencies.' + ); + $this->assertStringContainsString( + 'in_footer', + $this->caught_doing_it_wrong[ $function_name ], + 'The _doing_it_wrong message should reference the in_footer requirement.' + ); + $this->assertStringContainsString( + 'defer', + $this->caught_doing_it_wrong[ $function_name ], + 'The _doing_it_wrong message should reference the defer strategy.' + ); + } else { + $this->assertArrayNotHasKey( + $function_name, + $this->caught_doing_it_wrong, + 'No _doing_it_wrong warning should be triggered when in_footer is true or strategy is defer.' + ); + } + } + + /** + * Data provider for test_module_dependencies_require_footer_or_defer. + * + * @return array + */ + public function data_module_dependencies_require_footer_or_defer(): array { + $base_args = array( + '/script.js', + array(), + null, + ); + + return array( + 'register_blocking_warns' => array( + 'function_name' => 'wp_register_script', + 'args' => array_merge( + array( 'module-deps-blocking-register' ), + $base_args, + array( + array( + 'module_dependencies' => array( 'foo' ), + ), + ) + ), + 'should_warn' => true, + ), + 'enqueue_blocking_warns' => array( + 'function_name' => 'wp_enqueue_script', + 'args' => array_merge( + array( 'module-deps-blocking-enqueue' ), + $base_args, + array( + array( + 'module_dependencies' => array( 'foo' ), + ), + ) + ), + 'should_warn' => true, + ), + 'register_async_warns' => array( + 'function_name' => 'wp_register_script', + 'args' => array_merge( + array( 'module-deps-async-register' ), + $base_args, + array( + array( + 'module_dependencies' => array( 'foo' ), + 'strategy' => 'async', + ), + ) + ), + 'should_warn' => true, + ), + 'register_in_footer_does_not_warn' => array( + 'function_name' => 'wp_register_script', + 'args' => array_merge( + array( 'module-deps-footer-register' ), + $base_args, + array( + array( + 'module_dependencies' => array( 'foo' ), + 'in_footer' => true, + ), + ) + ), + 'should_warn' => false, + ), + 'enqueue_in_footer_does_not_warn' => array( + 'function_name' => 'wp_enqueue_script', + 'args' => array_merge( + array( 'module-deps-footer-enqueue' ), + $base_args, + array( + array( + 'module_dependencies' => array( 'foo' ), + 'in_footer' => true, + ), + ) + ), + 'should_warn' => false, + ), + 'register_defer_does_not_warn' => array( + 'function_name' => 'wp_register_script', + 'args' => array_merge( + array( 'module-deps-defer-register' ), + $base_args, + array( + array( + 'module_dependencies' => array( 'foo' ), + 'strategy' => 'defer', + ), + ) + ), + 'should_warn' => false, + ), + 'enqueue_defer_does_not_warn' => array( + 'function_name' => 'wp_enqueue_script', + 'args' => array_merge( + array( 'module-deps-defer-enqueue' ), + $base_args, + array( + array( + 'module_dependencies' => array( 'foo' ), + 'strategy' => 'defer', + ), + ) + ), + 'should_warn' => false, + ), + 'register_footer_and_defer_no_warn' => array( + 'function_name' => 'wp_register_script', + 'args' => array_merge( + array( 'module-deps-footer-defer-register' ), + $base_args, + array( + array( + 'module_dependencies' => array( 'foo' ), + 'in_footer' => true, + 'strategy' => 'defer', + ), + ) + ), + 'should_warn' => false, + ), + 'register_no_module_deps_no_warn' => array( + 'function_name' => 'wp_register_script', + 'args' => array_merge( + array( 'no-module-deps-register' ), + $base_args, + array( array() ) + ), + 'should_warn' => false, + ), + 'register_empty_module_deps_no_warn' => array( + 'function_name' => 'wp_register_script', + 'args' => array_merge( + array( 'empty-module-deps-register' ), + $base_args, + array( + array( + 'module_dependencies' => array(), + ), + ) + ), + 'should_warn' => false, + ), + ); + } + /** * Data provider. * diff --git a/tests/phpunit/tests/script-modules/wpScriptModules.php b/tests/phpunit/tests/script-modules/wpScriptModules.php index 330736431dffd..2f4a330b40981 100644 --- a/tests/phpunit/tests/script-modules/wpScriptModules.php +++ b/tests/phpunit/tests/script-modules/wpScriptModules.php @@ -2041,6 +2041,7 @@ public function test_included_module_appears_in_importmap() { array( 'classic-dependency' ), false, array( + 'in_footer' => true, 'module_dependencies' => array( 'example', array( @@ -2109,6 +2110,7 @@ public function test_import_map_includes_dependencies_of_classic_scripts_recursi array(), false, array( + 'in_footer' => true, 'module_dependencies' => array( 'classic-transitive-dependency' ), ) ); @@ -2118,6 +2120,7 @@ public function test_import_map_includes_dependencies_of_classic_scripts_recursi array( 'classic-transitive-dep' ), false, array( + 'in_footer' => true, 'module_dependencies' => array( 'not-enqueued' ), ) ); @@ -2153,6 +2156,7 @@ public function test_wp_scripts_doing_it_wrong_for_missing_script_module_depende array(), null, array( + 'in_footer' => true, 'module_dependencies' => array( 'does-not-exist' ), ) );