Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
507697a
Script Loader: Warn when classic scripts depend on modules without fo…
itzmekhokan May 10, 2026
34a953e
Fix array index alignment for PHPCS
westonruter May 13, 2026
cc69aaa
Fix phpstan issues in tests and improve phpdoc
westonruter May 14, 2026
00ca02e
Merge branch 'trunk' of https://github.com/WordPress/wordpress-develo…
westonruter May 14, 2026
22b2849
Use defer strategy instead of in_footer for half of scripts
westonruter May 14, 2026
155b3fc
Prevent passing non-string value into sprintf()
westonruter May 14, 2026
20e80c1
Add missing phpstan types
westonruter May 14, 2026
1e44dd7
Use null coalescing and re-use computed in_footer
westonruter May 14, 2026
10b6e8b
Align module_dependencies phpstan shape with wp_register_script
westonruter May 14, 2026
c0e166d
Add async test case
westonruter May 14, 2026
21d5f55
Fall back to __FUNCTION__ when debug_backtrace lacks a caller frame
westonruter May 14, 2026
ec1ff71
Bypass use of importmap
westonruter May 14, 2026
527e40f
Add missing espreeModuleUrl key to unit tests
westonruter May 14, 2026
ea07685
Merge branch 'trunk' of https://github.com/WordPress/wordpress-develo…
westonruter May 14, 2026
afff995
Eliminate espree from being a publicly-registered script module
westonruter May 14, 2026
3866c55
Warn when invoking wp.codeEditor.initialize() before DCL
westonruter May 14, 2026
6de4e00
Refine console warning
westonruter May 15, 2026
43f999f
Merge branch 'trunk' into fix/trac-65165-module-deps-doing-it-wrong
westonruter May 15, 2026
3a66d56
Merge branch 'trunk' into fix/trac-65165-module-deps-doing-it-wrong
westonruter May 16, 2026
903391f
Correct espreeModuleUrl JSDoc type
westonruter May 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/js/_enqueues/lib/codemirror/javascript-lint.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import CodeMirror from 'codemirror';
* @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."
* @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."
* @property {'implied'} [strict] - "This option requires the code to run in ECMAScript 5's strict mode."
* @property {string} [espreeModuleUrl] - The URL to the espree script module.
*/

