diff --git a/src/wp-includes/class-wp-scripts.php b/src/wp-includes/class-wp-scripts.php index 486dca3009489..b098de7e20e56 100644 --- a/src/wp-includes/class-wp-scripts.php +++ b/src/wp-includes/class-wp-scripts.php @@ -417,19 +417,32 @@ public function do_item( $handle, $group = false ) { $src = $this->base_url . $src; } - $query_args = array(); + $ver_to_add = ''; if ( empty( $obj->ver ) && null !== $obj->ver && is_string( $this->default_version ) ) { - $query_args['ver'] = $this->default_version; + $ver_to_add = $this->default_version; } elseif ( is_scalar( $obj->ver ) ) { - $query_args['ver'] = (string) $obj->ver; + $ver_to_add = (string) $obj->ver; } - if ( isset( $this->args[ $handle ] ) ) { - parse_str( $this->args[ $handle ], $parsed_args ); - if ( $parsed_args ) { - $query_args = array_merge( $query_args, $parsed_args ); + + $added_args = (string) ( $this->args[ $handle ] ?? '' ); + + if ( '' !== $ver_to_add || '' !== $added_args ) { + $fragment = strstr( $src, '#' ); + if ( false !== $fragment ) { + $src = substr( $src, 0, -strlen( $fragment ) ); + } + + if ( '' !== $ver_to_add ) { + $src .= ( str_contains( $src, '?' ) ? '&' : '?' ) . 'ver=' . rawurlencode( $ver_to_add ); + } + if ( '' !== $added_args ) { + $src .= ( str_contains( $src, '?' ) ? '&' : '?' ) . $added_args; + } + + if ( false !== $fragment ) { + $src .= $fragment; } } - $src = add_query_arg( rawurlencode_deep( $query_args ), $src ); /** This filter is documented in wp-includes/class-wp-scripts.php */ $src = esc_url_raw( apply_filters( 'script_loader_src', $src, $handle ) ); diff --git a/src/wp-includes/class-wp-styles.php b/src/wp-includes/class-wp-styles.php index 53437fe23b1e3..1edaeedb2660b 100644 --- a/src/wp-includes/class-wp-styles.php +++ b/src/wp-includes/class-wp-styles.php @@ -407,19 +407,32 @@ public function _css_href( $src, $ver, $handle ) { $src = $this->base_url . $src; } - $query_args = array(); + $ver_to_add = ''; if ( empty( $ver ) && null !== $ver && is_string( $this->default_version ) ) { - $query_args['ver'] = $this->default_version; + $ver_to_add = $this->default_version; } elseif ( is_scalar( $ver ) ) { - $query_args['ver'] = (string) $ver; + $ver_to_add = (string) $ver; } - if ( isset( $this->args[ $handle ] ) ) { - parse_str( $this->args[ $handle ], $parsed_args ); - if ( $parsed_args ) { - $query_args = array_merge( $query_args, $parsed_args ); + + $added_args = (string) ( $this->args[ $handle ] ?? '' ); + + if ( '' !== $ver_to_add || '' !== $added_args ) { + $fragment = strstr( $src, '#' ); + if ( false !== $fragment ) { + $src = substr( $src, 0, -strlen( $fragment ) ); + } + + if ( '' !== $ver_to_add ) { + $src .= ( str_contains( $src, '?' ) ? '&' : '?' ) . 'ver=' . rawurlencode( $ver_to_add ); + } + if ( '' !== $added_args ) { + $src .= ( str_contains( $src, '?' ) ? '&' : '?' ) . $added_args; + } + + if ( false !== $fragment ) { + $src .= $fragment; } } - $src = add_query_arg( rawurlencode_deep( $query_args ), $src ); /** * Filters an enqueued style's fully-qualified URL. diff --git a/tests/phpunit/tests/dependencies/scripts.php b/tests/phpunit/tests/dependencies/scripts.php index 6050983cc5f5e..5f1c30fe4cf47 100644 --- a/tests/phpunit/tests/dependencies/scripts.php +++ b/tests/phpunit/tests/dependencies/scripts.php @@ -631,7 +631,7 @@ public function data_provider_to_test_various_strategy_dependency_chains() { scriptEventLog.push( "blocking-not-async-without-dependency: before inline" ) //# sourceURL=blocking-not-async-without-dependency-js-before - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + HTML , ), @@ -1031,7 +1031,7 @@ public function data_provider_to_test_various_strategy_dependency_chains() { $this->add_test_inline_script( $handle, 'after' ); }, 'expected_markup' => << + - - + + + - + - - + + + HTML , ), @@ -4192,6 +4192,71 @@ public function test_varying_versions_added_to_handle_args_registered_then_enque $this->assertEqualHTML( $expected, $markup, '', 'Expected equal snapshot for wp_print_scripts() with version ' . var_export( $version, true ) . ":\n$markup" ); } + /** + * Tests that duplicate query vars and fragments are preserved in scripts. + * + * @ticket 64372 + * + * @dataProvider data_duplicate_query_vars_and_fragments_preserved_in_scripts + * + * @param string $src The script's source URL. + * @param string|bool|null $ver The script's version. + * @param string $expected_url The expected URL. + * @param string $handle Optional. The script's registered handle. Default 'test-script'. + */ + public function test_duplicate_query_vars_and_fragments_preserved_in_scripts( string $src, $ver, string $expected_url, string $handle = 'test-script' ): void { + wp_enqueue_script( $handle, $src, array(), $ver ); + $output = get_echo( 'wp_print_scripts' ); + $processor = new WP_HTML_Tag_Processor( $output ); + + $this->assertTrue( $processor->next_tag( 'script' ) ); + $this->assertSame( $expected_url, $processor->get_attribute( 'src' ) ); + } + + /** + * Data provider for test_duplicate_query_vars_and_fragments_preserved_in_scripts. + * + * @return array Data provider. + */ + public function data_duplicate_query_vars_and_fragments_preserved_in_scripts(): array { + $ver = get_bloginfo( 'version' ); + + return array( + 'duplicate query vars' => array( + 'src' => 'https://example.com/script.js?arg=1&arg=2', + 'ver' => '1.0', + 'expected_url' => 'https://example.com/script.js?arg=1&arg=2&ver=1.0', + ), + 'duplicate query vars, null version' => array( + 'src' => 'https://example.com/script.js?arg=1&arg=2', + 'ver' => null, + 'expected_url' => 'https://example.com/script.js?arg=1&arg=2', + ), + 'duplicate query vars, false version' => array( + 'src' => 'https://example.com/script.js?arg=1&arg=2', + 'ver' => false, + 'expected_url' => "https://example.com/script.js?arg=1&arg=2&ver=$ver", + ), + 'duplicate query vars in handle' => array( + 'src' => 'https://example.com/test-script.js', + 'ver' => '1.0', + 'expected_url' => 'https://example.com/test-script.js?ver=1.0&a=1&a=2', + 'handle' => 'test-script?a=1&a=2', + ), + 'duplicate query vars and fragments' => array( + 'src' => 'https://example.com/script.js?arg=1&arg=2#anchor', + 'ver' => '1.0', + 'expected_url' => 'https://example.com/script.js?arg=1&arg=2&ver=1.0#anchor', + ), + 'zero query var in handle' => array( + 'src' => 'https://example.com/test-script.js', + 'ver' => '1.0', + 'expected_url' => 'https://example.com/test-script.js?ver=1.0&0', + 'handle' => 'test-script?0', + ), + ); + } + /** * Data provider for: * - test_varying_versions_added_to_handle_args_enqueued_scripts diff --git a/tests/phpunit/tests/dependencies/styles.php b/tests/phpunit/tests/dependencies/styles.php index bbaf9432d8df0..de6fefb94b5bb 100644 --- a/tests/phpunit/tests/dependencies/styles.php +++ b/tests/phpunit/tests/dependencies/styles.php @@ -961,6 +961,71 @@ public function test_varying_versions_added_to_handle_args_registered_then_enque $this->assertEqualHTML( $expected, $markup, '', 'Expected equal snapshot for wp_print_styles() with version ' . var_export( $version, true ) . ":\n$markup" ); } + /** + * Tests that duplicate query vars and fragments are preserved in styles. + * + * @ticket 64372 + * + * @dataProvider data_duplicate_query_vars_and_fragments_preserved_in_styles + * + * @param string $src The stylesheet's source URL. + * @param string|bool|null $ver The style's version. + * @param string $expected_url The expected URL. + * @param string $handle Optional. The style's registered handle. Default 'test-style'. + */ + public function test_duplicate_query_vars_and_fragments_preserved_in_styles( string $src, $ver, string $expected_url, string $handle = 'test-style' ): void { + wp_enqueue_style( $handle, $src, array(), $ver ); + $output = get_echo( 'wp_print_styles' ); + $processor = new WP_HTML_Tag_Processor( $output ); + + $this->assertTrue( $processor->next_tag( 'link' ) ); + $this->assertSame( $expected_url, $processor->get_attribute( 'href' ) ); + } + + /** + * Data provider for test_duplicate_query_vars_and_fragments_preserved_in_styles. + * + * @return array Data provider. + */ + public function data_duplicate_query_vars_and_fragments_preserved_in_styles(): array { + $ver = get_bloginfo( 'version' ); + + return array( + 'duplicate query vars' => array( + 'src' => 'https://fonts.googleapis.com/css2?family=Figtree:wght@300;400;500;600;700&family=Montserrat:wght@700&display=swap', + 'ver' => '1.0', + 'expected_url' => 'https://fonts.googleapis.com/css2?family=Figtree:wght@300;400;500;600;700&family=Montserrat:wght@700&display=swap&ver=1.0', + ), + 'duplicate query vars, null version' => array( + 'src' => 'https://fonts.googleapis.com/css2?family=Figtree:wght@300;400;500;600;700&family=Montserrat:wght@700&display=swap', + 'ver' => null, + 'expected_url' => 'https://fonts.googleapis.com/css2?family=Figtree:wght@300;400;500;600;700&family=Montserrat:wght@700&display=swap', + ), + 'duplicate query vars, false version' => array( + 'src' => 'https://fonts.googleapis.com/css2?family=Figtree:wght@300;400;500;600;700&family=Montserrat:wght@700&display=swap', + 'ver' => false, + 'expected_url' => "https://fonts.googleapis.com/css2?family=Figtree:wght@300;400;500;600;700&family=Montserrat:wght@700&display=swap&ver=$ver", + ), + 'duplicate query vars in handle' => array( + 'src' => 'https://example.com/test-style.css', + 'ver' => '1.0', + 'expected_url' => 'https://example.com/test-style.css?ver=1.0&a=1&a=2', + 'handle' => 'test-style?a=1&a=2', + ), + 'duplicate query vars and fragments' => array( + 'src' => 'https://example.com/style.css?arg=1&arg=2#anchor', + 'ver' => '1.0', + 'expected_url' => 'https://example.com/style.css?arg=1&arg=2&ver=1.0#anchor', + ), + 'zero query var in handle' => array( + 'src' => 'https://example.com/test-style.css', + 'ver' => '1.0', + 'expected_url' => 'https://example.com/test-style.css?ver=1.0&0', + 'handle' => 'test-style?0', + ), + ); + } + /** * Data provider for: * - test_varying_versions_added_to_handle_args_enqueued_styles