Commit 6bc0a18b971 for woocommerce

commit 6bc0a18b9717df55c3c8486984d0cc6a31e5d82d
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Fri Oct 16 13:58:17 2026 +0300

    Fix duplicate Inter or Cardo in the font picker in Coming Soon mode (#69670)

    * fix(coming-soon): Skip bundled fonts the theme already provides

    In Coming Soon mode, the wp_theme_json_data_theme filter adds the
    bundled Inter and Cardo presets unless a theme preset has the same
    name. name is optional in theme.json, so a theme that declares the
    exact same font without a name got the bundled preset appended, and
    the editor font picker listed that font twice.

    Also treat a bundled font as present when a theme preset has the same
    fontFamily and slug and ships its own fontFace. Coming Soon patterns
    and saved templates reference the inter and cardo slugs, so a match
    under another slug, or one without font files, still gets the bundled
    preset. The name check stays: about 250 wordpress.org themes, such as
    ColorMag, name a preset "Inter" with a different CSS value, and
    matching on fontFamily alone would add a second Inter to their picker.

    Refs #49890

    * chore(changelog): Add entry for the Coming Soon font duplicate fix

    Refs #49890

    * fix: Fix Coming Soon font preset matching

    Theme presets can spell Inter with optional quotes or declare font faces
    without usable sources. Literal family comparison adds a duplicate in
    the first case; checking only for fontFace omits the bundled file in the
    second.

    Normalize quotes around the first family while preserving fallback
    semantics. Require a face with source data WordPress accepts, retaining
    the existing name and slug checks. Update fixtures with real source
    shapes and add six focused regression cases.

    Refs #49890

    * fix: Match Coming Soon fonts by primary family

    An unnamed Inter preset can have a different fallback list while still
    providing the font required by Coming Soon. Comparing the whole list
    adds a duplicate preset despite the matching slug and font source.

    Compare the primary family after quote normalization and preserve the
    complete theme preset. Retain the existing name, slug, and source checks.
    Cover an additional fallback family and update the existing fallback
    expectation to reflect the broader match.

    Refs #49890

    * fix: Normalize Coming Soon font family comparison

    The family-and-slug match compares a theme preset's primary font
    family with the bundled Inter and Cardo families. The regex that
    removed quotes ran before trimming, so a value with leading whitespace
    such as ` 'Inter', sans-serif` kept its quotes and failed to match.
    The comparison was also case-sensitive, while CSS family names are
    not, so `inter, sans-serif` failed too. Either miss added a second
    preset with the same font.

    Trim the primary family before removing quotes, using string
    functions in place of the regex, and compare families
    case-insensitively. Cover both cases in the data provider.

    Refs #49890

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * fix: Avoid duplicate Coming Soon font slugs

    When a theme preset already used the bundled `inter` or `cardo` slug
    but did not match by name, or by family with usable font files, the
    bundled preset was appended under the same slug. The font picker then
    listed the font twice, and the later preset set the
    `--wp--preset--font-family--*` variable, so a theme's own `cardo`
    preset was overridden by WooCommerce's Cardo across the site. The
    tests expected this duplicate.

    Never append a bundled preset whose slug the theme already uses. When
    that preset is the same font without usable files, append the bundled
    font faces to it so the font still loads; otherwise keep the theme's
    preset as is. Move the family comparison into a helper now that two
    places use it, and cover the merge in its own test.

    Refs #49890

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * refactor: Check only that Coming Soon theme fonts declare files

    The family-and-slug match skips the bundled font when the theme preset
    has usable font files. The helper behind it copied part of WordPress
    core's private font face validation: it dropped a face when any single
    source was empty, as WP_Font_Face::validate_font_face_declarations()
    does. Core exposes no public way to run that validation without
    printing CSS or resolving theme.json again, so the copy would have to
    track core's rules, and other rules such as the font-weight check were
    already missing.

    The check only needs to know whether the theme declares font files for
    the preset. A malformed face is the theme's bug, and WordPress reports
    it itself. Reduce the helper to "at least one face has a non-empty
    src", rename it to declares_font_files(), and drop the test case that
    pinned the copied per-source rule.

    Refs #49890

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-coming-soon-font-family-deduplication b/plugins/woocommerce/changelog/fix-coming-soon-font-family-deduplication
new file mode 100644
index 00000000000..7c031f118f8
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-coming-soon-font-family-deduplication
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Avoid a duplicate Inter or Cardo font in the editor font picker in Coming Soon mode when the theme already provides that font.
diff --git a/plugins/woocommerce/src/Internal/ComingSoon/ComingSoonRequestHandler.php b/plugins/woocommerce/src/Internal/ComingSoon/ComingSoonRequestHandler.php
index dd28f686106..613885d52c7 100644
--- a/plugins/woocommerce/src/Internal/ComingSoon/ComingSoonRequestHandler.php
+++ b/plugins/woocommerce/src/Internal/ComingSoon/ComingSoonRequestHandler.php
@@ -249,16 +249,46 @@ class ComingSoonRequestHandler {

 		// Add WooCommerce fonts if they don't already exist.
 		foreach ( $fonts_to_add as $font_to_add ) {
-			$found = false;
-			foreach ( $font_data as $font ) {
-				if ( isset( $font['name'] ) && $font['name'] === $font_to_add['name'] ) {
+			$found      = false;
+			$slug_index = null;
+			foreach ( $font_data as $index => $font ) {
+				// Theme presets may omit the name or use another one, so also match the same primary font family and slug.
+				// Coming Soon patterns and saved templates use the bundled slugs, and need the theme to load the font files.
+				if (
+					( isset( $font['name'] ) && $font['name'] === $font_to_add['name'] )
+					|| (
+						isset( $font['fontFamily'], $font['slug'] )
+						&& is_string( $font['fontFamily'] )
+						&& $this->is_same_font_family( $font['fontFamily'], $font_to_add['fontFamily'] )
+						&& $font['slug'] === $font_to_add['slug']
+						&& is_array( $font['fontFace'] ?? null )
+						&& $this->declares_font_files( $font['fontFace'] )
+					)
+				) {
 					$found = true;
 					break;
 				}
+
+				if ( null === $slug_index && ( $font['slug'] ?? null ) === $font_to_add['slug'] ) {
+					$slug_index = $index;
+				}
+			}
+
+			if ( $found ) {
+				continue;
 			}

-			if ( ! $found ) {
+			if ( null === $slug_index ) {
 				$font_data[] = $font_to_add;
+				continue;
+			}
+
+			// The theme already owns this slug, and a second preset with it would override the theme's CSS variable.
+			// Only add the bundled font files when the theme preset is the same font without font files.
+			$font = $font_data[ $slug_index ];
+			if ( isset( $font['fontFamily'] ) && is_string( $font['fontFamily'] ) && $this->is_same_font_family( $font['fontFamily'], $font_to_add['fontFamily'] ) ) {
+				$font_faces                           = is_array( $font['fontFace'] ?? null ) ? $font['fontFace'] : array();
+				$font_data[ $slug_index ]['fontFace'] = array_merge( $font_faces, $font_to_add['fontFace'] );
 			}
 		}

@@ -276,6 +306,45 @@ class ComingSoonRequestHandler {
 		return $theme_json;
 	}

+	/**
+	 * Checks whether two CSS font family lists start with the same font family.
+	 *
+	 * @param string $font_family       CSS font family list.
+	 * @param string $other_font_family CSS font family list to compare with.
+	 * @return bool Whether the primary font families match, ignoring case and quotes.
+	 */
+	private function is_same_font_family( string $font_family, string $other_font_family ): bool {
+		return 0 === strcasecmp( $this->first_font_family( $font_family ), $this->first_font_family( $other_font_family ) );
+	}
+
+	/**
+	 * Gets the primary font family without optional surrounding quotes.
+	 *
+	 * @param string $font_family CSS font family list.
+	 * @return string Primary font family.
+	 */
+	private function first_font_family( string $font_family ): string {
+		return trim( trim( explode( ',', $font_family, 2 )[0] ), '"\'' );
+	}
+
+	/**
+	 * Checks whether any font face declares font files.
+	 *
+	 * This does not check that WordPress accepts the faces. WordPress reports invalid faces itself.
+	 *
+	 * @param array[] $font_faces Font face definitions.
+	 * @return bool Whether at least one face has a non-empty `src`.
+	 */
+	private function declares_font_files( array $font_faces ): bool {
+		foreach ( $font_faces as $font_face ) {
+			if ( ! empty( $font_face['src'] ) ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
 	/**
 	 * Enqueues the coming soon banner styles.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/ComingSoon/ComingSoonRequestHandlerTest.php b/plugins/woocommerce/tests/php/src/Internal/ComingSoon/ComingSoonRequestHandlerTest.php
index 143e47a0305..7659ad06c49 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ComingSoon/ComingSoonRequestHandlerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ComingSoon/ComingSoonRequestHandlerTest.php
@@ -72,60 +72,281 @@ class ComingSoonRequestHandlerTest extends \WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Tests that the method adds the 'Inter' and 'Cardo' fonts to the theme JSON data.
+	 * @testdox Adds a bundled font only when the theme does not already provide it by name, by primary font family and slug with its own font faces, or under the bundled slug.
+	 * @dataProvider provider_theme_fonts
+	 *
+	 * @param array<int, array<string, mixed>> $theme_fonts    Theme font presets.
+	 * @param string[]                         $expected_slugs Preset slugs after filtering.
+	 */
+	public function test_experimental_filter_theme_json_theme_adds_missing_bundled_fonts( array $theme_fonts, array $expected_slugs ): void {
+		$fonts = $this->filter_theme_fonts( $theme_fonts );
+
+		$this->assertSame( $expected_slugs, array_column( $fonts, 'slug' ) );
+		foreach ( $theme_fonts as $index => $theme_font ) {
+			$theme_faces = $theme_font['fontFace'] ?? array();
+			unset( $theme_font['fontFace'] );
+			$filtered_font  = $fonts[ $index ];
+			$filtered_faces = $filtered_font['fontFace'] ?? array();
+			unset( $filtered_font['fontFace'] );
+
+			$this->assertSame( $theme_font, $filtered_font, 'Theme fonts should be kept in order, with only font faces added.' );
+			$this->assertSame( $theme_faces, array_slice( $filtered_faces, 0, count( $theme_faces ) ), 'Theme font faces should be kept first.' );
+		}
+	}
+
+	/**
+	 * @testdox Adds the bundled font faces to a theme preset under the bundled slug only when it is the same font without font files.
+	 * @dataProvider provider_theme_fonts_under_bundled_slug
+	 *
+	 * @param array<string, mixed> $theme_font        Theme font preset using a bundled slug.
+	 * @param bool                 $expect_bundled_face Whether the bundled font face should be added to the preset.
+	 */
+	public function test_experimental_filter_theme_json_theme_adds_bundled_font_faces_under_bundled_slug( array $theme_font, bool $expect_bundled_face ): void {
+		$fonts = $this->filter_theme_fonts( array( $theme_font ) );
+
+		$theme_faces = $theme_font['fontFace'] ?? array();
+		$added_faces = array_slice( $fonts[0]['fontFace'] ?? array(), count( $theme_faces ) );
+		$added_src   = array_merge( ...array_column( $added_faces, 'src' ) );
+
+		if ( $expect_bundled_face ) {
+			$this->assertCount( 1, $added_faces );
+			$this->assertStringContainsString( '/assets/fonts/', $added_src[0] );
+		} else {
+			$this->assertSame( array(), $added_faces );
+		}
+	}
+
+	/**
+	 * Theme font presets using a bundled slug, and whether the bundled font face should be added to them.
+	 *
+	 * @return array<string, array{0: array<string, mixed>, 1: bool}>
 	 */
-	public function test_experimental_filter_theme_json_theme() {
-		$theme_json   = $this->createMock( \WP_Theme_JSON_Data::class );
-		$initial_data = array(
-			'settings' => array(
-				'typography' => array(
-					'fontFamilies' => array(
-						'theme' => array(
+	public function provider_theme_fonts_under_bundled_slug(): array {
+		return array(
+			'same font without font faces' => array(
+				array(
+					'fontFamily' => 'Inter, sans-serif',
+					'slug'       => 'inter',
+				),
+				true,
+			),
+			'same font without a source'   => array(
+				array(
+					'fontFamily' => 'Inter, sans-serif',
+					'slug'       => 'inter',
+					'fontFace'   => array( array( 'fontFamily' => 'Inter' ) ),
+				),
+				true,
+			),
+			'same font with a source'      => array(
+				array(
+					'fontFamily' => 'Inter, sans-serif',
+					'slug'       => 'inter',
+					'fontFace'   => array(
+						array(
+							'fontFamily' => 'Inter',
+							'src'        => array( 'https://example.com/inter.woff2' ),
+						),
+					),
+				),
+				false,
+			),
+			'another font family'          => array(
+				array(
+					'fontFamily' => 'Theme Serif, serif',
+					'slug'       => 'cardo',
+				),
+				false,
+			),
+		);
+	}
+
+	/**
+	 * Theme font presets and the preset slugs expected after the Coming Soon fonts are added.
+	 *
+	 * @return array<string, array{0: array<int, array<string, mixed>>, 1: string[]}>
+	 */
+	public function provider_theme_fonts(): array {
+		$cases = array(
+			'no bundled fonts'                        => array(
+				array(
+					array(
+						'fontFamily' => 'Theme Sans',
+						'name'       => 'Theme Sans',
+						'slug'       => 'theme-sans',
+					),
+				),
+				array( 'theme-sans', 'inter', 'cardo' ),
+			),
+			'bundled fonts without the bundled names' => array(
+				array(
+					array(
+						'fontFamily' => '"Inter", sans-serif',
+						'name'       => 'Sans Serif: Inter',
+						'slug'       => 'inter',
+						'fontFace'   => array(
+							array(
+								'fontFamily' => 'Inter',
+								'src'        => array( 'https://example.com/inter.woff2' ),
+							),
+						),
+					),
+					array(
+						'fontFamily' => 'Cardo',
+						'slug'       => 'cardo',
+						'fontFace'   => array(
+							array(
+								'fontFamily' => 'Cardo',
+								'src'        => array( 'https://example.com/cardo.woff2' ),
+							),
+						),
+					),
+				),
+				array( 'inter', 'cardo' ),
+			),
+			'bundled font family and slug without font faces' => array(
+				array(
+					array(
+						'fontFamily' => '"Inter", sans-serif',
+						'slug'       => 'inter',
+					),
+				),
+				array( 'inter', 'cardo' ),
+			),
+			'bundled name with another font family'   => array(
+				array(
+					array(
+						'fontFamily' => 'IBM Plex Serif, sans-serif',
+						'name'       => 'IBMPlexSerif',
+						'slug'       => 'ibm-plex-serif',
+					),
+					array(
+						'fontFamily' => 'Inter, sans-serif',
+						'name'       => 'Inter',
+						'slug'       => 'inter',
+					),
+				),
+				array( 'ibm-plex-serif', 'inter', 'cardo' ),
+			),
+			'unnamed bundled font family under another slug' => array(
+				array(
+					array(
+						'fontFamily' => '"Inter", sans-serif',
+						'slug'       => 'body',
+						'fontFace'   => array(
 							array(
-								'fontFamily' => 'Existing Font',
-								'name'       => 'Existing Font',
-								'slug'       => 'existing-font',
-								'fontFace'   => array(
-									array(
-										'fontFamily' => 'Existing Font',
-										'fontStyle'  => 'normal',
-										'fontWeight' => '400',
-										'src'        => array( 'existing-font.woff2' ),
-									),
-								),
+								'fontFamily' => 'Inter',
+								'src'        => array( 'https://example.com/inter.woff2' ),
 							),
+						),
+					),
+				),
+				array( 'body', 'inter', 'cardo' ),
+			),
+			'unnamed bundled slug with another font family' => array(
+				array(
+					array(
+						'fontFamily' => 'Theme Serif, serif',
+						'slug'       => 'cardo',
+						'fontFace'   => array(
 							array(
-								'fontFamily' => 'Unnamed Font',
-								'slug'       => 'unnamed-font',
-								'fontFace'   => array(
-									array(
-										'fontFamily' => 'Unnamed Font',
-										'fontStyle'  => 'normal',
-										'fontWeight' => '400',
-										'src'        => array( 'unnamed-font.woff2' ),
-									),
-								),
+								'fontFamily' => 'Theme Serif',
+								'src'        => array( 'https://example.com/theme-serif.woff2' ),
 							),
 						),
 					),
 				),
+				array( 'cardo', 'inter' ),
 			),
 		);

-		$theme_json->method( 'get_data' )->willReturn( $initial_data );
+		foreach ( array(
+			'unquoted Inter'             => array( 'Inter, sans-serif', array( 'inter', 'cardo' ) ),
+			'single-quoted Inter'        => array( "'Inter' , sans-serif", array( 'inter', 'cardo' ) ),
+			'quoted generic fallback'    => array( '"Inter", "sans-serif"', array( 'inter', 'cardo' ) ),
+			'additional fallback family' => array( 'Inter, Arial, sans-serif', array( 'inter', 'cardo' ) ),
+			'leading whitespace'         => array( " 'Inter', sans-serif", array( 'inter', 'cardo' ) ),
+			'lowercase Inter'            => array( 'inter, sans-serif', array( 'inter', 'cardo' ) ),
+		) as $label => $case ) {
+			$cases[ $label ] = array(
+				array(
+					array(
+						'fontFamily' => $case[0],
+						'slug'       => 'inter',
+						'fontFace'   => array(
+							array(
+								'fontFamily' => 'Inter',
+								'src'        => array( 'https://example.com/inter.woff2' ),
+							),
+						),
+					),
+				),
+				$case[1],
+			);
+		}

+		$cases['missing source'] = array(
+			array(
+				array(
+					'fontFamily' => '"Inter", sans-serif',
+					'slug'       => 'inter',
+					'fontFace'   => array( array( 'fontFamily' => 'Inter' ) ),
+				),
+			),
+			array( 'inter', 'cardo' ),
+		);
+
+		$cases['later face with a string source'] = array(
+			array(
+				array(
+					'fontFamily' => '"Inter", sans-serif',
+					'slug'       => 'inter',
+					'fontFace'   => array(
+						array( 'fontFamily' => 'Inter' ),
+						array(
+							'fontFamily' => 'Inter',
+							'src'        => 'https://example.com/inter.woff2',
+						),
+					),
+				),
+			),
+			array( 'inter', 'cardo' ),
+		);
+
+		return $cases;
+	}
+
+	/**
+	 * Filters the supplied theme fonts through the Coming Soon theme JSON handler.
+	 *
+	 * @param array<int, array<string, mixed>> $font_data Theme font definitions.
+	 * @return array<int, array<string, mixed>> Filtered theme font definitions.
+	 */
+	private function filter_theme_fonts( array $font_data ): array {
+		$theme_json     = $this->createMock( \WP_Theme_JSON_Data::class );
+		$captured_fonts = array();
+
+		$theme_json->method( 'get_data' )->willReturn(
+			array(
+				'settings' => array(
+					'typography' => array(
+						'fontFamilies' => array(
+							'theme' => $font_data,
+						),
+					),
+				),
+			)
+		);
 		$theme_json->expects( $this->once() )
 			->method( 'update_with' )
-			->with(
-				$this->callback(
-					function ( $new_data ) {
-						$fonts      = $new_data['settings']['typography']['fontFamilies']['theme'];
-						$font_names = array_column( $fonts, 'name' );
-						return in_array( 'Inter', $font_names, true ) && in_array( 'Cardo', $font_names, true );
-					}
-				)
+			->willReturnCallback(
+				function ( array $new_data ) use ( &$captured_fonts ): void {
+					$captured_fonts = $new_data['settings']['typography']['fontFamilies']['theme'];
+				}
 			);

 		$this->sut->experimental_filter_theme_json_theme( $theme_json );
+
+		return $captured_fonts;
 	}
 }