From 86da535081ac3bce949f4159a53100c1acf76b81 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 5 May 2026 19:46:54 +0200 Subject: [PATCH 1/3] Script Loader: Add private script modules via scoped import maps. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a `scopes` arg on `wp_register_script_module()` and `wp_enqueue_script_module()` that constrains which importers can resolve a module's bare specifier. Modules with `scopes` are emitted under the import map's top-level `scopes` field, not `imports`. Each `scopes` entry is one of: - A non-empty URL prefix string, emitted as-authored. - `array( 'module_id' => $id )`, resolved at print time to the directory of the named module's filtered src URL (CDN-aware via the existing `script_module_loader_src` filter). `scopes => array()` registers a module that is not resolvable via bare specifier from anywhere; static dependencies on such a module are treated like missing dependencies. This is API hygiene + bare-specifier scoping per the import map spec — not a security boundary. Files remain fetchable via direct URL `import()`; resolution failure for out-of-scope importers is a runtime `TypeError`. --- src/wp-includes/class-wp-script-modules.php | 289 +++++++++-- src/wp-includes/script-modules.php | 23 +- .../tests/script-modules/wpScriptModules.php | 461 ++++++++++++++++++ 3 files changed, 726 insertions(+), 47 deletions(-) diff --git a/src/wp-includes/class-wp-script-modules.php b/src/wp-includes/class-wp-script-modules.php index 154c951513047..a21877762fd65 100644 --- a/src/wp-includes/class-wp-script-modules.php +++ b/src/wp-includes/class-wp-script-modules.php @@ -21,6 +21,7 @@ * fetchpriority: 'auto'|'low'|'high', * textdomain?: string, * translations_path?: string, + * scopes?: array, * } */ class WP_Script_Modules { @@ -98,36 +99,54 @@ class WP_Script_Modules { * * @since 6.5.0 * @since 6.9.0 Added the $args parameter. - * - * @param string $id The identifier of the script module. Should be unique. It will be used in the - * final import map. - * @param string $src Optional. Full URL of the script module, or path of the script module relative - * to the WordPress root directory. If it is provided and the script module has - * not been registered yet, it will be registered. - * @param array> $deps { - * Optional. List of dependencies. - * - * @type string|array ...$0 { - * An array of script module identifiers of the dependencies of this script - * module. The dependencies can be strings or arrays. If they are arrays, - * they need an `id` key with the script module identifier, and can contain - * an `import` key with either `static` or `dynamic`. By default, - * dependencies that don't contain an `import` key are considered static. - * - * @type string $id The script module identifier. - * @type string $import Optional. Import type. May be either `static` or - * `dynamic`. Defaults to `static`. - * } - * } - * @param string|false|null $version Optional. String specifying the script module version number. Defaults to false. - * It is added to the URL as a query string for cache busting purposes. If $version - * is set to false, the version number is the currently installed WordPress version. - * If $version is set to null, no version is added. - * @param array $args { + * @since 7.1.0 Added the `scopes` key to the $args parameter. + * + * @param string $id The identifier of the script module. Should be unique. It will be used in the + * final import map. + * @param string $src Optional. Full URL of the script module, or path of the script module relative + * to the WordPress root directory. If it is provided and the script module has + * not been registered yet, it will be registered. + * @param array> $deps { + * Optional. List of dependencies. + * + * @type string|array ...$0 { + * An array of script module identifiers of the dependencies of this script + * module. The dependencies can be strings or arrays. If they are arrays, + * they need an `id` key with the script module identifier, and can contain + * an `import` key with either `static` or `dynamic`. By default, + * dependencies that don't contain an `import` key are considered static. + * + * @type string $id The script module identifier. + * @type string $import Optional. Import type. May be either `static` or + * `dynamic`. Defaults to `static`. + * } + * } + * @param string|false|null $version Optional. String specifying the script module version number. Defaults to false. + * It is added to the URL as a query string for cache busting purposes. If $version + * is set to false, the version number is the currently installed WordPress version. + * If $version is set to null, no version is added. + * @param array>|null> $args { * Optional. An array of additional args. Default empty array. * * @type bool $in_footer Whether to print the script module in the footer. Only relevant to block themes. Default 'false'. Optional. * @type 'auto'|'low'|'high' $fetchpriority Fetch priority. Default 'auto'. Optional. + * @type array|null $scopes Optional. Constrains the importers that can resolve this module's bare specifier. + * When omitted or null, the module is public (top-level `imports`). When an array is + * provided, the module is emitted under the import map's `scopes` keyed by each entry, + * and is not present in top-level `imports`. An empty array means the module is + * registered but cannot be resolved via bare specifier from anywhere; static + * dependencies on such a module are treated like missing dependencies. + * Each entry is one of: + * - A non-empty string URL prefix, emitted as-authored. WordPress URL filters such as + * `script_module_loader_src` are NOT applied to string scopes; authors are + * responsible for matching the final browser URL. + * - `array( 'module_id' => string )` — at print time, resolves to the directory + * portion of the named registered module's filtered src URL. Recommended for + * CDN-aware scoping because it goes through the existing `script_module_loader_src` + * filter. + * This is an API-hygiene mechanism, not a security boundary; the file remains + * fetchable, and any caller can still list the module's id in `$deps` or call + * `wp_enqueue_script_module()` on it. Resolution failure is a runtime `TypeError`. * } */ public function register( string $id, string $src, array $deps = array(), $version = false, array $args = array() ) { @@ -178,13 +197,55 @@ public function register( string $id, string $src, array $deps = array(), $versi } } - $this->registered[ $id ] = array( + $registered_module = array( 'src' => $src, 'version' => $version, 'dependencies' => $dependencies, 'in_footer' => $in_footer, 'fetchpriority' => $fetchpriority, ); + + if ( array_key_exists( 'scopes', $args ) && null !== $args['scopes'] ) { + if ( ! is_array( $args['scopes'] ) ) { + _doing_it_wrong( + __METHOD__, + __( 'The "scopes" arg must be null or an array.' ), + '7.1.0' + ); + } else { + $validated_scopes = array(); + foreach ( $args['scopes'] as $scope_entry ) { + if ( is_string( $scope_entry ) ) { + if ( '' === $scope_entry ) { + _doing_it_wrong( + __METHOD__, + __( 'Empty string scope entry; entries must be non-empty.' ), + '7.1.0' + ); + continue; + } + $validated_scopes[] = $scope_entry; + } elseif ( + is_array( $scope_entry ) && + 1 === count( $scope_entry ) && + isset( $scope_entry['module_id'] ) && + is_string( $scope_entry['module_id'] ) && + '' !== $scope_entry['module_id'] + ) { + $validated_scopes[] = array( 'module_id' => $scope_entry['module_id'] ); + } else { + _doing_it_wrong( + __METHOD__, + __( 'Each scope entry must be a non-empty string or [ "module_id" => non-empty string ].' ), + '7.1.0' + ); + } + } + $registered_module['scopes'] = $validated_scopes; + } + } + + $this->registered[ $id ] = $registered_module; } } @@ -270,6 +331,7 @@ public function set_in_footer( string $id, bool $in_footer ): bool { * * @since 6.5.0 * @since 6.9.0 Added the $args parameter. + * @since 7.1.0 Added the `scopes` key to the $args parameter. * * @param string $id The identifier of the script module. Should be unique. It will be used in the * final import map. @@ -295,11 +357,12 @@ public function set_in_footer( string $id, bool $in_footer ): bool { * It is added to the URL as a query string for cache busting purposes. If $version * is set to false, the version number is the currently installed WordPress version. * If $version is set to null, no version is added. - * @param array $args { + * @param array>|null> $args { * Optional. An array of additional args. Default empty array. * * @type bool $in_footer Whether to print the script module in the footer. Only relevant to block themes. Default 'false'. Optional. * @type 'auto'|'low'|'high' $fetchpriority Fetch priority. Default 'auto'. Optional. + * @type array|null $scopes Optional. See {@see WP_Script_Modules::register()} for details. * } */ public function enqueue( string $id, string $src = '', array $deps = array(), $version = false, array $args = array() ) { @@ -619,7 +682,7 @@ public function print_script_module_preloads() { */ public function print_import_map() { $import_map = $this->get_import_map(); - if ( ! empty( $import_map['imports'] ) ) { + if ( ! empty( $import_map['imports'] ) || ! empty( $import_map['scopes'] ) ) { wp_print_inline_script_tag( (string) wp_json_encode( $import_map, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), array( @@ -635,11 +698,13 @@ public function print_import_map() { * * @since 6.5.0 * @since 7.0.0 Script module dependencies ('module_dependencies') of classic scripts are now included. + * @since 7.1.0 Scoped (private) modules are emitted under a top-level `scopes` key. * * @global WP_Scripts $wp_scripts * - * @return array> Array with an `imports` key mapping to an array of script module - * identifiers and their respective URLs, including the version query. + * @return array Array with an `imports` key mapping module identifiers to URLs (including the version query), + * and an optional `scopes` key mapping scope-prefix URLs to per-scope import maps. + * @phpstan-return array{ imports: array, scopes?: array> } */ private function get_import_map(): array { global $wp_scripts; @@ -714,13 +779,34 @@ private function get_import_map(): array { array_keys( $this->get_dependencies( array_merge( $this->queue, $classic_script_module_dependencies ) ) ) ) ); + + $scopes = array(); foreach ( $ids as $id ) { $src = $this->get_src( $id ); - if ( '' !== $src ) { + if ( '' === $src ) { + continue; + } + + if ( isset( $this->registered[ $id ]['scopes'] ) ) { + // Scoped (private) modules are emitted only under their resolved scope keys. + // Modules with `scopes => array()` resolve to no keys and are intentionally + // absent from both `imports` and `scopes`. + foreach ( $this->get_scope_keys( $id ) as $scope_key ) { + if ( ! isset( $scopes[ $scope_key ] ) ) { + $scopes[ $scope_key ] = array(); + } + $scopes[ $scope_key ][ $id ] = $src; + } + } else { $imports[ $id ] = $src; } } - return array( 'imports' => $imports ); + + $import_map = array( 'imports' => $imports ); + if ( ! empty( $scopes ) ) { + $import_map['scopes'] = $scopes; + } + return $import_map; } /** @@ -911,18 +997,49 @@ private function sort_item_dependencies( string $id, array $import_types, array // If the item requires dependencies that do not exist, fail. $missing_dependencies = array_diff( $dependency_ids, array_keys( $this->registered ) ); - if ( count( $missing_dependencies ) > 0 ) { + + // Dependencies on modules registered with empty `scopes` (`scopes => array()`) + // cannot be resolved via bare specifier from anywhere; treat them as missing + // so the dependent does not emit and a developer warning surfaces early. + $unreachable_dependencies = array(); + foreach ( $dependency_ids as $dep_id ) { + if ( in_array( $dep_id, $missing_dependencies, true ) ) { + continue; + } + if ( + isset( $this->registered[ $dep_id ]['scopes'] ) && + array() === $this->registered[ $dep_id ]['scopes'] + ) { + $unreachable_dependencies[] = $dep_id; + } + } + + if ( count( $missing_dependencies ) > 0 || count( $unreachable_dependencies ) > 0 ) { if ( ! in_array( $id, $this->modules_with_missing_dependencies, true ) ) { - _doing_it_wrong( - get_class( $this ) . '::register', - sprintf( - /* translators: 1: Script module ID, 2: List of missing dependency IDs. */ - __( 'The script module with the ID "%1$s" was enqueued with dependencies that are not registered: %2$s.' ), - $id, - implode( wp_get_list_item_separator(), $missing_dependencies ) - ), - '6.9.1' - ); + if ( count( $missing_dependencies ) > 0 ) { + _doing_it_wrong( + get_class( $this ) . '::register', + sprintf( + /* translators: 1: Script module ID, 2: List of missing dependency IDs. */ + __( 'The script module with the ID "%1$s" was enqueued with dependencies that are not registered: %2$s.' ), + $id, + implode( wp_get_list_item_separator(), $missing_dependencies ) + ), + '6.9.1' + ); + } + if ( count( $unreachable_dependencies ) > 0 ) { + _doing_it_wrong( + get_class( $this ) . '::register', + sprintf( + /* translators: 1: Script module ID, 2: List of unreachable dependency IDs. */ + __( 'The script module with the ID "%1$s" was enqueued with dependencies that are registered with empty scopes and cannot be resolved via bare specifier: %2$s.' ), + $id, + implode( wp_get_list_item_separator(), $unreachable_dependencies ) + ), + '7.1.0' + ); + } $this->modules_with_missing_dependencies[] = $id; } @@ -1000,6 +1117,77 @@ private function get_src( string $id ): string { return $src; } + /** + * Resolves the import-map scope keys for a registered scoped script module. + * + * String entries are returned as-authored. `[ 'module_id' => $other_id ]` entries are + * resolved to the directory portion of the named module's filtered src URL (query and + * fragment stripped, path truncated to the last "/"). Lookup failures are dropped with + * `_doing_it_wrong`. + * + * Returns an empty array for unregistered modules, public modules (no `scopes`), or + * modules whose `scopes` entries all failed to resolve. + * + * @since 7.1.0 + * + * @param string $id The script module identifier. + * @return string[] Resolved, unique scope keys. + */ + private function get_scope_keys( string $id ): array { + if ( ! isset( $this->registered[ $id ]['scopes'] ) ) { + return array(); + } + + $keys = array(); + foreach ( $this->registered[ $id ]['scopes'] as $entry ) { + if ( is_string( $entry ) ) { + $keys[ $entry ] = true; + continue; + } + + $other_id = $entry['module_id']; + if ( ! isset( $this->registered[ $other_id ] ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: Scope module id; 2: Owning module id. */ + __( 'Scope entry references unregistered module "%1$s" for module "%2$s".' ), + $other_id, + $id + ), + '7.1.0' + ); + continue; + } + + $other_src = $this->get_src( $other_id ); + if ( '' === $other_src ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: Scope module id; 2: Owning module id. */ + __( 'Scope entry references module "%1$s" with empty src for module "%2$s".' ), + $other_id, + $id + ), + '7.1.0' + ); + continue; + } + + // Strip query and fragment, truncate to last "/" to derive directory. + $path = preg_replace( '~[?#].*$~', '', $other_src ); + $last_slash = strrpos( (string) $path, '/' ); + if ( false === $last_slash ) { + continue; + } + + $keys[ substr( (string) $path, 0, $last_slash + 1 ) ] = true; + } + + return array_keys( $keys ); + } + /** * Print data associated with Script Modules. * @@ -1021,12 +1209,23 @@ public function print_script_module_data(): void { } $modules[ $id ] = true; } - foreach ( array_keys( $this->get_import_map()['imports'] ) as $id ) { + $import_map = $this->get_import_map(); + foreach ( array_keys( $import_map['imports'] ) as $id ) { if ( '@wordpress/a11y' === $id ) { $this->a11y_available = true; } $modules[ $id ] = true; } + if ( isset( $import_map['scopes'] ) ) { + foreach ( $import_map['scopes'] as $scope_entries ) { + foreach ( array_keys( $scope_entries ) as $id ) { + if ( '@wordpress/a11y' === $id ) { + $this->a11y_available = true; + } + $modules[ $id ] = true; + } + } + } foreach ( array_keys( $modules ) as $module_id ) { /** diff --git a/src/wp-includes/script-modules.php b/src/wp-includes/script-modules.php index b3a89fdf71844..3e66adce5b53f 100644 --- a/src/wp-includes/script-modules.php +++ b/src/wp-includes/script-modules.php @@ -36,6 +36,7 @@ function wp_script_modules(): WP_Script_Modules { * * @since 6.5.0 * @since 6.9.0 Added the $args parameter. + * @since 7.1.0 Added the `scopes` key to the $args parameter. * * @param string $id The identifier of the script module. Should be unique. It will be used in the * final import map. @@ -61,11 +62,27 @@ function wp_script_modules(): WP_Script_Modules { * It is added to the URL as a query string for cache busting purposes. If $version * is set to false, the version number is the currently installed WordPress version. * If $version is set to null, no version is added. - * @param array $args { + * @param array>|null> $args { * Optional. An array of additional args. Default empty array. * * @type bool $in_footer Whether to print the script module in the footer. Only relevant to block themes. Default 'false'. Optional. * @type 'auto'|'low'|'high' $fetchpriority Fetch priority. Default 'auto'. Optional. + * @type array|null $scopes Optional. Constrains the importers that can resolve this module's bare specifier. + * When omitted or null, the module is public. When an array is provided, the module is + * emitted under the import map's `scopes` keyed by each entry, and is not present in + * top-level `imports`. An empty array means the module is registered but cannot be + * resolved via bare specifier from anywhere; static dependencies on such a module + * are treated like missing dependencies. + * Each entry is one of: + * - A non-empty string URL prefix, emitted as-authored. WordPress URL-rewriting + * hooks such as `script_module_loader_src` are NOT applied to string scopes; + * authors are responsible for matching the final browser URL. + * - `array( 'module_id' => string )` — at print time, resolves to the directory + * portion of the named registered module's filtered src URL. Recommended for + * CDN-aware scoping (the existing `script_module_loader_src` filter applies). + * This is API-hygiene + bare-specifier scoping, not a security boundary; the file + * remains fetchable, callers can still list the module's id in `$deps` or call + * `wp_enqueue_script_module()` on it. Resolution failure is a runtime `TypeError`. * } */ function wp_register_script_module( string $id, string $src, array $deps = array(), $version = false, array $args = array() ) { @@ -80,6 +97,7 @@ function wp_register_script_module( string $id, string $src, array $deps = array * * @since 6.5.0 * @since 6.9.0 Added the $args parameter. + * @since 7.1.0 Added the `scopes` key to the $args parameter. * * @param string $id The identifier of the script module. Should be unique. It will be used in the * final import map. @@ -105,11 +123,12 @@ function wp_register_script_module( string $id, string $src, array $deps = array * It is added to the URL as a query string for cache busting purposes. If $version * is set to false, the version number is the currently installed WordPress version. * If $version is set to null, no version is added. - * @param array $args { + * @param array>|null> $args { * Optional. An array of additional args. Default empty array. * * @type bool $in_footer Whether to print the script module in the footer. Only relevant to block themes. Default 'false'. Optional. * @type 'auto'|'low'|'high' $fetchpriority Fetch priority. Default 'auto'. Optional. + * @type array|null $scopes Optional. See {@see wp_register_script_module()} for details. * } */ function wp_enqueue_script_module( string $id, string $src = '', array $deps = array(), $version = false, array $args = array() ) { diff --git a/tests/phpunit/tests/script-modules/wpScriptModules.php b/tests/phpunit/tests/script-modules/wpScriptModules.php index 330736431dffd..b5b2188db5ca8 100644 --- a/tests/phpunit/tests/script-modules/wpScriptModules.php +++ b/tests/phpunit/tests/script-modules/wpScriptModules.php @@ -107,6 +107,23 @@ public function get_import_map(): array { } } + /** + * Gets the full decoded import map (including scopes if present). + * + * @return array Decoded import map ('imports' and optional 'scopes'), or [] if no map printed. + */ + public function get_full_import_map(): array { + $p = new WP_HTML_Tag_Processor( get_echo( array( $this->script_modules, 'print_import_map' ) ) ); + if ( $p->next_tag( array( 'tag' => 'SCRIPT' ) ) ) { + $this->assertSame( 'importmap', $p->get_attribute( 'type' ) ); + $this->assertSame( 'wp-importmap', $p->get_attribute( 'id' ) ); + $data = json_decode( $p->get_modifiable_text(), true ); + $this->assertIsArray( $data ); + return $data; + } + return array(); + } + /** * Gets a list of preloaded script modules. * @@ -2872,4 +2889,448 @@ static function ( $translations, $file, $handle, $domain ) use ( &$seen_domain ) $this->assertSame( 'my-plugin', $seen_domain, 'load_script_module_textdomain() should be called with the overridden domain.' ); $this->assertStringContainsString( 'Hola', $output, 'Output should contain the translated string loaded under the overridden domain.' ); } + + /* + * -------------------------------------------------------------------- + * Scoped (private) script modules — `scopes` arg. + * -------------------------------------------------------------------- + */ + + /** + * Public registration produces no `scopes` field in the import map. + * + * @covers WP_Script_Modules::register + * @covers WP_Script_Modules::print_import_map + */ + public function test_scopes_public_module_unchanged() { + $this->script_modules->register( 'dep', '/dep.js' ); + $this->script_modules->register( 'foo', '/foo.js', array( 'dep' ) ); + $this->script_modules->enqueue( 'foo' ); + + $map = $this->get_full_import_map(); + $this->assertArrayHasKey( 'imports', $map ); + $this->assertArrayNotHasKey( 'scopes', $map ); + $this->assertArrayHasKey( 'dep', $map['imports'] ); + } + + /** + * A directory-style string scope emits under `scopes[]`. + * + * @covers WP_Script_Modules::register + * @covers WP_Script_Modules::print_import_map + */ + public function test_scopes_string_directory_scope() { + $this->script_modules->register( + 'private', + '/wp-content/plugins/x/private.js', + array(), + null, + array( 'scopes' => array( '/wp-content/plugins/x/' ) ) + ); + $this->script_modules->register( 'consumer', '/wp-content/plugins/x/consumer.js', array( 'private' ) ); + $this->script_modules->enqueue( 'consumer' ); + + $map = $this->get_full_import_map(); + $this->assertArrayHasKey( 'scopes', $map ); + $this->assertArrayHasKey( '/wp-content/plugins/x/', $map['scopes'] ); + $this->assertArrayHasKey( 'private', $map['scopes']['/wp-content/plugins/x/'] ); + $this->assertArrayNotHasKey( 'private', $map['imports'] ); + } + + /** + * An exact-URL string scope (no trailing slash) is accepted and emitted verbatim. + * + * @covers WP_Script_Modules::register + * @covers WP_Script_Modules::print_import_map + */ + public function test_scopes_string_exact_url_scope() { + $this->script_modules->register( + 'private', + '/private.js', + array(), + null, + array( 'scopes' => array( 'https://example.com/exact.js' ) ) + ); + $this->script_modules->register( 'consumer', '/consumer.js', array( 'private' ) ); + $this->script_modules->enqueue( 'consumer' ); + + $map = $this->get_full_import_map(); + $this->assertArrayHasKey( 'https://example.com/exact.js', $map['scopes'] ); + } + + /** + * `module_id` scope resolves at print time to the directory of the named module. + * + * @covers WP_Script_Modules::register + * @covers WP_Script_Modules::print_import_map + */ + public function test_scopes_module_id_resolves_to_directory() { + $this->script_modules->register( 'sibling', '/wp-content/plugins/x/build/sibling.js' ); + $this->script_modules->register( + 'private', + '/wp-content/plugins/x/build/private.js', + array(), + null, + array( 'scopes' => array( array( 'module_id' => 'sibling' ) ) ) + ); + $this->script_modules->register( 'consumer', '/wp-content/plugins/x/build/consumer.js', array( 'private' ) ); + $this->script_modules->enqueue( 'consumer' ); + + $map = $this->get_full_import_map(); + $this->assertArrayHasKey( '/wp-content/plugins/x/build/', $map['scopes'] ); + $this->assertArrayHasKey( 'private', $map['scopes']['/wp-content/plugins/x/build/'] ); + } + + /** + * `module_id` scope strips query and fragment from the resolved URL. + * + * @covers WP_Script_Modules::register + * @covers WP_Script_Modules::print_import_map + */ + public function test_scopes_module_id_strips_query_and_fragment() { + $this->script_modules->register( 'sibling', '/wp-content/plugins/x/sibling.js', array(), '1.2.3' ); + $this->script_modules->register( + 'private', + '/wp-content/plugins/x/private.js', + array(), + null, + array( 'scopes' => array( array( 'module_id' => 'sibling' ) ) ) + ); + $this->script_modules->register( 'consumer', '/wp-content/plugins/x/consumer.js', array( 'private' ) ); + $this->script_modules->enqueue( 'consumer' ); + + $map = $this->get_full_import_map(); + $this->assertArrayHasKey( '/wp-content/plugins/x/', $map['scopes'] ); + } + + /** + * `module_id` referencing an unregistered module drops the entry with `_doing_it_wrong`. + * + * @expectedIncorrectUsage WP_Script_Modules::get_scope_keys + * + * @covers WP_Script_Modules::register + * @covers WP_Script_Modules::print_import_map + */ + public function test_scopes_module_id_unregistered_drops() { + $this->script_modules->register( + 'private', + '/private.js', + array(), + null, + array( + 'scopes' => array( + array( 'module_id' => 'nope' ), + '/fallback/', + ), + ) + ); + $this->script_modules->register( 'consumer', '/consumer.js', array( 'private' ) ); + $this->script_modules->enqueue( 'consumer' ); + + $map = $this->get_full_import_map(); + $this->assertArrayHasKey( 'scopes', $map ); + $this->assertArrayHasKey( '/fallback/', $map['scopes'] ); + } + + /** + * Empty `scopes => array()` causes static dep edges to be treated as missing. + * + * @expectedIncorrectUsage WP_Script_Modules::register + * + * @covers WP_Script_Modules::register + * @covers WP_Script_Modules::print_enqueued_script_modules + */ + public function test_scopes_empty_array_static_dep_treated_as_missing() { + $this->script_modules->register( + 'unreachable', + '/unreachable.js', + array(), + null, + array( 'scopes' => array() ) + ); + $this->script_modules->enqueue( 'consumer', '/consumer.js', array( 'unreachable' ) ); + + $enqueued = $this->get_enqueued_script_modules(); + $this->assertArrayNotHasKey( 'consumer', $enqueued, 'Consumer should not be emitted when its static dep is in empty scopes.' ); + + $map = $this->get_full_import_map(); + $this->assertArrayNotHasKey( 'scopes', $map ); + if ( isset( $map['imports'] ) ) { + $this->assertArrayNotHasKey( 'unreachable', $map['imports'] ); + } + } + + /** + * Empty `scopes => array()` is not emitted in the import map. + * + * @covers WP_Script_Modules::register + * @covers WP_Script_Modules::print_import_map + */ + public function test_scopes_empty_array_not_emitted() { + $this->script_modules->register( 'public', '/public.js' ); + $this->script_modules->register( + 'unreachable', + '/unreachable.js', + array(), + null, + array( 'scopes' => array() ) + ); + $this->script_modules->enqueue( 'public' ); + + $map = $this->get_full_import_map(); + $this->assertArrayNotHasKey( 'scopes', $map ); + // 'unreachable' isn't in the queue's transitive deps, so no entry expected. + $this->assertArrayNotHasKey( 'unreachable', $map['imports'] ?? array() ); + } + + /** + * Multiple modules can share a single scope key entry. + * + * @covers WP_Script_Modules::register + * @covers WP_Script_Modules::print_import_map + */ + public function test_scopes_multiple_modules_share_scope_key() { + $this->script_modules->register( + 'priv-a', + '/a.js', + array(), + null, + array( 'scopes' => array( '/scope/' ) ) + ); + $this->script_modules->register( + 'priv-b', + '/b.js', + array(), + null, + array( 'scopes' => array( '/scope/' ) ) + ); + $this->script_modules->register( 'consumer', '/consumer.js', array( 'priv-a', 'priv-b' ) ); + $this->script_modules->enqueue( 'consumer' ); + + $map = $this->get_full_import_map(); + $this->assertArrayHasKey( '/scope/', $map['scopes'] ); + $this->assertArrayHasKey( 'priv-a', $map['scopes']['/scope/'] ); + $this->assertArrayHasKey( 'priv-b', $map['scopes']['/scope/'] ); + } + + /** + * Import map is printed when only `scopes` entries exist (no public imports). + * + * @covers WP_Script_Modules::print_import_map + */ + public function test_scopes_importmap_emitted_when_scopes_only() { + $this->script_modules->register( + 'private', + '/private.js', + array(), + null, + array( 'scopes' => array( '/x/' ) ) + ); + $this->script_modules->enqueue( 'consumer', '/consumer.js', array( 'private' ) ); + + $markup = get_echo( array( $this->script_modules, 'print_import_map' ) ); + $this->assertNotEmpty( $markup, 'Import map should be printed when scopes-only entries exist.' ); + $this->assertStringContainsString( 'importmap', $markup ); + } + + /** + * Register-time validation: non-array scopes value is rejected. + * + * @expectedIncorrectUsage WP_Script_Modules::register + * + * @covers WP_Script_Modules::register + */ + public function test_scopes_register_validation_non_array() { + $this->script_modules->register( + 'foo', + '/foo.js', + array(), + null, + array( 'scopes' => 'oops' ) + ); + + $registered = $this->script_modules->get_registered( 'foo' ); + $this->assertArrayNotHasKey( 'scopes', (array) $registered ); + } + + /** + * Register-time validation: invalid scope entries are skipped. + * + * @expectedIncorrectUsage WP_Script_Modules::register + * + * @covers WP_Script_Modules::register + */ + public function test_scopes_register_validation_invalid_entry() { + $this->script_modules->register( + 'foo', + '/foo.js', + array(), + null, + array( + 'scopes' => array( + '', // empty string + array( 'unknown_tag' => 'value' ), // unknown tag + array( 'module_id' => '' ), // empty module_id + array( // multi-key + 'module_id' => 'x', + 'extra' => 'y', + ), + '/keep/', // valid + ), + ) + ); + + $registered = $this->script_modules->get_registered( 'foo' ); + $this->assertSame( array( '/keep/' ), $registered['scopes'] ); + } + + /** + * Storage shape: omitted/null is public, [] is empty-scoped, [...] is scoped. + * + * @covers WP_Script_Modules::register + * @covers WP_Script_Modules::get_registered + */ + public function test_scopes_storage_shape() { + $this->script_modules->register( 'a', '/a.js' ); + $this->script_modules->register( 'b', '/b.js', array(), null, array( 'scopes' => null ) ); + $this->script_modules->register( 'c', '/c.js', array(), null, array( 'scopes' => array() ) ); + $this->script_modules->register( 'd', '/d.js', array(), null, array( 'scopes' => array( '/d/' ) ) ); + + $this->assertArrayNotHasKey( 'scopes', (array) $this->script_modules->get_registered( 'a' ) ); + $this->assertArrayNotHasKey( 'scopes', (array) $this->script_modules->get_registered( 'b' ) ); + $this->assertSame( array(), $this->script_modules->get_registered( 'c' )['scopes'] ); + $this->assertSame( array( '/d/' ), $this->script_modules->get_registered( 'd' )['scopes'] ); + } + + /** + * Bare-string scopes are NOT subject to script_module_loader_src filter. + * + * Documented v1 limitation: authors using CDNs should prefer `module_id` scopes, + * or author the final browser URL. + * + * @covers WP_Script_Modules::register + * @covers WP_Script_Modules::print_import_map + */ + public function test_scopes_bare_string_not_rewritten_by_filter() { + $rewriter = static function ( $src ) { + return 'https://cdn.example.com' . $src; + }; + add_filter( 'script_module_loader_src', $rewriter ); + + try { + $this->script_modules->register( + 'private', + '/wp-content/plugins/x/private.js', + array(), + null, + array( 'scopes' => array( '/wp-content/plugins/x/' ) ) + ); + $this->script_modules->register( 'consumer', '/wp-content/plugins/x/consumer.js', array( 'private' ) ); + $this->script_modules->enqueue( 'consumer' ); + + $map = $this->get_full_import_map(); + $this->assertArrayHasKey( '/wp-content/plugins/x/', $map['scopes'] ); + $this->assertStringStartsWith( 'https://cdn.example.com', $map['scopes']['/wp-content/plugins/x/']['private'] ); + } finally { + remove_filter( 'script_module_loader_src', $rewriter ); + } + } + + /** + * `module_id` scopes pick up filter rewrites because they go through get_src(). + * + * @covers WP_Script_Modules::register + * @covers WP_Script_Modules::print_import_map + */ + public function test_scopes_module_id_cdn_parity() { + $rewriter = static function ( $src ) { + return 'https://cdn.example.com' . $src; + }; + add_filter( 'script_module_loader_src', $rewriter ); + + try { + $this->script_modules->register( 'sibling', '/wp-content/plugins/x/build/sibling.js' ); + $this->script_modules->register( + 'private', + '/wp-content/plugins/x/build/private.js', + array(), + null, + array( 'scopes' => array( array( 'module_id' => 'sibling' ) ) ) + ); + $this->script_modules->register( 'consumer', '/wp-content/plugins/x/build/consumer.js', array( 'private' ) ); + $this->script_modules->enqueue( 'consumer' ); + + $map = $this->get_full_import_map(); + $this->assertArrayHasKey( 'https://cdn.example.com/wp-content/plugins/x/build/', $map['scopes'] ); + } finally { + remove_filter( 'script_module_loader_src', $rewriter ); + } + } + + /** + * `script_module_data_*` filter still fires for scoped modules. + * + * @covers WP_Script_Modules::print_script_module_data + */ + public function test_scopes_script_module_data_emitted_for_scoped() { + $this->script_modules->register( + 'private', + '/private.js', + array(), + null, + array( 'scopes' => array( '/x/' ) ) + ); + $this->script_modules->enqueue( 'consumer', '/consumer.js', array( 'private' ) ); + + add_filter( + 'script_module_data_private', + static function () { + return array( 'token' => 'abc' ); + } + ); + + $markup = get_echo( array( $this->script_modules, 'print_script_module_data' ) ); + $this->assertStringContainsString( 'wp-script-module-data-private', $markup ); + $this->assertStringContainsString( '"token":"abc"', $markup ); + } + + /** + * Preloads do not include modules whose scope is empty (sorter rejects the dep). + * + * @expectedIncorrectUsage WP_Script_Modules::register + * + * @covers WP_Script_Modules::print_script_module_preloads + */ + public function test_scopes_preloads_skip_empty_scoped_static_dep() { + $this->script_modules->register( + 'unreachable', + '/unreachable.js', + array(), + null, + array( 'scopes' => array() ) + ); + $this->script_modules->enqueue( 'consumer', '/consumer.js', array( 'unreachable' ) ); + + $preloads = $this->get_preloaded_script_modules(); + $this->assertArrayNotHasKey( 'unreachable', $preloads ); + } + + /** + * Preloads do include modules with non-empty scopes (URL-keyed; file is on the page anyway). + * + * @covers WP_Script_Modules::print_script_module_preloads + */ + public function test_scopes_preloads_include_scoped_static_dep() { + $this->script_modules->register( + 'private', + '/private.js', + array(), + null, + array( 'scopes' => array( '/x/' ) ) + ); + $this->script_modules->enqueue( 'consumer', '/consumer.js', array( 'private' ) ); + + $preloads = $this->get_preloaded_script_modules(); + $this->assertArrayHasKey( 'private', $preloads ); + } } From 602daa6127e3c31d39d3620acffb5fc4e9a04951 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 5 May 2026 20:00:30 +0200 Subject: [PATCH 2/3] Script Loader: Fix transitive leaks and improve diagnostics for scoped modules. Address review feedback for the scoped script modules feature: - Stop import-map traversal at modules registered with empty scopes so their public transitive dependencies don't leak into top-level `imports`. - Warn when a classic script's `module_dependencies` reference a module registered with empty scopes; such a dependency cannot be resolved via bare specifier from the classic script. - Warn when a `module_id` scope resolves to a URL with no path separator (which cannot be reduced to a directory). - Improve register-time validation messages to include the module identifier and received type, and avoid PHP syntax in translatable strings. - Correct the docblock claim that only static dependencies on empty-scoped modules fail; declared dependencies (static or dynamic) are treated like missing dependencies, mirroring existing missing-dep semantics. - Tighten test assertions and wrap a filter in `try/finally` to prevent leaks between tests; add coverage for the transitive-leak fix, the classic-script empty-scoped warning, and the `module_id` no-slash diagnostic. --- src/wp-includes/class-wp-script-modules.php | 97 +++++++++++-- src/wp-includes/script-modules.php | 5 +- .../tests/script-modules/wpScriptModules.php | 127 ++++++++++++++++-- 3 files changed, 210 insertions(+), 19 deletions(-) diff --git a/src/wp-includes/class-wp-script-modules.php b/src/wp-includes/class-wp-script-modules.php index a21877762fd65..6b337b11ab31e 100644 --- a/src/wp-includes/class-wp-script-modules.php +++ b/src/wp-includes/class-wp-script-modules.php @@ -134,8 +134,9 @@ class WP_Script_Modules { * When omitted or null, the module is public (top-level `imports`). When an array is * provided, the module is emitted under the import map's `scopes` keyed by each entry, * and is not present in top-level `imports`. An empty array means the module is - * registered but cannot be resolved via bare specifier from anywhere; static - * dependencies on such a module are treated like missing dependencies. + * registered but cannot be resolved via bare specifier from anywhere; declared + * dependencies on such a module (static or dynamic) are treated like missing + * dependencies and the dependent will not be emitted. * Each entry is one of: * - A non-empty string URL prefix, emitted as-authored. WordPress URL filters such as * `script_module_loader_src` are NOT applied to string scopes; authors are @@ -209,7 +210,12 @@ public function register( string $id, string $src, array $deps = array(), $versi if ( ! is_array( $args['scopes'] ) ) { _doing_it_wrong( __METHOD__, - __( 'The "scopes" arg must be null or an array.' ), + sprintf( + /* translators: 1: Module identifier; 2: Received type. */ + __( 'The "scopes" arg for module "%1$s" must be null or an array; received %2$s.' ), + $id, + gettype( $args['scopes'] ) + ), '7.1.0' ); } else { @@ -219,7 +225,11 @@ public function register( string $id, string $src, array $deps = array(), $versi if ( '' === $scope_entry ) { _doing_it_wrong( __METHOD__, - __( 'Empty string scope entry; entries must be non-empty.' ), + sprintf( + /* translators: %s: Module identifier. */ + __( 'A scope entry for module "%s" must be a non-empty string.' ), + $id + ), '7.1.0' ); continue; @@ -236,7 +246,11 @@ public function register( string $id, string $src, array $deps = array(), $versi } else { _doing_it_wrong( __METHOD__, - __( 'Each scope entry must be a non-empty string or [ "module_id" => non-empty string ].' ), + sprintf( + /* translators: %s: Module identifier. */ + __( 'A scope entry for module "%s" must be a non-empty string or an array with a single "module_id" key whose value is a non-empty string.' ), + $id + ), '7.1.0' ); } @@ -730,7 +744,8 @@ private function get_import_map(): array { $module_dependencies = $wp_scripts->get_data( $handle, 'module_dependencies' ); if ( is_array( $module_dependencies ) ) { - $missing_module_dependencies = array(); + $missing_module_dependencies = array(); + $unreachable_module_dependencies = array(); foreach ( $module_dependencies as $module ) { if ( is_string( $module ) ) { $id = $module; @@ -744,6 +759,11 @@ private function get_import_map(): array { if ( ! isset( $this->registered[ $id ] ) ) { $missing_module_dependencies[] = $id; + } elseif ( + isset( $this->registered[ $id ]['scopes'] ) && + array() === $this->registered[ $id ]['scopes'] + ) { + $unreachable_module_dependencies[] = $id; } else { $classic_script_module_dependencies[] = $id; } @@ -762,6 +782,20 @@ private function get_import_map(): array { '7.0.0' ); } + + if ( count( $unreachable_module_dependencies ) > 0 ) { + _doing_it_wrong( + 'WP_Scripts::add_data', + sprintf( + /* translators: 1: Script handle, 2: 'module_dependencies', 3: List of unreachable dependency IDs. */ + __( 'The script with the handle "%1$s" was enqueued with script module dependencies ("%2$s") that are registered with empty scopes and cannot be resolved via bare specifier: %3$s.' ), + $handle, + 'module_dependencies', + implode( wp_get_list_item_separator(), $unreachable_module_dependencies ) + ), + '7.1.0' + ); + } } foreach ( $wp_scripts->registered[ $handle ]->deps as $dep ) { @@ -772,11 +806,47 @@ private function get_import_map(): array { } } - // Note: the script modules in $this->queue are not included in the importmap because they get printed as scripts. + /* + * Walk the dependency graph from queue items + classic-script module dependencies. + * Queue items themselves are not added to `$ids` (they are printed as `