Skip to content

Commit aa60d9d

Browse files
committed
Script Loader: Warn when classic scripts with module dependencies lack footer/defer.
A classic script with `module_dependencies` may be evaluated before the script modules import map is printed if it loads 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. * Remove the `module_dependencies` arg from the `wp-codemirror` script registration in favor of passing the espree module URL directly through `wp_get_code_editor_settings()`. This avoids registering `espree` as a publicly-available script module when it is only ever used internally as a private implementation detail of the code editor. * Add a `console.warn()` in `wp.codeEditor.initialize()` when invoked before `DOMContentLoaded`, so developers are alerted if the function is called too early for the import map to have been parsed. * Add PHPStan types which were missing when `module_dependencies` were initially introduced. * Harden `WP_Scripts::add_data()` against non-string `strategy` values being passed to `sprintf()`. Developed in WordPress#11788 Follow-up to r61587. Props khokansardar, westonruter, jonsurrell, jorbin. See #61500, #64238. Fixes #65165. git-svn-id: https://develop.svn.wordpress.org/trunk@62368 602fd350-edb4-49c9-b593-d223f7449a82
1 parent 165d83e commit aa60d9d

10 files changed

Lines changed: 372 additions & 58 deletions

File tree

src/js/_enqueues/lib/codemirror/javascript-lint.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import CodeMirror from 'codemirror';
3030
* @property {boolean} [es3] - "This option tells JSHint that your code needs to adhere to ECMAScript 3 specification. Use this option if you need your program to be executable in older browsers—such as Internet Explorer 6/7/8/9—and other legacy JavaScript environments."
3131
* @property {boolean} [module] - "This option informs JSHint that the input code describes an ECMAScript 6 module. All module code is interpreted as strict mode code."
3232
* @property {'implied'} [strict] - "This option requires the code to run in ECMAScript 5's strict mode."
33+
* @property {string} [espreeModuleUrl] - The URL to the espree script module.
3334
*/
3435

