Skip to content

Commit 507697a

Browse files
Script Loader: Warn when classic scripts depend on modules without footer/defer.
Scripts registered or enqueued with a `module_dependencies` arg may evaluate before the script modules import map is printed if they are loaded blocking in the document head, causing a "Failed to resolve module specifier" error on dynamic imports. Trigger `_doing_it_wrong()` from `_wp_scripts_add_args_data()` when a classic script provides `module_dependencies` without setting `in_footer` to true or using a `defer` loading `strategy`, and document this requirement in the `wp_register_script()` and `wp_enqueue_script()` docblocks. Existing tests in `wpScriptModules.php` that exercised this path are updated to use `in_footer => true` so they continue to validate the import map behavior without tripping the new warning. See #65165, #61500. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7c55fde commit 507697a

3 files changed

Lines changed: 235 additions & 0 deletions

File tree

src/wp-includes/functions.wp-scripts.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,32 @@ function _wp_scripts_add_args_data( WP_Scripts $wp_scripts, string $handle, arra
108108
}
109109
if ( ! empty( $args['module_dependencies'] ) ) {
110110
$wp_scripts->add_data( $handle, 'module_dependencies', $args['module_dependencies'] );
111+
112+
/*
113+
* A classic script with module dependencies must either be printed in the
114+
* footer or use the 'defer' loading strategy. Otherwise, the script may be
115+
* evaluated before the script modules import map is printed, causing
116+
* dynamic imports to fail with a "Failed to resolve module specifier" error.
117+
*/
118+
$is_in_footer = ! empty( $args['in_footer'] );
119+
$is_deferred = isset( $args['strategy'] ) && 'defer' === $args['strategy'];
120+
if ( ! $is_in_footer && ! $is_deferred ) {
121+
$trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 2 );
122+
$function_name = ( $trace[1]['class'] ?? '' ) . ( $trace[1]['type'] ?? '' ) . $trace[1]['function'];
123+
_doing_it_wrong(
124+
$function_name,
125+
sprintf(
126+
/* translators: 1: 'module_dependencies', 2: Script handle, 3: 'in_footer', 4: 'strategy', 5: 'defer'. */
127+
__( '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.' ),
128+
'<code>module_dependencies</code>',
129+
$handle,
130+
'<code>in_footer</code>',
131+
'<code>strategy</code>',
132+
'<code>defer</code>'
133+
),
134+
'7.0.0'
135+
);
136+
}
111137
}
112138
}
113139

@@ -221,6 +247,10 @@ function wp_add_inline_script( $handle, $data, $position = 'after' ) {
221247
* @type string $fetchpriority Optional. The fetch priority for the script. Default 'auto'.
222248
* @type array<string|array<string, string>> $module_dependencies Optional. IDs for module dependencies loaded via dynamic import. Default empty array.
223249
* For the full data format, see the `$deps` param of {@see wp_register_script_module()}.
250+
* When provided, the script must either be printed in the footer (with
251+
* `in_footer` set to true) or use a deferred loading `strategy` (`defer`),
252+
* so that the script modules import map is printed before the script
253+
* is evaluated. Otherwise dynamic imports may fail to resolve.
224254
* }
225255
* @return bool Whether the script has been registered. True on success, false on failure.
226256
*/
@@ -403,6 +433,10 @@ function wp_deregister_script( $handle ) {
403433
* @type string $fetchpriority Optional. The fetch priority for the script. Default 'auto'.
404434
* @type array<string|array<string, string>> $module_dependencies Optional. IDs for module dependencies loaded via dynamic import. Default empty array.
405435
* For the full data format, see the `$deps` param of {@see wp_register_script_module()}.
436+
* When provided, the script must either be printed in the footer (with
437+
* `in_footer` set to true) or use a deferred loading `strategy` (`defer`),
438+
* so that the script modules import map is printed before the script
439+
* is evaluated. Otherwise dynamic imports may fail to resolve.
406440
* }
407441
*/
408442
function wp_enqueue_script( $handle, $src = '', $deps = array(), $ver = false, $args = array() ) {

tests/phpunit/tests/dependencies/scripts.php

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,6 +1396,203 @@ public function data_add_data_module_dependencies_validation(): array {
13961396
);
13971397
}
13981398

