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' ),
)
);