Skip to content

Commit 602daa6

Browse files
committed
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.
1 parent 86da535 commit 602daa6

3 files changed

Lines changed: 210 additions & 19 deletions

File tree

src/wp-includes/class-wp-script-modules.php

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,9 @@ class WP_Script_Modules {
134134
* When omitted or null, the module is public (top-level `imports`). When an array is
135135
* provided, the module is emitted under the import map's `scopes` keyed by each entry,
136136
* and is not present in top-level `imports`. An empty array means the module is
137-
* registered but cannot be resolved via bare specifier from anywhere; static
138-
* dependencies on such a module are treated like missing dependencies.
137+
* registered but cannot be resolved via bare specifier from anywhere; declared
138+
* dependencies on such a module (static or dynamic) are treated like missing
139+
* dependencies and the dependent will not be emitted.
139140
* Each entry is one of:
140141
* - A non-empty string URL prefix, emitted as-authored. WordPress URL filters such as
141142
* `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
209210
if ( ! is_array( $args['scopes'] ) ) {
210211
_doing_it_wrong(
211212
__METHOD__,
212-
__( 'The "scopes" arg must be null or an array.' ),
213+
sprintf(
214+
/* translators: 1: Module identifier; 2: Received type. */
215+
__( 'The "scopes" arg for module "%1$s" must be null or an array; received %2$s.' ),
216+
$id,
217+
gettype( $args['scopes'] )
218+
),
213219
'7.1.0'
214220
);
215221
} else {
@@ -219,7 +225,11 @@ public function register( string $id, string $src, array $deps = array(), $versi
219225
if ( '' === $scope_entry ) {
220226
_doing_it_wrong(
221227
__METHOD__,
222-
__( 'Empty string scope entry; entries must be non-empty.' ),
228+
sprintf(
229+
/* translators: %s: Module identifier. */
230+
__( 'A scope entry for module "%s" must be a non-empty string.' ),
231+
$id
232+
),
223233
'7.1.0'
224234
);
225235
continue;
@@ -236,7 +246,11 @@ public function register( string $id, string $src, array $deps = array(), $versi
236246
} else {
237247
_doing_it_wrong(
238248
__METHOD__,
239-
__( 'Each scope entry must be a non-empty string or [ "module_id" => non-empty string ].' ),
249+
sprintf(
250+
/* translators: %s: Module identifier. */
251+
__( '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.' ),
252+
$id
253+
),
240254
'7.1.0'
241255
);
242256
}
@@ -730,7 +744,8 @@ private function get_import_map(): array {
730744

731745
$module_dependencies = $wp_scripts->get_data( $handle, 'module_dependencies' );
732746
if ( is_array( $module_dependencies ) ) {
733-
$missing_module_dependencies = array();
747+
$missing_module_dependencies = array();
748+
$unreachable_module_dependencies = array();
734749
foreach ( $module_dependencies as $module ) {
735750
if ( is_string( $module ) ) {
736751
$id = $module;
@@ -744,6 +759,11 @@ private function get_import_map(): array {
744759

745760
if ( ! isset( $this->registered[ $id ] ) ) {
746761
$missing_module_dependencies[] = $id;
762+
} elseif (
763+
isset( $this->registered[ $id ]['scopes'] ) &&
764+
array() === $this->registered[ $id ]['scopes']
765+
) {
766+
$unreachable_module_dependencies[] = $id;
747767
} else {
748768
$classic_script_module_dependencies[] = $id;
749769
}
@@ -762,6 +782,20 @@ private function get_import_map(): array {
762782
'7.0.0'
763783
);
764784
}
785+
786+
if ( count( $unreachable_module_dependencies ) > 0 ) {
787+
_doing_it_wrong(
788+
'WP_Scripts::add_data',
789+
sprintf(
790+
/* translators: 1: Script handle, 2: 'module_dependencies', 3: List of unreachable dependency IDs. */
791+
__( '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.' ),
792+
$handle,
793+
'module_dependencies',
794+
implode( wp_get_list_item_separator(), $unreachable_module_dependencies )
795+
),
796+
'7.1.0'
797+
);
798+
}
765799
}
766800

767801
foreach ( $wp_scripts->registered[ $handle ]->deps as $dep ) {
@@ -772,11 +806,47 @@ private function get_import_map(): array {
772806
}
773807
}
774808

775-
// Note: the script modules in $this->queue are not included in the importmap because they get printed as scripts.
809+
/*
810+
* Walk the dependency graph from queue items + classic-script module dependencies.
811+
* Queue items themselves are not added to `$ids` (they are printed as `<script>` tags),
812+
* but they appear via dep edges of other modules — matching prior behavior of
813+
* `get_dependencies()`.
814+
*
815+
* Modules registered with empty scopes (`scopes => array()`) are not traversed:
816+
* their transitive deps are unreachable via bare specifier from any importer, so
817+
* walking through them would leak otherwise-private deps into top-level `imports`.
818+
*/
819+
$collected_dependencies = array();
820+
$id_queue = array_merge( $this->queue, $classic_script_module_dependencies );
821+
while ( ! empty( $id_queue ) ) {
822+
$current_id = array_shift( $id_queue );
823+
if ( ! isset( $this->registered[ $current_id ] ) ) {
824+
continue;
825+
}
826+
827+
// Stop at empty-scoped modules.
828+
if (
829+
isset( $this->registered[ $current_id ]['scopes'] ) &&
830+
array() === $this->registered[ $current_id ]['scopes']
831+
) {
832+
continue;
833+
}
834+
835+
foreach ( $this->registered[ $current_id ]['dependencies'] as $dependency ) {
836+
if (
837+
! isset( $collected_dependencies[ $dependency['id'] ] ) &&
838+
isset( $this->registered[ $dependency['id'] ] )
839+
) {
840+
$collected_dependencies[ $dependency['id'] ] = true;
841+
$id_queue[] = $dependency['id'];
842+
}
843+
}
844+
}
845+
776846
$ids = array_unique(
777847
array_merge(
778848
$classic_script_module_dependencies,
779-
array_keys( $this->get_dependencies( array_merge( $this->queue, $classic_script_module_dependencies ) ) )
849+
array_keys( $collected_dependencies )
780850
)
781851
);
782852

@@ -1179,6 +1249,17 @@ private function get_scope_keys( string $id ): array {
11791249
$path = preg_replace( '~[?#].*$~', '', $other_src );
11801250
$last_slash = strrpos( (string) $path, '/' );
11811251
if ( false === $last_slash ) {
1252+
_doing_it_wrong(
1253+
__METHOD__,
1254+
sprintf(
1255+
/* translators: 1: Scope module id; 2: Owning module id; 3: Resolved URL. */
1256+
__( 'Scope entry references module "%1$s" whose URL "%3$s" has no "/" path separator and cannot be reduced to a directory for module "%2$s".' ),
1257+
$other_id,
1258+
$id,
1259+
$other_src
1260+
),
1261+
'7.1.0'
1262+
);
11821263
continue;
11831264
}
11841265

src/wp-includes/script-modules.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,9 @@ function wp_script_modules(): WP_Script_Modules {
7171
* When omitted or null, the module is public. When an array is provided, the module is
7272
* emitted under the import map's `scopes` keyed by each entry, and is not present in
7373
* top-level `imports`. An empty array means the module is registered but cannot be
74-
* resolved via bare specifier from anywhere; static dependencies on such a module
75-
* are treated like missing dependencies.
74+
* resolved via bare specifier from anywhere; declared dependencies on such a
75+
* module (static or dynamic) are treated like missing dependencies and the
76+
* dependent will not be emitted.
7677
* Each entry is one of:
7778
* - A non-empty string URL prefix, emitted as-authored. WordPress URL-rewriting
7879
* hooks such as `script_module_loader_src` are NOT applied to string scopes;

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

Lines changed: 118 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2956,6 +2956,8 @@ public function test_scopes_string_exact_url_scope() {
29562956

29572957
$map = $this->get_full_import_map();
29582958
$this->assertArrayHasKey( 'https://example.com/exact.js', $map['scopes'] );
2959+
$this->assertArrayHasKey( 'private', $map['scopes']['https://example.com/exact.js'] );
2960+
$this->assertArrayNotHasKey( 'private', $map['imports'] );
29592961
}
29602962

29612963
/**
@@ -3029,7 +3031,10 @@ public function test_scopes_module_id_unregistered_drops() {
30293031

30303032
$map = $this->get_full_import_map();
30313033
$this->assertArrayHasKey( 'scopes', $map );
3034+
$this->assertCount( 1, $map['scopes'] );
30323035
$this->assertArrayHasKey( '/fallback/', $map['scopes'] );
3036+
$this->assertArrayHasKey( 'private', $map['scopes']['/fallback/'] );
3037+
$this->assertArrayNotHasKey( 'private', $map['imports'] );
30333038
}
30343039

30353040
/**
@@ -3282,16 +3287,18 @@ public function test_scopes_script_module_data_emitted_for_scoped() {
32823287
);
32833288
$this->script_modules->enqueue( 'consumer', '/consumer.js', array( 'private' ) );
32843289

3285-
add_filter(
3286-
'script_module_data_private',
3287-
static function () {
3288-
return array( 'token' => 'abc' );
3289-
}
3290-
);
3290+
$data_filter = static function () {
3291+
return array( 'token' => 'abc' );
3292+
};
3293+
add_filter( 'script_module_data_private', $data_filter );
32913294

3292-
$markup = get_echo( array( $this->script_modules, 'print_script_module_data' ) );
3293-
$this->assertStringContainsString( 'wp-script-module-data-private', $markup );
3294-
$this->assertStringContainsString( '"token":"abc"', $markup );
3295+
try {
3296+
$markup = get_echo( array( $this->script_modules, 'print_script_module_data' ) );
3297+
$this->assertStringContainsString( 'wp-script-module-data-private', $markup );
3298+
$this->assertStringContainsString( '"token":"abc"', $markup );
3299+
} finally {
3300+
remove_filter( 'script_module_data_private', $data_filter );
3301+
}
32953302
}
32963303

32973304
/**
@@ -3333,4 +3340,106 @@ public function test_scopes_preloads_include_scoped_static_dep() {
33333340
$preloads = $this->get_preloaded_script_modules();
33343341
$this->assertArrayHasKey( 'private', $preloads );
33353342
}
3343+
3344+
/**
3345+
* Empty-scoped modules do not leak their public transitive deps into top-level imports.
3346+
*
3347+
* @covers WP_Script_Modules::get_import_map
3348+
*/
3349+
public function test_scopes_empty_does_not_leak_transitive_deps_to_imports() {
3350+
// Public leaf reachable only through the empty-scoped module.
3351+
$this->script_modules->register( 'leaf', '/leaf.js' );
3352+
$this->script_modules->register(
3353+
'unreachable',
3354+
'/unreachable.js',
3355+
array( 'leaf' ),
3356+
null,
3357+
array( 'scopes' => array() )
3358+
);
3359+
$this->script_modules->enqueue( 'consumer', '/consumer.js', array( 'unreachable' ) );
3360+
3361+
$map = $this->get_full_import_map();
3362+
$this->assertArrayNotHasKey( 'leaf', $map['imports'] ?? array(), 'leaf must not appear in imports — only reachable via empty-scoped module.' );
3363+
$this->assertArrayNotHasKey( 'unreachable', $map['imports'] ?? array() );
3364+
$this->assertArrayNotHasKey( 'scopes', $map );
3365+
}
3366+
3367+
/**
3368+
* A leaf reachable both via empty-scoped and via a public path is still emitted.
3369+
*
3370+
* @covers WP_Script_Modules::get_import_map
3371+
*/
3372+
public function test_scopes_empty_does_not_hide_dep_reachable_via_public_path() {
3373+
$this->script_modules->register( 'leaf', '/leaf.js' );
3374+
$this->script_modules->register(
3375+
'unreachable',
3376+
'/unreachable.js',
3377+
array( 'leaf' ),
3378+
null,
3379+
array( 'scopes' => array() )
3380+
);
3381+
// Public path also reaches leaf.
3382+
$this->script_modules->register( 'public-mid', '/public-mid.js', array( 'leaf' ) );
3383+
$this->script_modules->enqueue( 'consumer-a', '/consumer-a.js', array( 'unreachable' ) );
3384+
$this->script_modules->enqueue( 'consumer-b', '/consumer-b.js', array( 'public-mid' ) );
3385+
3386+
$map = $this->get_full_import_map();
3387+
$this->assertArrayHasKey( 'leaf', $map['imports'] );
3388+
$this->assertArrayHasKey( 'public-mid', $map['imports'] );
3389+
}
3390+
3391+
/**
3392+
* Classic-script `module_dependencies` referencing an empty-scoped module triggers a warning.
3393+
*
3394+
* @expectedIncorrectUsage WP_Scripts::add_data
3395+
*
3396+
* @covers WP_Script_Modules::get_import_map
3397+
*/
3398+
public function test_scopes_classic_script_module_dep_on_empty_scoped_warns() {
3399+
$this->script_modules->register(
3400+
'unreachable',
3401+
'/unreachable.js',
3402+
array(),
3403+
null,
3404+
array( 'scopes' => array() )
3405+
);
3406+
3407+
wp_enqueue_script(
3408+
'classic',
3409+
'/classic.js',
3410+
array(),
3411+
null,
3412+
array( 'module_dependencies' => array( 'unreachable' ) )
3413+
);
3414+
3415+
// Trigger import-map computation.
3416+
$map = $this->get_full_import_map();
3417+
$this->assertArrayNotHasKey( 'unreachable', $map['imports'] ?? array() );
3418+
$this->assertArrayNotHasKey( 'scopes', $map );
3419+
}
3420+
3421+
/**
3422+
* `module_id` resolution to a URL with no slash drops with a warning.
3423+
*
3424+
* @expectedIncorrectUsage WP_Script_Modules::get_scope_keys
3425+
*
3426+
* @covers WP_Script_Modules::get_import_map
3427+
*/
3428+
public function test_scopes_module_id_no_slash_drops_with_warning() {
3429+
// A module whose src has no slash (after version stripping). `null` version avoids ?ver.
3430+
$this->script_modules->register( 'oddurl', 'oddurl', array(), null );
3431+
$this->script_modules->register(
3432+
'private',
3433+
'/private.js',
3434+
array(),
3435+
null,
3436+
array( 'scopes' => array( array( 'module_id' => 'oddurl' ) ) )
3437+
);
3438+
$this->script_modules->enqueue( 'consumer', '/consumer.js', array( 'private' ) );
3439+
3440+
$map = $this->get_full_import_map();
3441+
// No scope key derivable; private appears nowhere; map is not emitted.
3442+
$this->assertArrayNotHasKey( 'scopes', $map );
3443+
$this->assertArrayNotHasKey( 'private', $map['imports'] ?? array() );
3444+
}
33363445
}

0 commit comments

Comments
 (0)