1399+
/**
1400+
* Tests that registering a script with `module_dependencies` triggers `_doing_it_wrong`
1401+
* when the script is not printed in the footer and does not use the `defer` strategy.
1402+
*
1403+
* @ticket 65165
1404+
*
1405+
* @covers ::wp_register_script
1406+
* @covers ::wp_enqueue_script
1407+
* @covers ::_wp_scripts_add_args_data
1408+
*
1409+
* @dataProvider data_module_dependencies_require_footer_or_defer
1410+
*
1411+
* @param string $function_name Function name to call.
1412+
* @param array $args Arguments to pass to the function.
1413+
* @param bool $should_warn Whether the call is expected to trigger a `_doing_it_wrong` warning.
1414+
*/
1415+
public function test_module_dependencies_require_footer_or_defer( string $function_name, array $args, bool $should_warn ) {
1416+
if ( $should_warn ) {
1417+
$this->setExpectedIncorrectUsage( $function_name );
1418+
}
1419+
1420+
call_user_func_array( $function_name, $args );
1421+
1422+
if ( $should_warn ) {
1423+
$this->assertStringContainsString(
1424+
'module_dependencies',
1425+
$this->caught_doing_it_wrong[ $function_name ],
1426+
'The _doing_it_wrong message should reference module_dependencies.'
1427+
);
1428+
$this->assertStringContainsString(
1429+
'in_footer',
1430+
$this->caught_doing_it_wrong[ $function_name ],
1431+
'The _doing_it_wrong message should reference the in_footer requirement.'
1432+
);
1433+
$this->assertStringContainsString(
1434+
'defer',
1435+
$this->caught_doing_it_wrong[ $function_name ],
1436+
'The _doing_it_wrong message should reference the defer strategy.'
1437+
);
1438+
} else {
1439+
$this->assertArrayNotHasKey(
1440+
$function_name,
1441+
$this->caught_doing_it_wrong,
1442+
'No _doing_it_wrong warning should be triggered when in_footer is true or strategy is defer.'
1443+
);
1444+
}
1445+
}
1446+
1447+
/**
1448+
* Data provider for test_module_dependencies_require_footer_or_defer.
1449+
*
1450+
* @return array<string, array{function_name: string, args: array, should_warn: bool}>
1451+
*/
1452+
public function data_module_dependencies_require_footer_or_defer(): array {
1453+
$base_args = array(
1454+
'/script.js',
1455+
array(),
1456+
null,
1457+
);
1458+
1459+
return array(
1460+
'register_blocking_warns' => array(
1461+
'function_name' => 'wp_register_script',
1462+
'args' => array_merge(
1463+
array( 'module-deps-blocking-register' ),
1464+
$base_args,
1465+
array(
1466+
array(
1467+
'module_dependencies' => array( 'foo' ),
1468+
),
1469+
)
1470+
),
1471+
'should_warn' => true,
1472+
),
1473+
'enqueue_blocking_warns' => array(
1474+
'function_name' => 'wp_enqueue_script',
1475+
'args' => array_merge(
1476+
array( 'module-deps-blocking-enqueue' ),
1477+
$base_args,
1478+
array(
1479+
array(
1480+
'module_dependencies' => array( 'foo' ),
1481+
),
1482+
)
1483+
),
1484+
'should_warn' => true,
1485+
),
1486+
'register_async_warns' => array(
1487+
'function_name' => 'wp_register_script',
1488+
'args' => array_merge(
1489+
array( 'module-deps-async-register' ),
1490+
$base_args,
1491+
array(
1492+
array(
1493+
'module_dependencies' => array( 'foo' ),
1494+
'strategy' => 'async',
1495+
),
1496+
)
1497+
),
1498+
'should_warn' => true,
1499+
),
1500+
'register_in_footer_does_not_warn' => array(
1501+
'function_name' => 'wp_register_script',
1502+
'args' => array_merge(
1503+
array( 'module-deps-footer-register' ),
1504+
$base_args,
1505+
array(
1506+
array(
1507+
'module_dependencies' => array( 'foo' ),
1508+
'in_footer' => true,
1509+
),
1510+
)
1511+
),
1512+
'should_warn' => false,
1513+
),
1514+
'enqueue_in_footer_does_not_warn' => array(
1515+
'function_name' => 'wp_enqueue_script',
1516+
'args' => array_merge(
1517+
array( 'module-deps-footer-enqueue' ),
1518+
$base_args,
1519+
array(
1520+
array(
1521+
'module_dependencies' => array( 'foo' ),
1522+
'in_footer' => true,
1523+
),
1524+
)
1525+
),
1526+
'should_warn' => false,
1527+
),
1528+
'register_defer_does_not_warn' => array(
1529+
'function_name' => 'wp_register_script',
1530+
'args' => array_merge(
1531+
array( 'module-deps-defer-register' ),
1532+
$base_args,
1533+
array(
1534+
array(
1535+
'module_dependencies' => array( 'foo' ),
1536+
'strategy' => 'defer',
1537+
),
1538+
)
1539+
),
1540+
'should_warn' => false,
1541+
),
1542+
'enqueue_defer_does_not_warn' => array(
1543+
'function_name' => 'wp_enqueue_script',
1544+
'args' => array_merge(
1545+
array( 'module-deps-defer-enqueue' ),
1546+
$base_args,
1547+
array(
1548+
array(
1549+
'module_dependencies' => array( 'foo' ),
1550+
'strategy' => 'defer',
1551+
),
1552+
)
1553+
),
1554+
'should_warn' => false,
1555+
),
1556+
'register_footer_and_defer_no_warn' => array(
1557+
'function_name' => 'wp_register_script',
1558+
'args' => array_merge(
1559+
array( 'module-deps-footer-defer-register' ),
1560+
$base_args,
1561+
array(
1562+
array(
1563+
'module_dependencies' => array( 'foo' ),
1564+
'in_footer' => true,
1565+
'strategy' => 'defer',
1566+
),
1567+
)
1568+
),
1569+
'should_warn' => false,
1570+
),
1571+
'register_no_module_deps_no_warn' => array(
1572+
'function_name' => 'wp_register_script',
1573+
'args' => array_merge(
1574+
array( 'no-module-deps-register' ),
1575+
$base_args,
1576+
array( array() )
1577+
),
1578+
'should_warn' => false,
1579+
),
1580+
'register_empty_module_deps_no_warn' => array(
1581+
'function_name' => 'wp_register_script',
1582+
'args' => array_merge(
1583+
array( 'empty-module-deps-register' ),
1584+
$base_args,
1585+
array(
1586+
array(
1587+
'module_dependencies' => array(),
1588+
),
1589+
)
1590+
),
1591+
'should_warn' => false,
1592+
),
1593+
);
1594+
}
1595+
13991596
/**
14001597
* Data provider.
14011598
*

tests/phpunit/tests/script-modules/wpScriptModules.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2041,6 +2041,7 @@ public function test_included_module_appears_in_importmap() {
20412041
array( 'classic-dependency' ),
20422042
false,
20432043
array(
2044+
'in_footer' => true,
20442045
'module_dependencies' => array(
20452046
'example',
20462047
array(
@@ -2109,6 +2110,7 @@ public function test_import_map_includes_dependencies_of_classic_scripts_recursi
21092110
array(),
21102111
false,
21112112
array(
2113+
'in_footer' => true,
21122114
'module_dependencies' => array( 'classic-transitive-dependency' ),
21132115
)
21142116
);
@@ -2118,6 +2120,7 @@ public function test_import_map_includes_dependencies_of_classic_scripts_recursi
21182120
array( 'classic-transitive-dep' ),
21192121
false,
21202122
array(
2123+
'in_footer' => true,
21212124
'module_dependencies' => array( 'not-enqueued' ),
21222125
)
21232126
);
@@ -2153,6 +2156,7 @@ public function test_wp_scripts_doing_it_wrong_for_missing_script_module_depende
21532156
array(),
21542157
null,
21552158
array(
2159+
'in_footer' => true,
21562160
'module_dependencies' => array( 'does-not-exist' ),
21572161
)
21582162
);

0 commit comments

Comments
 (0)