Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 45 additions & 0 deletions src/wp-includes/class-wp-script-modules.php
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,8 @@ public function add_hooks() {
add_action( 'admin_print_footer_scripts', array( $this, 'print_script_module_data' ) );
add_action( 'wp_footer', array( $this, 'print_a11y_script_module_html' ), 20 );
add_action( 'admin_print_footer_scripts', array( $this, 'print_a11y_script_module_html' ), 20 );

add_filter( 'wp_resource_hints', array( $this, 'filter_resource_hints' ), 10, 2 );
}

/**
Expand Down Expand Up @@ -1023,4 +1025,47 @@ public function print_a11y_script_module_html() {
. '<div id="a11y-speak-polite" class="a11y-speak-region" aria-live="polite" aria-relevant="additions text" aria-atomic="true"></div>'
. '</div>';
}

/**
* Filters resource hints to add DNS prefetch for external script module hosts.
*
* Hooks into the {@see 'wp_resource_hints'} filter to include DNS prefetch hints
* for all external hosts used by enqueued script modules and their dependencies,
* including dynamic dependencies which do not receive modulepreload hints.
*
* @since 7.1.0
*
* @param array $urls Array of resources and their attributes, or URLs to print
* for resource hints.
* @param string $relation_type The relation type the URLs are printed for.
* @return array Filtered array of resource URLs.
*/
public function filter_resource_hints( array $urls, string $relation_type ): array {
if ( 'dns-prefetch' !== $relation_type ) {
return $urls;
}

// Collect all queued module IDs and all their dependencies (static and dynamic).
$all_ids = array_unique(
array_merge( $this->queue, array_keys( $this->get_dependencies( $this->queue ) ) )
);

foreach ( $all_ids as $id ) {
if ( ! isset( $this->registered[ $id ] ) ) {
continue;
}

$src = $this->registered[ $id ]['src'];
$parsed = wp_parse_url( $src );

if (
! empty( $parsed['host'] ) &&
$parsed['host'] !== $_SERVER['SERVER_NAME']
) {
$urls[] = $src;
}
}

return $urls;
}
}
165 changes: 165 additions & 0 deletions tests/phpunit/tests/script-modules/wpScriptModulesResourceHints.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<?php
/**
* Unit tests covering WP_Script_Modules::filter_resource_hints().
*
* @package WordPress
* @subpackage Script Modules
*
* @since 7.1.0
*
* @group script-modules
* @ticket 62709
* @covers WP_Script_Modules::filter_resource_hints
*/
class Tests_Script_Modules_WpScriptModules_ResourceHints extends WP_UnitTestCase {

protected WP_Script_Modules $original_script_modules;
protected WP_Script_Modules $script_modules;

public function set_up() {
global $wp_script_modules;
parent::set_up();
$this->original_script_modules = $wp_script_modules;
$wp_script_modules = null;
$this->script_modules = wp_script_modules();
}

public function tear_down() {
global $wp_script_modules;
$wp_script_modules = $this->original_script_modules;
parent::tear_down();
}

/**
* Tests that a DNS prefetch hint is added for an enqueued module with an external src.
*/
public function test_dns_prefetch_for_enqueued_script_module() {
$this->script_modules->enqueue( 'my-module', 'https://cdn.example.com/module.js' );

$hints = $this->script_modules->filter_resource_hints( array(), 'dns-prefetch' );
$hosts = array_map( fn( $url ) => wp_parse_url( $url, PHP_URL_HOST ), $hints );

$this->assertContains( 'cdn.example.com', $hosts );
}

/**
* Tests that a DNS prefetch hint is added for a static external dependency.
*/
public function test_dns_prefetch_for_static_dependency() {
$this->script_modules->register( 'dep-module', 'https://cdn.example.com/dep.js' );
$this->script_modules->enqueue( 'my-module', '/local/module.js', array( 'dep-module' ) );

$hints = $this->script_modules->filter_resource_hints( array(), 'dns-prefetch' );
$hosts = array_map( fn( $url ) => wp_parse_url( $url, PHP_URL_HOST ), $hints );

$this->assertContains( 'cdn.example.com', $hosts );
}

/**
* Tests that a DNS prefetch hint is added for a dynamic external dependency.
*
* Dynamic dependencies never receive modulepreload hints, so dns-prefetch is
* particularly valuable for them.
*/
public function test_dns_prefetch_for_dynamic_dependency() {
$this->script_modules->register( 'dep-module', 'https://cdn.example.com/dep.js' );
$this->script_modules->enqueue(
'my-module',
'/local/module.js',
array(
array(
'id' => 'dep-module',
'import' => 'dynamic',
),
)
);

$hints = $this->script_modules->filter_resource_hints( array(), 'dns-prefetch' );
$hosts = array_map( fn( $url ) => wp_parse_url( $url, PHP_URL_HOST ), $hints );

$this->assertContains( 'cdn.example.com', $hosts );
}

/**
* Tests that modules hosted on the same site are excluded from DNS prefetch hints.
*/
public function test_dns_prefetch_excludes_same_site_modules() {
$server_name = $_SERVER['SERVER_NAME'];
$this->script_modules->enqueue( 'local-module', "https://{$server_name}/module.js" );

$hints = $this->script_modules->filter_resource_hints( array(), 'dns-prefetch' );

$this->assertEmpty( $hints );
}

/**
* Tests that modules with relative (root-relative) src paths are excluded from DNS prefetch hints.
*/
public function test_dns_prefetch_excludes_relative_src_modules() {
$this->script_modules->enqueue( 'local-module', '/wp-includes/js/my-module.js' );

$hints = $this->script_modules->filter_resource_hints( array(), 'dns-prefetch' );

$this->assertEmpty( $hints );
}

/**
* Tests that registered-but-not-enqueued modules do not produce DNS prefetch hints.
*/
public function test_dns_prefetch_excludes_registered_only_modules() {
$this->script_modules->register( 'my-module', 'https://cdn.example.com/module.js' );
// Intentionally not enqueued.

$hints = $this->script_modules->filter_resource_hints( array(), 'dns-prefetch' );

$this->assertEmpty( $hints );
}

/**
* Tests that non-dns-prefetch relation types are not modified.
*/
public function test_other_relation_types_are_not_modified() {
$this->script_modules->enqueue( 'my-module', 'https://cdn.example.com/module.js' );

$original = array( 'https://other.example.com' );

foreach ( array( 'preconnect', 'prefetch', 'prerender' ) as $relation_type ) {
$hints = $this->script_modules->filter_resource_hints( $original, $relation_type );
$this->assertSame( $original, $hints, "Relation type '{$relation_type}' should not be modified." );
}
}

/**
* Tests that duplicate external hosts only produce a single DNS prefetch hint entry.
*/
public function test_dns_prefetch_deduplicates_hosts_via_wp_resource_hints() {
// Both modules share the same CDN host.
$this->script_modules->enqueue( 'module-a', 'https://cdn.example.com/module-a.js' );
$this->script_modules->enqueue( 'module-b', 'https://cdn.example.com/module-b.js' );

// filter_resource_hints itself may include both raw URLs; deduplication by host
// is handled by wp_resource_hints(). Verify via the full pipeline.
add_filter( 'wp_resource_hints', array( $this->script_modules, 'filter_resource_hints' ), 10, 2 );
$output = get_echo( 'wp_resource_hints' );
remove_filter( 'wp_resource_hints', array( $this->script_modules, 'filter_resource_hints' ) );

$this->assertSame(
1,
substr_count( $output, '//cdn.example.com' ),
'The same CDN host should only appear once in the output.'
);
}

/**
* Tests that the filter_resource_hints method integrates correctly with wp_resource_hints().
*/
public function test_integration_with_wp_resource_hints() {
$this->script_modules->enqueue( 'my-module', 'https://cdn.example.com/module.js' );

add_filter( 'wp_resource_hints', array( $this->script_modules, 'filter_resource_hints' ), 10, 2 );
$output = get_echo( 'wp_resource_hints' );
remove_filter( 'wp_resource_hints', array( $this->script_modules, 'filter_resource_hints' ) );

$this->assertStringContainsString( "<link rel='dns-prefetch' href='//cdn.example.com' />", $output );
}
}
Loading