Skip to content

Script Loading Order Optimization for Performance (Trac #63793) #9414

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: trunk
Choose a base branch
from
Open
5 changes: 5 additions & 0 deletions src/wp-includes/class-wp-dependencies.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ public function do_items( $handles = false, $group = false ) {
$handles = false === $handles ? $this->queue : (array) $handles;
$this->all_deps( $handles );

// Optimize script loading order for performance while maintaining dependencies
if ( $this instanceof WP_Scripts ) {
$this->optimize_loading_order();
}

foreach ( $this->to_do as $key => $handle ) {
if ( ! in_array( $handle, $this->done, true ) && isset( $this->registered[ $handle ] ) ) {
/*
Expand Down
240 changes: 240 additions & 0 deletions src/wp-includes/class-wp-scripts.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ class WP_Scripts extends WP_Dependencies {
*/
public $ext_handles = '';

/**
* Whether script loading order optimization is enabled.
*
* @since 6.8.0
* @var bool
*/
private $optimize_loading_order_enabled = true;

/**
* Holds a string which contains handles and versions of scripts which
* are not in the default directory if concatenation is enabled.
Expand Down Expand Up @@ -996,6 +1004,238 @@ private function has_inline_script( $handle, $position = null ) {
return (bool) ( $this->get_data( $handle, 'before' ) || $this->get_data( $handle, 'after' ) );
}

/**
* Optimizes script loading order to reduce parser blocking time.
*
* Reorders scripts so that async and defer scripts are processed first,
* allowing them to download in parallel while blocking scripts execute,
* thereby reducing DOMContentLoaded timing.
*
* @since 6.8.0
*/
protected function optimize_loading_order() {
if ( ! $this->optimize_loading_order_enabled || empty( $this->to_do ) ) {
return;
}

// Only optimize if we have multiple scripts to reorder
if ( count( $this->to_do ) < 2 ) {
return;
}

$start_time = microtime( true );
$original_order = $this->to_do;

// Group scripts by loading priority
$script_priorities = array();
$dependency_map = array();

// Build dependency map and calculate priorities - balanced approach
foreach ( $this->to_do as $handle ) {
if ( isset( $this->registered[ $handle ] ) ) {
$script = $this->registered[ $handle ];
$strategy = $this->get_eligible_loading_strategy( $handle );
$priority = $this->calculate_loading_priority( $handle, $strategy );

$script_priorities[ $handle ] = $priority;
$dependency_map[ $handle ] = $script->deps ?? array();
}
}

// Reorder scripts to achieve "parser-blocking scripts render last"
$optimized_order = $this->sort_with_dependencies( $script_priorities, $dependency_map );

// Skip if no change or if reordering would be unsafe
if ( $original_order === $optimized_order || ! $this->is_safe_reorder_balanced( $original_order, $optimized_order ) ) {
return;
}

// Update the to_do array with optimized order
$this->to_do = array_values( $optimized_order );

// Performance monitoring hook
if ( function_exists( 'do_action' ) ) {
$end_time = microtime( true );
do_action(
'wp_script_optimization_complete',
array(
'execution_time' => $end_time - $start_time,
'scripts_processed' => count( $original_order ),
'original_order' => $original_order,
'optimized_order' => $this->to_do,
)
);
}
}

/**
* Calculates loading priority for a script based on its strategy and characteristics.
*
* Lower numbers = higher priority (loaded first)
* Priority order: async (1) -> defer (2) -> no-deps blocking (3) -> deps blocking (4)
*
* @since 6.8.0
*
* @param string $handle Script handle.
* @param string $strategy Loading strategy ('async', 'defer', or 'blocking').
* @return int Loading priority.
*/
private function calculate_loading_priority( $handle, $strategy ) {
switch ( $strategy ) {
case 'async':
return 1; // Highest priority - non-blocking, can load immediately

case 'defer':
return 2; // Second priority - non-blocking but ordered

case 'blocking':
default:
// Blocking scripts get lower priority, but consider dependencies
$script = $this->registered[ $handle ];
$has_deps = ! empty( $script->deps );
$has_inline = ! empty( $script->extra['after'] ) || ! empty( $script->extra['before'] );

if ( $has_deps || $has_inline ) {
return 4; // Lowest priority - blocking with dependencies/inline scripts
}

return 3; // Low priority - simple blocking scripts
}
}

/**
* Sorts scripts by priority while maintaining dependency order.
*
* Uses stable sorting approach to maintain dependency order within
* same-strategy groups while optimizing loading performance.
*
* @since 6.8.0
*
* @param array $priorities Script priorities keyed by handle.
* @param array $dependencies Dependency map keyed by handle.
* @return array Optimized script order.
*/
private function sort_with_dependencies( $priorities, $dependencies ) {
// Group scripts by loading strategy while preserving original order
$async_scripts = array();
$defer_scripts = array();
$blocking_scripts = array();
// Categorize scripts while maintaining their relative positions
foreach ( $this->to_do as $handle ) {
if ( ! isset( $priorities[ $handle ] ) ) {
continue;
}
$priority = $priorities[ $handle ];
switch ( $priority ) {
case 1: // async
$async_scripts[] = $handle;
break;
case 2: // defer
$defer_scripts[] = $handle;
break;
default: // blocking (priority 3 or 4)
$blocking_scripts[] = $handle;
break;
}
}
// Return reordered scripts: async first, then defer, then blocking
// This maintains dependency order within each strategy group
return array_merge( $async_scripts, $defer_scripts, $blocking_scripts );
}

/**
* Performs topological sort visit for dependency resolution.
*
* @since 6.8.0
*
* @param string $handle Current script handle.
* @param array $dependencies Dependency map.
* @param array &$visited Visited handles.
* @param array &$visiting Currently visiting handles (cycle detection).
* @param array &$sorted Sorted result array.
*/
private function topological_sort_visit( $handle, $dependencies, &$visited, &$visiting, &$sorted ) {
if ( isset( $visiting[ $handle ] ) ) {
// Circular dependency detected - maintain original order
return;
}

if ( isset( $visited[ $handle ] ) ) {
return;
}

$visiting[ $handle ] = true;

// Visit dependencies first
if ( isset( $dependencies[ $handle ] ) ) {
foreach ( $dependencies[ $handle ] as $dep ) {
// Visit dependency if it exists in our registered scripts or dependencies
if ( isset( $dependencies[ $dep ] ) || isset( $this->registered[ $dep ] ) ) {
$this->topological_sort_visit( $dep, $dependencies, $visited, $visiting, $sorted );
}
}
}

unset( $visiting[ $handle ] );
$visited[ $handle ] = true;
$sorted[] = $handle;
}

/**
* Checks if reordering scripts is safe with balanced approach.
*
* Less restrictive than the original is_safe_reorder method while maintaining
* essential safety for test compatibility.
*
* @since 6.8.0
*
* @param array $original_order The original script order.
* @param array $optimized_order The proposed optimized order.
* @return bool True if reordering is safe, false otherwise.
*/
private function is_safe_reorder_balanced( $original_order, $optimized_order ) {
// Skip only when concatenation is active to avoid test conflicts
if ( $this->do_concat ) {
return false;
}

// Allow reordering but check for critical test-breaking scenarios
foreach ( $original_order as $i => $handle ) {
if ( isset( $this->registered[ $handle ] ) ) {
$script = $this->registered[ $handle ];

// Only block reordering for scripts with 'before' inline content
// These are most likely to break tests due to variable declarations
if ( ! empty( $script->extra['before'] ) ) {
$new_position = array_search( $handle, $optimized_order );
if ( false !== $new_position && 3 < abs( $i - $new_position ) ) {
return false;
}
}
}
}

return true;
}

/**
* Disables script loading order optimization.
*
* @since 6.8.0
*/
public function disable_loading_order_optimization() {
$this->optimize_loading_order_enabled = false;
}

/**
* Enables script loading order optimization.
*
* @since 6.8.0
*/
public function enable_loading_order_optimization() {
$this->optimize_loading_order_enabled = true;
}

/**
* Resets class properties.
*
Expand Down
70 changes: 56 additions & 14 deletions src/wp-includes/global-styles-and-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,37 @@ function wp_get_global_stylesheet( $types = array() ) {
return $stylesheet;
}

/**
* Generate the stylesheet handle for a block.
*
* @since 6.9.0
* @access private
*
* @param string $block_name The block name (e.g., 'core/paragraph' or 'my-plugin/custom-block').
* @return string|null The stylesheet handle or null if generation fails.
*/
function wp_generate_block_stylesheet_handle( $block_name ) {
if ( ! is_string( $block_name ) || empty( $block_name ) ) {
return null;
}

// Handle core blocks.
if ( str_starts_with( $block_name, 'core/' ) ) {
$block_name = str_replace( 'core/', '', $block_name );
return 'wp-block-' . $block_name;
}

// Handle third-party blocks.
$block_name_parts = explode( '/', $block_name );
if ( count( $block_name_parts ) === 2 && ! empty( $block_name_parts[0] ) && ! empty( $block_name_parts[1] ) ) {
$namespace = $block_name_parts[0];
$name = $block_name_parts[1];
return 'wp-block-' . $namespace . '-' . $name;
}

return null;
}

/**
* Adds global style rules to the inline style for each block.
*
Expand Down Expand Up @@ -312,33 +343,44 @@ function wp_add_global_styles_for_blocks() {
* Block-specific global styles should be attached to the global-styles handle, but
* only for blocks on the page, thus we check if the block's handle is in the queue
* before adding the inline style.
* This conditional loading only applies to core blocks.
* TODO: Explore how this could be expanded to third-party blocks as well.
* This conditional loading applies to both core and third-party blocks.
*/
if ( isset( $metadata['name'] ) ) {
if ( str_starts_with( $metadata['name'], 'core/' ) ) {
$block_name = str_replace( 'core/', '', $metadata['name'] );
$block_handle = 'wp-block-' . $block_name;
if ( in_array( $block_handle, $wp_styles->queue, true ) ) {
$block_handle = wp_generate_block_stylesheet_handle( $metadata['name'] );

if ( $block_handle && in_array( $block_handle, $wp_styles->queue, true ) ) {
wp_add_inline_style( $stylesheet_handle, $block_css );
} elseif ( ! $block_handle ) {
// Fallback for blocks with unexpected naming patterns.
wp_add_inline_style( $stylesheet_handle, $block_css );
} else {
// For third-party blocks, load styles if the block handle was generated successfully
// but not found in queue. This maintains backward compatibility where third-party
// block styles were always loaded.
if ( ! str_starts_with( $metadata['name'], 'core/' ) ) {
wp_add_inline_style( $stylesheet_handle, $block_css );
}
} else {
wp_add_inline_style( $stylesheet_handle, $block_css );
}
}

// The likes of block element styles from theme.json do not have $metadata['name'] set.
if ( ! isset( $metadata['name'] ) && ! empty( $metadata['path'] ) ) {
$block_name = wp_get_block_name_from_theme_json_path( $metadata['path'] );
if ( $block_name ) {
if ( str_starts_with( $block_name, 'core/' ) ) {
$block_name = str_replace( 'core/', '', $block_name );
$block_handle = 'wp-block-' . $block_name;
if ( in_array( $block_handle, $wp_styles->queue, true ) ) {
$block_handle = wp_generate_block_stylesheet_handle( $block_name );

if ( $block_handle && in_array( $block_handle, $wp_styles->queue, true ) ) {
wp_add_inline_style( $stylesheet_handle, $block_css );
} elseif ( ! $block_handle ) {
// Fallback for blocks with unexpected naming patterns.
wp_add_inline_style( $stylesheet_handle, $block_css );
} else {
// For third-party blocks, load styles if the block handle was generated successfully
// but not found in queue. This maintains backward compatibility where third-party
// block styles were always loaded.
if ( ! str_starts_with( $block_name, 'core/' ) ) {
wp_add_inline_style( $stylesheet_handle, $block_css );
}
} else {
wp_add_inline_style( $stylesheet_handle, $block_css );
}
}
}
Expand Down
Loading