diff --git a/src/wp-includes/general-template.php b/src/wp-includes/general-template.php
index f16d787132452..3dbf954f16d1c 100644
--- a/src/wp-includes/general-template.php
+++ b/src/wp-includes/general-template.php
@@ -4672,9 +4672,26 @@ function paginate_links( $args = '' ) {
// Append the format placeholder to the base URL.
$pagenum_link = trailingslashit( $url_parts[0] ) . '%_%';
+ /*
+ * Ensures sites not using trailing slashes get links in the form
+ * `/page/2` rather than `/page/2/`. On these sites, linking to the
+ * URL with a trailing slash will results in a 301 redirect from the
+ * incorrect URL to the correctly formattted one. This presents an
+ * unnecessary performance hit.
+ */
+ if ( $wp_rewrite->using_permalinks() && ! $wp_rewrite->use_trailing_slashes ) {
+ $pagenum_link = untrailingslashit( $url_parts[0] );
+ } else {
+ $pagenum_link = trailingslashit( $url_parts[0] );
+ }
+ $pagenum_link .= '%_%';
+
// URL base depends on permalink settings.
$format = $wp_rewrite->using_index_permalinks() && ! strpos( $pagenum_link, 'index.php' ) ? 'index.php/' : '';
$format .= $wp_rewrite->using_permalinks() ? user_trailingslashit( $wp_rewrite->pagination_base . '/%#%', 'paged' ) : '?paged=%#%';
+ if ( $wp_rewrite->using_permalinks() && ! $wp_rewrite->use_trailing_slashes ) {
+ $format = '/' . ltrim( $format, '/' );
+ }
$defaults = array(
'base' => $pagenum_link, // http://example.com/all_posts.php%_% : %_% is replaced by format (below).
diff --git a/tests/phpunit/tests/general/paginateLinks.php b/tests/phpunit/tests/general/paginateLinks.php
index d9833c6245488..587e4639136ae 100644
--- a/tests/phpunit/tests/general/paginateLinks.php
+++ b/tests/phpunit/tests/general/paginateLinks.php
@@ -9,6 +9,39 @@ class Tests_General_PaginateLinks extends WP_UnitTestCase {
private $i18n_count = 0;
+ /**
+ * Post IDs created for shared fixtures.
+ *
+ * @var int[]
+ */
+ protected static $post_ids = array();
+
+ /**
+ * Category ID created for shared fixtures.
+ *
+ * @var int
+ */
+ protected static $category_id = 0;
+
+ /**
+ * Set up shared fixtures.
+ *
+ * @param WP_UnitTest_Factory $factory Factory instance.
+ */
+ public static function wpSetUpBeforeClass( $factory ) {
+ self::$category_id = $factory->term->create(
+ array(
+ 'taxonomy' => 'category',
+ 'name' => 'Categorized',
+ )
+ );
+
+ self::$post_ids = $factory->post->create_many( 10 );
+ foreach ( self::$post_ids as $post_id ) {
+ wp_set_post_categories( $post_id, array( self::$category_id ) );
+ }
+ }
+
public function set_up() {
parent::set_up();
@@ -383,4 +416,127 @@ public function test_custom_base_query_arg_should_be_stripped_from_current_url_b
$page_2_url = home_url() . '?foo=2';
$this->assertContains( "2", $links );
}
+
+ /**
+ * Ensures pagination links include trailing slashes when the permalink structure includes them.
+ *
+ * @ticket 61393
+ */
+ public function test_permalinks_with_trailing_slash_produce_links_with_trailing_slashes() {
+ update_option( 'posts_per_page', 2 );
+ $this->set_permalink_structure( '/%postname%/' );
+
+ $this->go_to( '/category/categorized/page/2/' );
+
+ // `current` needs to be passed as it's not picked up from the query vars set by `go_to()` above.
+ $links = paginate_links( array( 'current' => 2 ) );
+
+ $processor = new WP_HTML_Tag_Processor( $links );
+ $found_links = 0;
+ while ( $processor->next_tag( 'A' ) ) {
+ ++$found_links;
+ $href = $processor->get_attribute( 'href' );
+ $this->assertStringEndsWith( '/', $href, "Pagination links should end with a trailing slash, found: $href" );
+ }
+ $this->assertGreaterThan( 0, $found_links, 'There should be pagination links found.' );
+ }
+
+ /**
+ * Ensures pagination links do not include trailing slashes when the permalink structure doesn't include them.
+ *
+ * @ticket 61393
+ */
+ public function test_permalinks_without_trailing_slash_produce_links_without_trailing_slashes() {
+ update_option( 'posts_per_page', 2 );
+ $this->set_permalink_structure( '/%postname%' );
+
+ $this->go_to( '/category/categorized/page/2' );
+
+ // `current` needs to be passed as it's not picked up from the query vars set by `go_to()` above.
+ $links = paginate_links( array( 'current' => 2 ) );
+
+ $processor = new WP_HTML_Tag_Processor( $links );
+ $found_links = 0;
+ while ( $processor->next_tag( 'A' ) ) {
+ ++$found_links;
+ $href = $processor->get_attribute( 'href' );
+ $this->assertStringEndsNotWith( '/', $href, "Pagination links should end with a trailing slash, found: $href" );
+ }
+ $this->assertGreaterThan( 0, $found_links, 'There should be pagination links found.' );
+ }
+
+ /**
+ * Ensures the pagination links do not modify query strings (permalinks with trailing slash).
+ *
+ * @ticket 61393
+ * @ticket 63123
+ *
+ * @dataProvider data_query_strings
+ *
+ * @param string $query_string Query string.
+ * @param string $unexpected Unexpected query string.
+ */
+ public function test_permalinks_with_trailing_slash_do_not_modify_query_strings( string $query_string ) {
+ update_option( 'posts_per_page', 2 );
+ $this->set_permalink_structure( '/%postname%/' );
+
+ $this->go_to( "/page/2/?{$query_string}" );
+
+ // `current` needs to be passed as it's not picked up from the query vars set by `go_to()` above.
+ $links = paginate_links( array( 'current' => 2 ) );
+
+ $processor = new WP_HTML_Tag_Processor( $links );
+ $found_links = 0;
+ while ( $processor->next_tag( 'A' ) ) {
+ ++$found_links;
+ $href = $processor->get_attribute( 'href' );
+ $this->assertStringEndsWith( "/?{$query_string}", $href, "Pagination links should not modify the query string, found: $href" );
+ }
+ $this->assertGreaterThan( 0, $found_links, 'There should be pagination links found.' );
+ }
+
+ /**
+ * Ensures the pagination links do not modify query strings (permalinks without trailing slash).
+ *
+ * @ticket 61393
+ * @ticket 63123
+ *
+ * @dataProvider data_query_strings
+ *
+ * @param string $query_string Query string.
+ * @param string $unexpected Unexpected query string.
+ */
+ public function test_permalinks_without_trailing_slash_do_not_modify_query_strings( string $query_string ) {
+ update_option( 'posts_per_page', 2 );
+ $this->set_permalink_structure( '/%postname%' );
+
+ $this->go_to( "/page/2?{$query_string}" );
+
+ // `current` needs to be passed as it's not picked up from the query vars set by `go_to()` above.
+ $links = paginate_links( array( 'current' => 2 ) );
+
+ $processor = new WP_HTML_Tag_Processor( $links );
+ $found_links = 0;
+ while ( $processor->next_tag( 'A' ) ) {
+ ++$found_links;
+ $href = $processor->get_attribute( 'href' );
+ $this->assertStringEndsWith( "?{$query_string}", $href, "Pagination links should not modify the query string, found: $href" );
+ $this->assertStringEndsNotWith( "/?{$query_string}", $href, "Pagination links should not be slashed before the query string, found: $href" );
+ }
+ $this->assertGreaterThan( 0, $found_links, 'There should be pagination links found.' );
+ }
+
+ /**
+ * Data provider for
+ * - test_permalinks_without_trailing_slash_do_not_modify_query_strings
+ * - test_permalinks_with_trailing_slash_do_not_modify_query_strings
+ *
+ * @return array Data provider.
+ */
+ public function data_query_strings(): array {
+ return array(
+ array( 'foo=bar' ),
+ array( 'foo=bar&pen=pencil' ),
+ );
+ }
}