3536
/**
@@ -42,9 +43,13 @@ import CodeMirror from 'codemirror';
4243
* @returns {Promise<CodeMirrorLintError[]>}
4344
*/
4445
async function validator( text, options ) {
46+
if ( ! options.espreeModuleUrl ) {
47+
return [];
48+
}
49+
4550
const errors = /** @type {CodeMirrorLintError[]} */ [];
4651
try {
47-
const espree = await import( /* webpackIgnore: true */ 'espree' );
52+
const espree = await import( /* webpackIgnore: true */ options.espreeModuleUrl );
4853
espree.parse( text, {
4954
...getEspreeOptions( options ),
5055
loc: true,

src/js/_enqueues/wp/code-editor.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* @output wp-admin/js/code-editor.js
33
*/
44

5+
/* global console */
6+
57
/* eslint-env es2020 */
68

79
if ( 'undefined' === typeof window.wp ) {
@@ -412,6 +414,10 @@ if ( 'undefined' === typeof window.wp.codeEditor ) {
412414
* @return {CodeEditorInstance} Instance.
413415
*/
414416
wp.codeEditor.initialize = function initialize( textarea, settings ) {
417+
if ( document.readyState === 'loading' ) {
418+
console.warn( 'wp.codeEditor.initialize() ran too early. Invoke this function in a `DOMContentLoaded` event listener.' );
419+
}
420+
415421
let $textarea;
416422
if ( 'string' === typeof textarea ) {
417423
$textarea = $( '#' + textarea );

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -885,7 +885,7 @@ public function add_data( $handle, $key, $value ) {
885885
sprintf(
886886
/* translators: 1: $strategy, 2: $handle */
887887
__( 'Invalid strategy `%1$s` defined for `%2$s` during script registration.' ),
888-
$value,
888+
is_string( $value ) ? $value : gettype( $value ),
889889
$handle
890890
),
891891
'6.3.0'
@@ -897,7 +897,7 @@ public function add_data( $handle, $key, $value ) {
897897
sprintf(
898898
/* translators: 1: $strategy, 2: $handle */
899899
__( 'Cannot supply a strategy `%1$s` for script `%2$s` because it is an alias (it lacks a `src` value).' ),
900-
$value,
900+
is_string( $value ) ? $value : gettype( $value ),
901901
$handle
902902
),
903903
'6.3.0'

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

Lines changed: 96 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -71,19 +71,30 @@ function _wp_scripts_maybe_doing_it_wrong( $function_name, $handle = '' ) {
7171
/**
7272
* Adds the data for the recognized args and warns for unrecognized args.
7373
*
74+
* @see wp_enqueue_script()
75+
* @see wp_register_script()
76+
*
7477
* @ignore
7578
* @since 7.0.0
7679
*
7780
* @param WP_Scripts $wp_scripts WP_Scripts instance.
7881
* @param string $handle Script handle.
7982
* @param array $args Array of extra args for the script.
83+
*
84+
* @phpstan-param non-empty-string $handle
85+
* @phpstan-param array{
86+
* in_footer?: bool,
87+
* strategy?: 'async'|'defer',
88+
* fetchpriority?: 'low'|'auto'|'high',
89+
* module_dependencies?: array<non-empty-string|array{ id: non-empty-string, ... }>,
90+
* } $args
8091
*/
81-
function _wp_scripts_add_args_data( WP_Scripts $wp_scripts, string $handle, array $args ) {
92+
function _wp_scripts_add_args_data( WP_Scripts $wp_scripts, string $handle, array $args ): void {
8293
$allowed_keys = array( 'strategy', 'in_footer', 'fetchpriority', 'module_dependencies' );
8394
$unknown_keys = array_diff( array_keys( $args ), $allowed_keys );
8495
if ( ! empty( $unknown_keys ) ) {
8596
$trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 2 );
86-
$function_name = ( $trace[1]['class'] ?? '' ) . ( $trace[1]['type'] ?? '' ) . $trace[1]['function'];
97+
$function_name = ( $trace[1]['class'] ?? '' ) . ( $trace[1]['type'] ?? '' ) . ( $trace[1]['function'] ?? __FUNCTION__ );
8798
_doing_it_wrong(
8899
$function_name,
89100
sprintf(
@@ -97,7 +108,8 @@ function _wp_scripts_add_args_data( WP_Scripts $wp_scripts, string $handle, arra
97108
);
98109
}
99110

100-
if ( ! empty( $args['in_footer'] ) ) {
111+
$in_footer = ! empty( $args['in_footer'] );
112+
if ( $in_footer ) {
101113
$wp_scripts->add_data( $handle, 'group', 1 );
102114
}
103115
if ( ! empty( $args['strategy'] ) ) {
@@ -108,6 +120,31 @@ function _wp_scripts_add_args_data( WP_Scripts $wp_scripts, string $handle, arra
108120
}
109121
if ( ! empty( $args['module_dependencies'] ) ) {
110122
$wp_scripts->add_data( $handle, 'module_dependencies', $args['module_dependencies'] );
123+
124+
/*
125+
* A classic script with module dependencies must either be printed in the
126+
* footer or use the 'defer' loading strategy. Otherwise, the script may be
127+
* evaluated before the script modules import map is printed, causing
128+
* dynamic imports to fail with a "Failed to resolve module specifier" error.
129+
*/
130+
$is_deferred = 'defer' === ( $args['strategy'] ?? null );
131+
if ( ! $in_footer && ! $is_deferred ) {
132+
$trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 2 );
133+
$function_name = ( $trace[1]['class'] ?? '' ) . ( $trace[1]['type'] ?? '' ) . ( $trace[1]['function'] ?? __FUNCTION__ );
134+
_doing_it_wrong(
135+
$function_name,
136+
sprintf(
137+
/* translators: 1: 'module_dependencies', 2: Script handle, 3: 'in_footer', 4: 'strategy', 5: 'defer'. */
138+
__( '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.' ),
139+
'<code>module_dependencies</code>',
140+
$handle,
141+
'<code>in_footer</code>',
142+
'<code>strategy</code>',
143+
'<code>defer</code>'
144+
),
145+
'7.0.0'
146+
);
147+
}
111148
}
112149
}
113150

@@ -204,25 +241,39 @@ function wp_add_inline_script( $handle, $data, $position = 'after' ) {
204241
* @since 6.9.0 The $fetchpriority parameter of type string was added to the $args parameter of type array.
205242
* @since 7.0.0 The $module_dependencies parameter of type string[] was added to the $args parameter of type array.
206243
*
207-
* @param string $handle Name of the script. Should be unique.
208-
* @param string|false $src Full URL of the script, or path of the script relative to the WordPress root directory.
209-
* If source is set to false, script is an alias of other scripts it depends on.
210-
* @param string[] $deps Optional. An array of registered script handles this script depends on. Default empty array.
211-
* @param string|bool|null $ver Optional. String specifying script version number, if it has one, which is added to the URL
212-
* as a query string for cache busting purposes. If version is set to false, a version
213-
* number is automatically added equal to current installed WordPress version.
214-
* If set to null, no version is added.
215-
* @param array<string, string|bool|array<string|array<string, string>>>|bool $args {
244+
* @param string $handle Name of the script. Should be unique.
245+
* @param string|false $src Full URL of the script, or path of the script relative to the WordPress root directory.
246+
* If source is set to false, script is an alias of other scripts it depends on.
247+
* @param string[] $deps Optional. An array of registered script handles this script depends on. Default empty array.
248+
* @param string|bool|null $ver Optional. String specifying script version number, if it has one, which is added to the URL
249+
* as a query string for cache busting purposes. If version is set to false, a version
250+
* number is automatically added equal to current installed WordPress version.
251+
* If set to null, no version is added.
252+
* @param array|bool $args {
216253
* Optional. An array of extra args for the script. Default empty array.
217254
* Otherwise, it may be a boolean in which case it determines whether the script is printed in the footer. Default false.
218255
*
219-
* @type string $strategy Optional. If provided, may be either 'defer' or 'async'.
220-
* @type bool $in_footer Optional. Whether to print the script in the footer. Default 'false'.
221-
* @type string $fetchpriority Optional. The fetch priority for the script. Default 'auto'.
222-
* @type array<string|array<string, string>> $module_dependencies Optional. IDs for module dependencies loaded via dynamic import. Default empty array.
256+
* @type string $strategy Optional. If provided, may be either 'defer' or 'async'.
257+
* @type bool $in_footer Optional. Whether to print the script in the footer. Default 'false'.
258+
* @type string $fetchpriority Optional. The fetch priority for the script. Default 'auto'.
259+
* @type array $module_dependencies Optional. IDs for module dependencies loaded via dynamic import. Default empty array.
223260
* For the full data format, see the `$deps` param of {@see wp_register_script_module()}.
261+
* When provided, the script must either be printed in the footer (with
262+
* `in_footer` set to true) or use a deferred loading `strategy` (`defer`),
263+
* so that the script modules import map is printed before the script
264+
* is evaluated. Otherwise dynamic imports may fail to resolve.
224265
* }
225266
* @return bool Whether the script has been registered. True on success, false on failure.
267+
*
268+
* @phpstan-param non-empty-string $handle
269+
* @phpstan-param non-empty-string|false $src
270+
* @phpstan-param non-empty-string[] $deps
271+
* @phpstan-param array{
272+
* in_footer?: bool,
273+
* strategy?: 'async'|'defer',
274+
* fetchpriority?: 'low'|'auto'|'high',
275+
* module_dependencies?: array<non-empty-string|array{ id: non-empty-string, ... }>,
276+
* }|bool $args
226277
*/
227278
function wp_register_script( $handle, $src, $deps = array(), $ver = false, $args = array() ) {
228279
if ( ! is_array( $args ) ) {
@@ -386,31 +437,46 @@ function wp_deregister_script( $handle ) {
386437
* @since 6.9.0 The $fetchpriority parameter of type string was added to the $args parameter of type array.
387438
* @since 7.0.0 The $module_dependencies parameter of type string[] was added to the $args parameter of type array.
388439
*
389-
* @param string $handle Name of the script. Should be unique.
390-
* @param string $src Full URL of the script, or path of the script relative to the WordPress root directory.
391-
* Default empty.
392-
* @param string[] $deps Optional. An array of registered script handles this script depends on. Default empty array.
393-
* @param string|bool|null $ver Optional. String specifying script version number, if it has one, which is added to the URL
394-
* as a query string for cache busting purposes. If version is set to false, a version
395-
* number is automatically added equal to current installed WordPress version.
396-
* If set to null, no version is added.
397-
* @param array<string, string|bool|array<string|array<string, string>>>|bool $args {
440+
* @param string $handle Name of the script. Should be unique.
441+
* @param string $src Full URL of the script, or path of the script relative to the WordPress root directory.
442+
* Default empty.
443+
* @param string[] $deps Optional. An array of registered script handles this script depends on. Default empty array.
444+
* @param string|bool|null $ver Optional. String specifying script version number, if it has one, which is added to the URL
445+
* as a query string for cache busting purposes. If version is set to false, a version
446+
* number is automatically added equal to current installed WordPress version.
447+
* If set to null, no version is added.
448+
* @param array|bool $args {
398449
* Optional. An array of extra args for the script. Default empty array.
399450
* Otherwise, it may be a boolean in which case it determines whether the script is printed in the footer. Default false.
400451
*
401-
* @type string $strategy Optional. If provided, may be either 'defer' or 'async'.
402-
* @type bool $in_footer Optional. Whether to print the script in the footer. Default 'false'.
403-
* @type string $fetchpriority Optional. The fetch priority for the script. Default 'auto'.
404-
* @type array<string|array<string, string>> $module_dependencies Optional. IDs for module dependencies loaded via dynamic import. Default empty array.
405-
* For the full data format, see the `$deps` param of {@see wp_register_script_module()}.
452+
* @type string $strategy Optional. If provided, may be either 'defer' or 'async'.
453+
* @type bool $in_footer Optional. Whether to print the script in the footer. Default 'false'.
454+
* @type string $fetchpriority Optional. The fetch priority for the script. Default 'auto'.
455+
* @type array $module_dependencies Optional. IDs for module dependencies loaded via dynamic import. Default empty array.
456+
* For the full data format, see the `$deps` param of {@see wp_register_script_module()}.
457+
* When provided, the script must either be printed in the footer (with
458+
* `in_footer` set to true) or use a deferred loading `strategy` (`defer`),
459+
* so that the script modules import map is printed before the script
460+
* is evaluated. Otherwise dynamic imports may fail to resolve.
406461
* }
462+
*
463+
* @phpstan-param non-empty-string $handle
464+
* @phpstan-param string $src
465+
* @phpstan-param non-empty-string[] $deps
466+
* @phpstan-param array{
467+
* in_footer?: bool,
468+
* strategy?: 'async'|'defer',
469+
* fetchpriority?: 'low'|'auto'|'high',
470+
* module_dependencies?: array<non-empty-string|array{ id: non-empty-string, ... }>,
471+
* }|bool $args
407472
*/
408473
function wp_enqueue_script( $handle, $src = '', $deps = array(), $ver = false, $args = array() ) {
409474
_wp_scripts_maybe_doing_it_wrong( __FUNCTION__, $handle );
410475

411476
$wp_scripts = wp_scripts();
412477

413478
if ( $src || ! empty( $args ) ) {
479+
/** @var array{ 0: non-empty-string, 1?: string } $_handle */
414480
$_handle = explode( '?', $handle );
415481
if ( ! is_array( $args ) ) {
416482
$args = array(

src/wp-includes/general-template.php

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4155,27 +4155,31 @@ function wp_get_code_editor_settings( $args ) {
41554155
'outline-none' => true,
41564156
),
41574157
'jshint' => array(
4158-
'esversion' => 11,
4159-
'module' => str_ends_with( $args['file'] ?? '', '.mjs' ),
4158+
'esversion' => 11,
4159+
'module' => str_ends_with( $args['file'] ?? '', '.mjs' ),
4160+
4161+
// This script module URL is intentionally referenced here instead of registering an espree script module
4162+
// in wp_default_script_modules(). This is a first stab at a core-only private module.
4163+
'espreeModuleUrl' => add_query_arg( 'ver', '9.6.1', includes_url( 'js/codemirror/espree.min.js' ) ),
41604164

41614165
// The following JSHint *linting rule* options are copied from
41624166
// <https://github.com/WordPress/wordpress-develop/blob/6.9.0/.jshintrc>.
41634167
// Parsing-related options such as `esversion` (and, in other contexts, `es5`, `es3`, `module`, `strict`)
41644168
// are honored by the Espree-based integration, but these linting-rule options are not interpreted by Espree
41654169
// and are kept only for compatibility/documentation with the original JSHint configuration.
4166-
'boss' => true,
4167-
'curly' => true,
4168-
'eqeqeq' => true,
4169-
'eqnull' => true,
4170-
'expr' => true,
4171-
'immed' => true,
4172-
'noarg' => true,
4173-
'nonbsp' => true,
4174-
'quotmark' => 'single',
4175-
'undef' => true,
4176-
'unused' => true,
4177-
'browser' => true,
4178-
'globals' => array(
4170+
'boss' => true,
4171+
'curly' => true,
4172+
'eqeqeq' => true,
4173+
'eqnull' => true,
4174+
'expr' => true,
4175+
'immed' => true,
4176+
'noarg' => true,
4177+
'nonbsp' => true,
4178+
'quotmark' => 'single',
4179+
'undef' => true,
4180+
'unused' => true,
4181+
'browser' => true,
4182+
'globals' => array(
41794183
'_' => false,
41804184
'Backbone' => false,
41814185
'jQuery' => false,

src/wp-includes/script-loader.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,9 +1200,8 @@ function wp_default_scripts( $scripts ) {
12001200
);
12011201

12021202
$scripts->add( 'wp-codemirror', '/wp-includes/js/codemirror/codemirror.min.js', array(), '5.65.20' );
1203-
did_action( 'init' ) && $scripts->add_data( 'wp-codemirror', 'module_dependencies', array( 'espree' ) );
12041203
$scripts->add( 'csslint', '/wp-includes/js/codemirror/csslint.js', array(), '1.0.5' );
1205-
$scripts->add( 'esprima', '/wp-includes/js/codemirror/esprima.js', array(), '4.0.1' ); // Deprecated. Use 'espree' script module.
1204+
$scripts->add( 'esprima', '/wp-includes/js/codemirror/esprima.js', array(), '4.0.1' ); // Deprecated.
12061205
$scripts->add( 'jshint', '/wp-includes/js/codemirror/fakejshint.js', array( 'esprima' ), '2.9.5' ); // Deprecated.
12071206
$scripts->add( 'jsonlint', '/wp-includes/js/codemirror/jsonlint.js', array(), '1.6.3' );
12081207
$scripts->add( 'htmlhint', '/wp-includes/js/codemirror/htmlhint.js', array(), '1.8.0' );

src/wp-includes/script-modules.php

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -219,13 +219,6 @@ function wp_default_script_modules() {
219219
$module_deps = $script_module_data['module_dependencies'] ?? array();
220220
wp_register_script_module( $script_module_id, $path, $module_deps, $script_module_data['version'], $args );
221221
}
222-
223-
wp_register_script_module(
224-
'espree',
225-
includes_url( 'js/codemirror/espree.min.js' ),
226-
array(),
227-
'9.6.1'
228-
);
229222
}
230223

231224
/**

tests/phpunit/includes/abstract-testcase.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ abstract class WP_UnitTestCase_Base extends PHPUnit_Adapter_TestCase {
1818
protected $expected_deprecated = array();
1919
protected $caught_deprecated = array();
2020
protected $expected_doing_it_wrong = array();
21-
protected $caught_doing_it_wrong = array();
21+
22+
/** @var non-empty-string[] */
23+
protected $caught_doing_it_wrong = array();
2224

2325
protected static $hooks_saved = array();
2426
protected static $ignore_files;

0 commit comments

Comments
 (0)