/**
Expand All @@ -42,9 +43,13 @@ import CodeMirror from 'codemirror';
* @returns {Promise<CodeMirrorLintError[]>}
*/
async function validator( text, options ) {
if ( ! options.espreeModuleUrl ) {
return [];
}

const errors = /** @type {CodeMirrorLintError[]} */ [];
try {
const espree = await import( /* webpackIgnore: true */ 'espree' );
const espree = await import( /* webpackIgnore: true */ options.espreeModuleUrl );
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sirreal Thoughts on this? Because a full URL to a module is supplied, the codemirror script indeed no longer has any dependency on the import map and it can freely be printed in the head before the importmap script.

In the future, when we support multiple importmap scripts, we could return to using module_dependencies and importing espree.

Copy link
Copy Markdown
Member

@sirreal sirreal May 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One note here:

Because a full URL to a module is supplied, the codemirror script indeed no longer has any dependency on the import map and it can freely be printed in the head before the importmap script.

Before multiple importmaps (unsupported as of current Firefox 150), any module loading or preloading would invalidate subsequent importmaps. Firefox will print a warning and refuse to load an importmap. It's not sufficient to not use an imports, the act of loading (or preloading) a module triggers this behavior.

I don't think this is likely to be a problem in practice on the plugin pages, but we could defer and move them to the footer. This would also resolve the "practice what we preach" concern above. If the editor won't be initialized until DOMContentLoaded anyways, it doesn't seem like a big change.

Here's a demo:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before multiple importmaps (unsupported as of current Firefox 150), any module loading or preloading would invalidate subsequent importmaps. Firefox will print a warning and refuse to load an importmap. It's not sufficient to not use an imports, the act of loading (or preloading) a module triggers this behavior.

Alas, you're right. I was testing with Chrome which allows this:

<script type="module">
import { parse } from '/wp-includes/js/codemirror/espree.min.js';
// import { parse } from 'espree';
console.log( 'Parsed:', parse( 'document.write("Hello World!");' ) );
</script>

<script type="importmap">
{"imports":{
	"@wordpress/a11y": "/wp-content/plugins/gutenberg/build/modules/a11y/index.js?ver=1c371cb517a97cdbcb9f",
	"espree": "/wp-includes/js/codemirror/espree.min.js"
}}
</script>

<script type="module">
import { speak } from '@wordpress/a11y';
speak( 'Hello world!' );
console.log( 'World greeted!' );
</script>

If I swap the imports to be like this, however:

// import { parse } from '/wp-includes/js/codemirror/espree.min.js';
import { parse } from 'espree';

Then Chrome errors:

Uncaught TypeError: Failed to resolve module specifier "espree". Relative references must start with either "/", "./", or "../".

In Firefox, there is always this failure:

Uncaught TypeError: The specifier “@wordpress/a11y” was a bare specifier, but was not remapped to anything. Relative module specifiers must start with “./”, “../” or “/”.

The error appearing in Chrome also appears in Firefox if I try importing espree before the importmap is seen.

espree.parse( text, {
...getEspreeOptions( options ),
loc: true,
Expand Down
6 changes: 6 additions & 0 deletions src/js/_enqueues/wp/code-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* @output wp-admin/js/code-editor.js
*/

/* global console */

/* eslint-env es2020 */

if ( 'undefined' === typeof window.wp ) {
Expand Down Expand Up @@ -412,6 +414,10 @@ if ( 'undefined' === typeof window.wp.codeEditor ) {
* @return {CodeEditorInstance} Instance.
*/
wp.codeEditor.initialize = function initialize( textarea, settings ) {
if ( document.readyState === 'loading' ) {
console.warn( 'wp.codeEditor.initialize() ran too early. Invoke this function in a `DOMContentLoaded` event listener.' );
}

let $textarea;
if ( 'string' === typeof textarea ) {
$textarea = $( '#' + textarea );
Expand Down
4 changes: 2 additions & 2 deletions src/wp-includes/class-wp-scripts.php
Original file line number Diff line number Diff line change
Expand Up @@ -885,7 +885,7 @@ public function add_data( $handle, $key, $value ) {
sprintf(
/* translators: 1: $strategy, 2: $handle */
__( 'Invalid strategy `%1$s` defined for `%2$s` during script registration.' ),
$value,
is_string( $value ) ? $value : gettype( $value ),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When looking at the PHPStan analysis, this inconsistency with the fetchpriority and ! $this->registered[ $handle ]->src conditions and popped out at me. This was originally introduced in 613b0e7.

$handle
),
'6.3.0'
Expand All @@ -897,7 +897,7 @@ public function add_data( $handle, $key, $value ) {
sprintf(
/* translators: 1: $strategy, 2: $handle */
__( 'Cannot supply a strategy `%1$s` for script `%2$s` because it is an alias (it lacks a `src` value).' ),
$value,
is_string( $value ) ? $value : gettype( $value ),
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread
westonruter marked this conversation as resolved.
$handle
),
'6.3.0'
Expand Down
126 changes: 96 additions & 30 deletions src/wp-includes/functions.wp-scripts.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,19 +71,30 @@ function _wp_scripts_maybe_doing_it_wrong( $function_name, $handle = '' ) {
/**
* Adds the data for the recognized args and warns for unrecognized args.
*
* @see wp_enqueue_script()
* @see wp_register_script()
Comment on lines +74 to +75
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are the only two functions which use this internal function. Again, something which would have been helpful to add when it was introduced in 6357346.

*
* @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.
*
* @phpstan-param non-empty-string $handle
* @phpstan-param array{
* in_footer?: bool,
* strategy?: 'async'|'defer',
* fetchpriority?: 'low'|'auto'|'high',
* module_dependencies?: array<non-empty-string|array{ id: non-empty-string, ... }>,
* } $args
Comment on lines +84 to +90
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is introduced in 7.0 but it lacks full PHPStan typing. This is a follow-up to 6357346.

*/
function _wp_scripts_add_args_data( WP_Scripts $wp_scripts, string $handle, array $args ) {
function _wp_scripts_add_args_data( WP_Scripts $wp_scripts, string $handle, array $args ): void {
$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'];
$function_name = ( $trace[1]['class'] ?? '' ) . ( $trace[1]['type'] ?? '' ) . ( $trace[1]['function'] ?? __FUNCTION__ );
_doing_it_wrong(
$function_name,
sprintf(
Expand All @@ -97,7 +108,8 @@ function _wp_scripts_add_args_data( WP_Scripts $wp_scripts, string $handle, arra
);
}

if ( ! empty( $args['in_footer'] ) ) {
$in_footer = ! empty( $args['in_footer'] );
if ( $in_footer ) {
$wp_scripts->add_data( $handle, 'group', 1 );
}
if ( ! empty( $args['strategy'] ) ) {
Expand All @@ -108,6 +120,31 @@ 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_deferred = 'defer' === ( $args['strategy'] ?? null );
if ( ! $in_footer && ! $is_deferred ) {
$trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 2 );
$function_name = ( $trace[1]['class'] ?? '' ) . ( $trace[1]['type'] ?? '' ) . ( $trace[1]['function'] ?? __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.' ),
'<code>module_dependencies</code>',
$handle,
'<code>in_footer</code>',
'<code>strategy</code>',
'<code>defer</code>'
),
'7.0.0'
);
}
}
}

Expand Down Expand Up @@ -204,25 +241,39 @@ function wp_add_inline_script( $handle, $data, $position = 'after' ) {
* @since 6.9.0 The $fetchpriority parameter of type string was added to the $args parameter of type 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<string, string|bool|array<string|array<string, string>>>|bool $args {
* @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 array<string|array<string, string>> $module_dependencies Optional. IDs for module dependencies loaded via dynamic import. Default empty array.
* @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.
Comment on lines +244 to +259
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've simplified the types here to avoid the excessive indentation. This is a follow-up to 6357346 which is for 7.0.

I've provided the full PHPStan types below, whereas they were previously incomplete by not specifying the array keys above (and so not very helpful).

* 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.
*
* @phpstan-param non-empty-string $handle
* @phpstan-param non-empty-string|false $src
* @phpstan-param non-empty-string[] $deps
* @phpstan-param array{
* in_footer?: bool,
* strategy?: 'async'|'defer',
* fetchpriority?: 'low'|'auto'|'high',
* module_dependencies?: array<non-empty-string|array{ id: non-empty-string, ... }>,
* }|bool $args
*/
function wp_register_script( $handle, $src, $deps = array(), $ver = false, $args = array() ) {
if ( ! is_array( $args ) ) {
Expand Down Expand Up @@ -386,31 +437,46 @@ function wp_deregister_script( $handle ) {
* @since 6.9.0 The $fetchpriority parameter of type string was added to the $args parameter of type 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<string, string|bool|array<string|array<string, string>>>|bool $args {
* @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 array<string|array<string, string>> $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()}.
* @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()}.
Comment on lines +440 to +456
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

* 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.
* }
*
* @phpstan-param non-empty-string $handle
* @phpstan-param string $src
* @phpstan-param non-empty-string[] $deps
* @phpstan-param array{
* in_footer?: bool,
* strategy?: 'async'|'defer',
* fetchpriority?: 'low'|'auto'|'high',
* module_dependencies?: array<non-empty-string|array{ id: non-empty-string, ... }>,
* }|bool $args
*/
function wp_enqueue_script( $handle, $src = '', $deps = array(), $ver = false, $args = array() ) {
_wp_scripts_maybe_doing_it_wrong( __FUNCTION__, $handle );

$wp_scripts = wp_scripts();

if ( $src || ! empty( $args ) ) {
/** @var array{ 0: non-empty-string, 1?: string } $_handle */
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically this could be string[], as $handle could be ? or ?foo=bar or ?????????????. But in practice, and the intended usage (which has test coverage) is for the handle to optionally have query string args added after it (which is extremely unusual anyway). In the future we can add some assertions to validate that $_handle[0] is actually a non-empty string.

For that matter, the $handle should actually use strtok() to obtain the value:

$_handle = strtok( $handle, '?' );

$_handle = explode( '?', $handle );
if ( ! is_array( $args ) ) {
$args = array(
Expand Down
34 changes: 19 additions & 15 deletions src/wp-includes/general-template.php
Original file line number Diff line number Diff line change
Expand Up @@ -4155,27 +4155,31 @@ function wp_get_code_editor_settings( $args ) {
'outline-none' => true,
),
'jshint' => array(
'esversion' => 11,
'module' => str_ends_with( $args['file'] ?? '', '.mjs' ),
'esversion' => 11,
'module' => str_ends_with( $args['file'] ?? '', '.mjs' ),

// This script module URL is intentionally referenced here instead of registering an espree script module
// in wp_default_script_modules(). This is a first stab at a core-only private module.
'espreeModuleUrl' => add_query_arg( 'ver', '9.6.1', includes_url( 'js/codemirror/espree.min.js' ) ),

// The following JSHint *linting rule* options are copied from
// <https://github.com/WordPress/wordpress-develop/blob/6.9.0/.jshintrc>.
// Parsing-related options such as `esversion` (and, in other contexts, `es5`, `es3`, `module`, `strict`)
// are honored by the Espree-based integration, but these linting-rule options are not interpreted by Espree
// and are kept only for compatibility/documentation with the original JSHint configuration.
'boss' => true,
'curly' => true,
'eqeqeq' => true,
'eqnull' => true,
'expr' => true,
'immed' => true,
'noarg' => true,
'nonbsp' => true,
'quotmark' => 'single',
'undef' => true,
'unused' => true,
'browser' => true,
'globals' => array(
'boss' => true,
'curly' => true,
'eqeqeq' => true,
'eqnull' => true,
'expr' => true,
'immed' => true,
'noarg' => true,
'nonbsp' => true,
'quotmark' => 'single',
'undef' => true,
'unused' => true,
'browser' => true,
'globals' => array(
'_' => false,
'Backbone' => false,
'jQuery' => false,
Expand Down
3 changes: 1 addition & 2 deletions src/wp-includes/script-loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -1200,9 +1200,8 @@ function wp_default_scripts( $scripts ) {
);

$scripts->add( 'wp-codemirror', '/wp-includes/js/codemirror/codemirror.min.js', array(), '5.65.20' );
did_action( 'init' ) && $scripts->add_data( 'wp-codemirror', 'module_dependencies', array( 'espree' ) );
$scripts->add( 'csslint', '/wp-includes/js/codemirror/csslint.js', array(), '1.0.5' );
$scripts->add( 'esprima', '/wp-includes/js/codemirror/esprima.js', array(), '4.0.1' ); // Deprecated. Use 'espree' script module.
$scripts->add( 'esprima', '/wp-includes/js/codemirror/esprima.js', array(), '4.0.1' ); // Deprecated.
$scripts->add( 'jshint', '/wp-includes/js/codemirror/fakejshint.js', array( 'esprima' ), '2.9.5' ); // Deprecated.
$scripts->add( 'jsonlint', '/wp-includes/js/codemirror/jsonlint.js', array(), '1.6.3' );
$scripts->add( 'htmlhint', '/wp-includes/js/codemirror/htmlhint.js', array(), '1.8.0' );
Expand Down
7 changes: 0 additions & 7 deletions src/wp-includes/script-modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -219,13 +219,6 @@ function wp_default_script_modules() {
$module_deps = $script_module_data['module_dependencies'] ?? array();
wp_register_script_module( $script_module_id, $path, $module_deps, $script_module_data['version'], $args );
}

wp_register_script_module(
'espree',
includes_url( 'js/codemirror/espree.min.js' ),
array(),
'9.6.1'
);
}

/**
Expand Down
4 changes: 3 additions & 1 deletion tests/phpunit/includes/abstract-testcase.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ abstract class WP_UnitTestCase_Base extends PHPUnit_Adapter_TestCase {
protected $expected_deprecated = array();
protected $caught_deprecated = array();
protected $expected_doing_it_wrong = array();
protected $caught_doing_it_wrong = array();

/** @var non-empty-string[] */
protected $caught_doing_it_wrong = array();

protected static $hooks_saved = array();
protected static $ignore_files;
Expand Down
Loading
Loading