Commit b968daa38d4 for woocommerce

commit b968daa38d496a70e8cbe900c39dbff1f9b6fc21
Author: Néstor Soriano <konamiman@konamiman.com>
Date:   Tue Sep 1 17:55:44 2026 +0200

    Fix fragments handling in the GraphQL infrastructure exposed by the dual API (#68235)

diff --git a/plugins/woocommerce/changelog/fix-graphql-fragments-processing b/plugins/woocommerce/changelog/fix-graphql-fragments-processing
new file mode 100644
index 00000000000..943837799e6
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-graphql-fragments-processing
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix unnecessary repeated processing of fragments in the GraphQL side of the dual API.
diff --git a/plugins/woocommerce/phpcs.xml b/plugins/woocommerce/phpcs.xml
index 0df2e8a0b97..b451992e4a5 100644
--- a/plugins/woocommerce/phpcs.xml
+++ b/plugins/woocommerce/phpcs.xml
@@ -275,6 +275,8 @@
 		<exclude-pattern>src/Internal/Api/Autogenerated/</exclude-pattern>
 		<exclude-pattern>src/Api/Infrastructure/GraphQLControllerBase.php</exclude-pattern>
 		<exclude-pattern>src/Api/Infrastructure/QueryInfoExtractor.php</exclude-pattern>
+		<exclude-pattern>src/Internal/Api/QueryComplexityRule.php</exclude-pattern>
+		<exclude-pattern>src/Internal/Api/QueryDepthRule.php</exclude-pattern>
 	</rule>

 	<!-- API build scripts use empty if/elseif for intentional no-ops (e.g. enum
diff --git a/plugins/woocommerce/src/Api/Infrastructure/QueryInfoExtractor.php b/plugins/woocommerce/src/Api/Infrastructure/QueryInfoExtractor.php
index 0140ac74691..92f6b0e3133 100644
--- a/plugins/woocommerce/src/Api/Infrastructure/QueryInfoExtractor.php
+++ b/plugins/woocommerce/src/Api/Infrastructure/QueryInfoExtractor.php
@@ -22,9 +22,10 @@ use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\SelectionSetNode;
  * - Leaf field (no args, no sub-selection) => true
  * - Field with sub-selections => nested associative array
  * - Field arguments => '__args' reserved key
- * - Inline fragments => '...TypeName' prefix key
- * - Named fragment spreads => expanded inline (merged into the parent as
- *   siblings of the other selections), matching how GraphQL evaluates them
+ * - Inline fragments with a type condition => '...TypeName' prefix key
+ * - Inline fragments without a type condition and named fragment spreads =>
+ *   expanded inline (merged into the parent as siblings of the other
+ *   selections), matching how GraphQL evaluates them
  * - Top-level query args included via '__args'
  */
 class QueryInfoExtractor {
@@ -56,6 +57,26 @@ class QueryInfoExtractor {
 	 * @return array The query info tree for the selection set.
 	 */
 	public static function extract( ?SelectionSetNode $selection_set, array $variable_values, array $fragments = array() ): array {
+		$expanded_fragments = array();
+
+		return self::extract_selection_set( $selection_set, $variable_values, $fragments, $expanded_fragments );
+	}
+
+	/**
+	 * Recursive worker behind {@see self::extract()}.
+	 *
+	 * Named fragments are expanded once per extract() call and the result is
+	 * reused for every further spread, so the work stays proportional to the
+	 * size of the document. This runs after validation, whose limits don't
+	 * bound how often a fragment is spread.
+	 *
+	 * @param ?SelectionSetNode                     $selection_set      The selection set to process.
+	 * @param array                                 $variable_values    Variable values for resolving arguments.
+	 * @param array<string, FragmentDefinitionNode> $fragments          Named fragment definitions from the document.
+	 * @param array<string, array>                  $expanded_fragments Memoized expansions, keyed by fragment name. Passed by reference so the whole walk shares one cache.
+	 * @return array The query info tree for the selection set.
+	 */
+	private static function extract_selection_set( ?SelectionSetNode $selection_set, array $variable_values, array $fragments, array &$expanded_fragments ): array {
 		if ( null === $selection_set ) {
 			return array();
 		}
@@ -65,11 +86,17 @@ class QueryInfoExtractor {
 		foreach ( $selection_set->selections as $selection ) {
 			if ( $selection instanceof FieldNode ) {
 				$field_name            = $selection->name->value;
-				$result[ $field_name ] = self::build_field_entry( $selection, $variable_values, $fragments );
+				$result[ $field_name ] = self::build_field_entry( $selection, $variable_values, $fragments, $expanded_fragments );
 			} elseif ( $selection instanceof InlineFragmentNode ) {
-				$type_name      = $selection->typeCondition->name->value;
-				$key            = '...' . $type_name;
-				$result[ $key ] = self::extract( $selection->selectionSet, $variable_values, $fragments );
+				$sub = self::extract_selection_set( $selection->selectionSet, $variable_values, $fragments, $expanded_fragments );
+				if ( null === $selection->typeCondition ) {
+					// No `on Type` clause (e.g. `... @include(if: $flag) { ... }`):
+					// the fragment applies to the parent type, so merge it like
+					// a named fragment spread.
+					$result = self::merge_selections( $result, $sub );
+				} else {
+					$result[ '...' . $selection->typeCondition->name->value ] = $sub;
+				}
 			} elseif ( $selection instanceof FragmentSpreadNode ) {
 				// Expand named fragment spreads inline: their fields become
 				// siblings of the other selections, matching how GraphQL
@@ -79,11 +106,10 @@ class QueryInfoExtractor {
 				// recursive merge so overlapping selections are unioned
 				// rather than replaced — `array_merge` would drop the
 				// existing sub-selection under the same field name.
-				$fragment = $fragments[ $selection->name->value ] ?? null;
-				if ( null === $fragment ) {
+				$spread = self::expand_fragment( $selection->name->value, $variable_values, $fragments, $expanded_fragments );
+				if ( null === $spread ) {
 					continue;
 				}
-				$spread = self::extract( $fragment->selectionSet, $variable_values, $fragments );
 				$result = self::merge_selections( $result, $spread );
 			}
 		}
@@ -91,15 +117,44 @@ class QueryInfoExtractor {
 		return $result;
 	}

+	/**
+	 * Expand a named fragment into its query info tree, memoizing the result.
+	 *
+	 * @param string                                $name               The fragment name.
+	 * @param array                                 $variable_values    Variable values for resolving arguments.
+	 * @param array<string, FragmentDefinitionNode> $fragments          Named fragment definitions from the document.
+	 * @param array<string, array>                  $expanded_fragments Memoized expansions, keyed by fragment name.
+	 * @return ?array The expanded tree, or null when the fragment is not defined.
+	 */
+	private static function expand_fragment( string $name, array $variable_values, array $fragments, array &$expanded_fragments ): ?array {
+		if ( array_key_exists( $name, $expanded_fragments ) ) {
+			return $expanded_fragments[ $name ];
+		}
+
+		$fragment = $fragments[ $name ] ?? null;
+		if ( null === $fragment ) {
+			return null;
+		}
+
+		// Seed the entry before recursing so a fragment cycle expands to nothing
+		// instead of recursing forever (defensive: NoFragmentCycles rejects
+		// such documents during validation).
+		$expanded_fragments[ $name ] = array();
+		$expanded_fragments[ $name ] = self::extract_selection_set( $fragment->selectionSet, $variable_values, $fragments, $expanded_fragments );
+
+		return $expanded_fragments[ $name ];
+	}
+
 	/**
 	 * Build the entry for a single field node.
 	 *
-	 * @param FieldNode                             $field           The field node.
-	 * @param array                                 $variable_values Variable values for resolving arguments.
-	 * @param array<string, FragmentDefinitionNode> $fragments       Named fragment definitions from the document.
+	 * @param FieldNode                             $field              The field node.
+	 * @param array                                 $variable_values    Variable values for resolving arguments.
+	 * @param array<string, FragmentDefinitionNode> $fragments          Named fragment definitions from the document.
+	 * @param array<string, array>                  $expanded_fragments Memoized fragment expansions, keyed by fragment name.
 	 * @return array|bool True for leaf fields, associative array otherwise.
 	 */
-	private static function build_field_entry( FieldNode $field, array $variable_values, array $fragments ): array|bool {
+	private static function build_field_entry( FieldNode $field, array $variable_values, array $fragments, array &$expanded_fragments ): array|bool {
 		$has_args          = ! empty( $field->arguments ) && count( $field->arguments ) > 0;
 		$has_sub_selection = null !== $field->selectionSet;

@@ -118,7 +173,7 @@ class QueryInfoExtractor {
 		}

 		if ( $has_sub_selection ) {
-			$sub   = self::extract( $field->selectionSet, $variable_values, $fragments );
+			$sub   = self::extract_selection_set( $field->selectionSet, $variable_values, $fragments, $expanded_fragments );
 			$entry = self::merge_selections( $entry, $sub );
 		}

diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
index a3da661a0ea..888b1f56ac5 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_generation_date.txt
@@ -1 +1 @@
-2026-05-21T10:29:44+00:00
\ No newline at end of file
+2026-09-01T14:24:30+00:00
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
index 59f41b2e8ec..1326460d955 100644
--- a/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
+++ b/plugins/woocommerce/src/Internal/Api/Autogenerated/api_source_hash.txt
@@ -1 +1 @@
-2965921ea12d55aff3aa621ff023f57cf0dde87517b5c2fabf7ad47752f8a128
\ No newline at end of file
+8a1772a0ce7165390f95a55b811332ed47a895440da445291275295d06089e70
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/QueryComplexityRule.php b/plugins/woocommerce/src/Internal/Api/QueryComplexityRule.php
index f52e2009895..fc260660c67 100644
--- a/plugins/woocommerce/src/Internal/Api/QueryComplexityRule.php
+++ b/plugins/woocommerce/src/Internal/Api/QueryComplexityRule.php
@@ -4,15 +4,272 @@ declare(strict_types=1);

 namespace Automattic\WooCommerce\Internal\Api;

+use Automattic\WooCommerce\Vendor\GraphQL\Error\Error;
+use Automattic\WooCommerce\Vendor\GraphQL\Executor\Values;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FieldNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FragmentSpreadNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\NodeKind;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\SelectionNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\SelectionSetNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\Directive;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\FieldDefinition;
+use Automattic\WooCommerce\Vendor\GraphQL\Validator\QueryValidationContext;
 use Automattic\WooCommerce\Vendor\GraphQL\Validator\Rules\QueryComplexity;

 /**
- * QueryComplexity validation rule that returns a generic error message when the complexity is exceeded.
+ * QueryComplexity validation rule that returns a generic error message when
+ * the complexity is exceeded. Admins can still read both values via debug
+ * mode; see {@see GraphQLController} step 8.
  *
- * Admins can still read both values via debug mode; see
- * {@see GraphQLController} step 8.
+ * Unlike the stock webonyx rule, the work done stays proportional to the size
+ * of the document: each named fragment is scored once and the result reused
+ * for every spread, variable values are coerced once instead of once per
+ * directive or complexity callback, field definitions come from the visitor's
+ * TypeInfo instead of being re-collected for every selection set, and scores
+ * saturate at {@see self::COMPLEXITY_CEILING} instead of overflowing.
  */
 class QueryComplexityRule extends QueryComplexity {
+	/**
+	 * Upper bound for computed complexity scores.
+	 *
+	 * Far above any configurable limit, so real scores stay exact, while leaving
+	 * headroom below PHP_INT_MAX for complexity callbacks to multiply a saturated
+	 * child score by a page size without overflowing.
+	 */
+	public const COMPLEXITY_CEILING = PHP_INT_MAX >> 10;
+
+	/**
+	 * Memoized complexity of each named fragment, keyed by fragment name.
+	 *
+	 * @var array<string, int>
+	 */
+	private array $fragment_complexities = array();
+
+	/**
+	 * Names of the fragments whose complexity is currently being computed;
+	 * guards against fragment cycles (which the NoFragmentCycles rule reports).
+	 *
+	 * @var array<string, true>
+	 */
+	private array $fragments_in_progress = array();
+
+	/**
+	 * Variable values coerced for the current document, or null when not yet computed.
+	 *
+	 * @var ?array<string, mixed>
+	 */
+	private ?array $coerced_variable_values = null;
+
+	/**
+	 * Schema definition of every field node in the document, keyed by the
+	 * node's spl_object_id(). Populated as the visitor enters each field.
+	 *
+	 * @var array<int, ?FieldDefinition>
+	 */
+	private array $field_definitions = array();
+
+	/**
+	 * Reset the per-document state, then replace the stock SELECTION_SET
+	 * callback, which re-collects field definitions through every fragment
+	 * reachable from each selection set, with recording the definition that
+	 * TypeInfo already resolves as the visitor enters each field.
+	 *
+	 * @param QueryValidationContext $context The validation context.
+	 * @return array The visitor definition.
+	 */
+	public function getVisitor( QueryValidationContext $context ): array {
+		$this->fragment_complexities   = array();
+		$this->fragments_in_progress   = array();
+		$this->coerced_variable_values = null;
+		$this->field_definitions       = array();
+
+		$visitor = parent::getVisitor( $context );
+		if ( array() === $visitor ) {
+			// The rule is disabled.
+			return $visitor;
+		}
+
+		unset( $visitor[ NodeKind::SELECTION_SET ] );
+		$visitor[ NodeKind::FIELD ] = function ( FieldNode $node ) use ( $context ): void {
+			$this->field_definitions[ spl_object_id( $node ) ] = $context->getFieldDef();
+		};
+
+		return $visitor;
+	}
+
+	/**
+	 * Look up the schema definition recorded for a field node.
+	 *
+	 * @param FieldNode $field The field node.
+	 * @return ?FieldDefinition The definition, or null when the field doesn't exist on its parent type.
+	 */
+	protected function fieldDefinition( FieldNode $field ): ?FieldDefinition {
+		return $this->field_definitions[ spl_object_id( $field ) ] ?? null;
+	}
+
+	/**
+	 * Sum the complexity of a selection set's selections, saturating at
+	 * {@see self::COMPLEXITY_CEILING}.
+	 *
+	 * @param SelectionSetNode $selection_set The selection set to score.
+	 * @return int The (possibly saturated) complexity.
+	 * @throws \Exception When variable or argument coercion fails.
+	 */
+	protected function fieldComplexity( SelectionSetNode $selection_set ): int {
+		$complexity = 0;
+
+		foreach ( $selection_set->selections as $selection ) {
+			$complexity = $this->add_saturating( $complexity, $this->nodeComplexity( $selection ) );
+		}
+
+		return $complexity;
+	}
+
+	/**
+	 * Score a single selection. Named fragments are scored once and the result
+	 * reused for every spread; everything else is delegated to the stock rule.
+	 *
+	 * @param SelectionNode $node The selection to score.
+	 * @return int The complexity of the selection.
+	 * @throws \Exception When variable or argument coercion fails.
+	 */
+	protected function nodeComplexity( SelectionNode $node ): int {
+		if ( ! $node instanceof FragmentSpreadNode ) {
+			return parent::nodeComplexity( $node );
+		}
+
+		$fragment = $this->getFragment( $node );
+		if ( is_null( $fragment ) ) {
+			return 0;
+		}
+
+		$name = $fragment->name->value;
+		if ( array_key_exists( $name, $this->fragment_complexities ) ) {
+			return $this->fragment_complexities[ $name ];
+		}
+
+		// A fragment that (transitively) spreads itself has unbounded
+		// complexity. NoFragmentCycles reports the actual error.
+		if ( isset( $this->fragments_in_progress[ $name ] ) ) {
+			return self::COMPLEXITY_CEILING;
+		}
+
+		$this->fragments_in_progress[ $name ] = true;
+		try {
+			$complexity = $this->fieldComplexity( $fragment->selectionSet );
+		} finally {
+			unset( $this->fragments_in_progress[ $name ] );
+		}
+
+		$this->fragment_complexities[ $name ] = $complexity;
+
+		return $complexity;
+	}
+
+	/**
+	 * Whether `@include` / `@skip` directives exclude the field from execution.
+	 *
+	 * Same semantics as the stock rule, but variable values are coerced once
+	 * per document (see {@see self::get_coerced_variable_values()}).
+	 *
+	 * @param FieldNode $node The field node.
+	 * @return bool True when the field will not be executed.
+	 * @throws \Exception When variable coercion fails.
+	 */
+	protected function directiveExcludesField( FieldNode $node ): bool {
+		foreach ( $node->directives as $directive_node ) {
+			$directive_name = $directive_node->name->value;
+
+			if ( Directive::INCLUDE_NAME === $directive_name ) {
+				$include_arguments = Values::getArgumentValues(
+					Directive::includeDirective(),
+					$directive_node,
+					$this->get_coerced_variable_values()
+				);
+				if ( false === $include_arguments['if'] ) {
+					return true;
+				}
+			} elseif ( Directive::SKIP_NAME === $directive_name ) {
+				$skip_arguments = Values::getArgumentValues(
+					Directive::skipDirective(),
+					$directive_node,
+					$this->get_coerced_variable_values()
+				);
+				if ( true === $skip_arguments['if'] ) {
+					return true;
+				}
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Build the argument values handed to a field's complexity callback.
+	 *
+	 * Same semantics as the stock rule, but variable values are coerced once
+	 * per document (see {@see self::get_coerced_variable_values()}).
+	 *
+	 * @param FieldNode $node The field node.
+	 * @return array<string, mixed> The coerced argument values.
+	 * @throws \Exception When variable or argument coercion fails.
+	 */
+	protected function buildFieldArguments( FieldNode $node ): array {
+		$field_definition = $this->fieldDefinition( $node );
+
+		return $field_definition instanceof FieldDefinition
+			? Values::getArgumentValues( $field_definition, $node, $this->get_coerced_variable_values() )
+			: array();
+	}
+
+	/**
+	 * Coerce the document's variable values against their definitions,
+	 * once per document.
+	 *
+	 * @return array<string, mixed> The coerced variable values.
+	 * @throws Error When the provided variables don't satisfy their definitions (same error the stock rule throws).
+	 */
+	private function get_coerced_variable_values(): array {
+		if ( ! is_null( $this->coerced_variable_values ) ) {
+			return $this->coerced_variable_values;
+		}
+
+		list( $errors, $variable_values ) = Values::getVariableValues(
+			$this->context->getSchema(),
+			$this->variableDefs,
+			$this->getRawVariableValues()
+		);
+
+		if ( ! empty( $errors ) ) {
+			// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Not HTML; serialized as JSON by the GraphQL error formatter.
+			throw new Error(
+				implode(
+					"\n\n",
+					array_map( static fn( Error $error ): string => $error->getMessage(), $errors )
+				)
+			);
+			// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
+		}
+
+		$this->coerced_variable_values = $variable_values ?? array();
+
+		return $this->coerced_variable_values;
+	}
+
+	/**
+	 * Add two complexity scores, saturating at {@see self::COMPLEXITY_CEILING}.
+	 *
+	 * @param int $a First score.
+	 * @param int $b Second score.
+	 * @return int The saturated sum.
+	 */
+	private function add_saturating( int $a, int $b ): int {
+		$sum = $a + $b;
+
+		// An int overflow turns the sum into a float, which is also above the ceiling.
+		return $sum > self::COMPLEXITY_CEILING ? self::COMPLEXITY_CEILING : (int) $sum;
+	}
+
 	/**
 	 * Override webonyx's default ("Max query complexity should be {max} but
 	 * got {count}.").
diff --git a/plugins/woocommerce/src/Internal/Api/QueryDepthRule.php b/plugins/woocommerce/src/Internal/Api/QueryDepthRule.php
index 3607f90383d..1b1d885c6c9 100644
--- a/plugins/woocommerce/src/Internal/Api/QueryDepthRule.php
+++ b/plugins/woocommerce/src/Internal/Api/QueryDepthRule.php
@@ -4,15 +4,91 @@ declare(strict_types=1);

 namespace Automattic\WooCommerce\Internal\Api;

+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FragmentSpreadNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\Node;
+use Automattic\WooCommerce\Vendor\GraphQL\Validator\QueryValidationContext;
 use Automattic\WooCommerce\Vendor\GraphQL\Validator\Rules\QueryDepth;

 /**
- * QueryDepth validation rule that returns a generic error message when the depth is exceeded.
- *
- * Admins can still read both values via debug mode; see
+ * QueryDepth validation rule that returns a generic error message when the
+ * depth is exceeded. Admins can still read both values via debug mode; see
  * {@see GraphQLController} step 8.
+ *
+ * Unlike the stock webonyx rule, which walks a named fragment again on every
+ * spread, each fragment's depth is computed once, relative to the position it
+ * is spread at, and reused.
  */
 class QueryDepthRule extends QueryDepth {
+	/**
+	 * Sentinel for a selection tree with no nested selection sets, which adds
+	 * no depth wherever it is spread. The stock walk only ever raises the
+	 * running maximum, so seeding it with -1 makes the same walk report either
+	 * the relative depth (>= 0) or this sentinel.
+	 */
+	private const NO_NESTED_FIELDS = -1;
+
+	/**
+	 * Memoized relative depth of each named fragment, keyed by fragment name.
+	 *
+	 * @var array<string, int>
+	 */
+	private array $fragment_depths = array();
+
+	/**
+	 * Reset the per-document memoization before delegating to the stock visitor.
+	 *
+	 * @param QueryValidationContext $context The validation context.
+	 * @return array The visitor definition.
+	 */
+	public function getVisitor( QueryValidationContext $context ): array {
+		$this->fragment_depths = array();
+
+		return parent::getVisitor( $context );
+	}
+
+	/**
+	 * Compute the depth reached below a selection. Named fragment spreads use
+	 * the fragment's relative depth, computed once; everything else is
+	 * delegated to the stock rule.
+	 *
+	 * @param Node $node      The selection node.
+	 * @param int  $depth     The depth the selection sits at.
+	 * @param int  $max_depth The maximum depth seen so far.
+	 * @return int The updated maximum depth.
+	 */
+	protected function nodeDepth( Node $node, int $depth = 0, int $max_depth = 0 ): int {
+		if ( ! $node instanceof FragmentSpreadNode ) {
+			return parent::nodeDepth( $node, $depth, $max_depth );
+		}
+
+		$fragment = $this->getFragment( $node );
+		if ( is_null( $fragment ) ) {
+			return $max_depth;
+		}
+
+		$name = $fragment->name->value;
+		if ( ! array_key_exists( $name, $this->fragment_depths ) ) {
+			// Same cycle guard as the stock rule: a fragment that (transitively)
+			// spreads itself is reported as exceeding the limit.
+			if ( isset( $this->calculatedFragments[ $name ] ) ) {
+				return $this->maxQueryDepth + 1;
+			}
+
+			$this->calculatedFragments[ $name ] = true;
+			try {
+				$this->fragment_depths[ $name ] = $this->fieldDepth( $fragment, 0, self::NO_NESTED_FIELDS );
+			} finally {
+				unset( $this->calculatedFragments[ $name ] );
+			}
+		}
+
+		$relative_depth = $this->fragment_depths[ $name ];
+
+		return self::NO_NESTED_FIELDS === $relative_depth
+			? $max_depth
+			: max( $max_depth, $depth + $relative_depth );
+	}
+
 	/**
 	 * Override webonyx's default ("Max query depth should be {max} but
 	 * got {count}.").
diff --git a/plugins/woocommerce/tests/php/src/Api/Infrastructure/QueryInfoExtractorTest.php b/plugins/woocommerce/tests/php/src/Api/Infrastructure/QueryInfoExtractorTest.php
index 7767d41ed9c..6a3b686225a 100644
--- a/plugins/woocommerce/tests/php/src/Api/Infrastructure/QueryInfoExtractorTest.php
+++ b/plugins/woocommerce/tests/php/src/Api/Infrastructure/QueryInfoExtractorTest.php
@@ -11,6 +11,7 @@ declare(strict_types=1);
 namespace Automattic\WooCommerce\Tests\Api\Infrastructure;

 use Automattic\WooCommerce\Api\Infrastructure\QueryInfoExtractor;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\CountingNodeList;
 use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\DocumentNode;
 use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FieldNode;
 use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FragmentDefinitionNode;
@@ -118,6 +119,24 @@ class QueryInfoExtractorTest extends WC_Unit_Test_Case {
 		$this->assertSame( true, $tree['...Gadget']['parts_count'] ?? null );
 	}

+	/**
+	 * @testdox extract merges inline fragments without a type condition into the parent.
+	 */
+	public function test_extract_merges_inline_fragments_without_type_condition(): void {
+		[ $field ] = $this->parse_top_field(
+			'{ thing { id ... { name } ... @include(if: true) { sku reviews { nodes { id } } } ... on Widget { color } } }'
+		);
+
+		$tree = QueryInfoExtractor::extract( $field->selectionSet, array() );
+
+		$this->assertSame( true, $tree['id'] ?? null );
+		$this->assertSame( true, $tree['name'] ?? null );
+		$this->assertSame( true, $tree['sku'] ?? null );
+		$this->assertSame( true, $tree['reviews']['nodes']['id'] ?? null );
+		$this->assertSame( true, $tree['...Widget']['color'] ?? null );
+		$this->assertArrayNotHasKey( '...', $tree );
+	}
+
 	/**
 	 * @testdox extract expands named fragment spreads inline into the parent.
 	 */
@@ -191,4 +210,114 @@ class QueryInfoExtractorTest extends WC_Unit_Test_Case {

 		$this->assertArrayNotHasKey( '__args', $tree );
 	}
+
+	// The enable above closes the ResolveInfo block only; restore the file-level suppression for the AST properties used below.
+	// phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+
+	/**
+	 * @testdox extract expands each named fragment once, so a document whose fragments spread each other twice is processed in linear work.
+	 */
+	public function test_extract_expands_duplicate_fragment_spreads_once_per_fragment(): void {
+		// Each fragment spreads the next one twice.
+		$fragment_count = 40;
+		$source         = "{ product { ...F0 } }\n";
+		for ( $i = 0; $i < $fragment_count - 1; $i++ ) {
+			$next    = $i + 1;
+			$source .= "fragment F{$i} on Product { ...F{$next} ...F{$next} }\n";
+		}
+		$source .= 'fragment F' . ( $fragment_count - 1 ) . " on Product { id name }\n";
+
+		[ $field, $fragments ] = $this->parse_top_field( $source );
+		foreach ( $fragments as $fragment ) {
+			CountingNodeList::instrument( $fragment->selectionSet );
+		}
+		CountingNodeList::reset();
+
+		$tree = QueryInfoExtractor::extract( $field->selectionSet, array(), $fragments );
+
+		// Each fragment's selections are iterated exactly once, rather than once per spread.
+		$this->assertSame( $fragment_count, CountingNodeList::$iterations );
+		$this->assertSame(
+			array(
+				'id'   => true,
+				'name' => true,
+			),
+			$tree
+		);
+	}
+
+	/**
+	 * @testdox extract yields the same tree for repeated spreads of a memoized fragment as for a single spread.
+	 */
+	public function test_extract_repeated_spreads_of_the_same_fragment_are_idempotent(): void {
+		[ $field, $fragments ] = $this->parse_top_field(
+			'{ root { product { id ...Details } ...Extra ...Extra } } '
+			. 'fragment Details on Product { name price { amount } } '
+			. 'fragment Extra on Root { product { price { currency } } other(id: 3) { id } }'
+		);
+
+		$tree = QueryInfoExtractor::extract( $field->selectionSet, array(), $fragments );
+
+		$this->assertSame(
+			array(
+				'product' => array(
+					'id'    => true,
+					'name'  => true,
+					'price' => array(
+						'amount'   => true,
+						'currency' => true,
+					),
+				),
+				'other'   => array(
+					'__args' => array( 'id' => 3 ),
+					'id'     => true,
+				),
+			),
+			$tree
+		);
+	}
+
+	/**
+	 * @testdox extract expands the same fragment under every parent it is spread in.
+	 */
+	public function test_extract_expands_the_same_fragment_under_different_parents(): void {
+		[ $field, $fragments ] = $this->parse_top_field( '{ root { a { ...F } b { ...F } } } fragment F on Thing { x y }' );
+
+		$tree = QueryInfoExtractor::extract( $field->selectionSet, array(), $fragments );
+
+		$this->assertSame(
+			array(
+				'a' => array(
+					'x' => true,
+					'y' => true,
+				),
+				'b' => array(
+					'x' => true,
+					'y' => true,
+				),
+			),
+			$tree
+		);
+	}
+
+	/**
+	 * @testdox extract terminates on a fragment cycle instead of recursing forever.
+	 */
+	public function test_extract_terminates_on_fragment_cycles(): void {
+		[ $field, $fragments ] = $this->parse_top_field( '{ root { ...A } } fragment A on Root { a ...B } fragment B on Root { b ...A }' );
+
+		$tree = QueryInfoExtractor::extract( $field->selectionSet, array(), $fragments );
+
+		$this->assertArrayHasKey( 'a', $tree );
+		$this->assertArrayHasKey( 'b', $tree );
+	}
+
+	/**
+	 * @testdox extract ignores spreads of undefined fragments.
+	 */
+	public function test_extract_ignores_undefined_fragments(): void {
+		[ $field ] = $this->parse_top_field( '{ root { a ...Missing } }' );
+
+		$this->assertSame( array( 'a' => true ), QueryInfoExtractor::extract( $field->selectionSet, array() ) );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/CountingNodeList.php b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/CountingNodeList.php
new file mode 100644
index 00000000000..0be920be345
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/Fixtures/CountingNodeList.php
@@ -0,0 +1,67 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api\Fixtures;
+
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\DocumentNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\FragmentDefinitionNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\NodeList;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\SelectionSetNode;
+
+/**
+ * NodeList that counts how many times it is iterated.
+ *
+ * Tests swap it in for the `selections` of fragment selection sets to assert
+ * how many times a walk over the document visits each fragment, however often
+ * the fragment is spread.
+ */
+final class CountingNodeList extends NodeList {
+	/**
+	 * Number of iterations started over any CountingNodeList since the last reset().
+	 *
+	 * @var int
+	 */
+	public static int $iterations = 0;
+
+	/**
+	 * Reset the iteration counter.
+	 */
+	public static function reset(): void {
+		self::$iterations = 0;
+	}
+
+	/**
+	 * Replace the selections of a selection set with a counting copy.
+	 *
+	 * @param SelectionSetNode $selection_set The selection set to instrument.
+	 */
+	public static function instrument( SelectionSetNode $selection_set ): void {
+		$selection_set->selections = new self( iterator_to_array( $selection_set->selections ) );
+	}
+
+	/**
+	 * Instrument the selection set of every named fragment in a document.
+	 *
+	 * @param DocumentNode $document The parsed document.
+	 * @return int The number of fragments instrumented.
+	 */
+	public static function instrument_fragments( DocumentNode $document ): int {
+		$count = 0;
+		foreach ( $document->definitions as $definition ) {
+			if ( $definition instanceof FragmentDefinitionNode ) {
+				self::instrument( $definition->selectionSet );
+				++$count;
+			}
+		}
+		return $count;
+	}
+
+	/**
+	 * Count the iteration, then iterate as usual.
+	 */
+	public function getIterator(): \Traversable {
+		++self::$iterations;
+		return parent::getIterator();
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php
index 65a15c026d1..74f92945854 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php
@@ -6,9 +6,12 @@ namespace Automattic\WooCommerce\Tests\Internal\Api;

 use Automattic\WooCommerce\Api\Infrastructure\GraphQLControllerBase;
 use Automattic\WooCommerce\Internal\Api\QueryCache;
+use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\CountingNodeList;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Infrastructure\ClassResolver as DummyContainer;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApi\Store as DummyStore;
 use Automattic\WooCommerce\Tests\Internal\Api\Fixtures\DummyApiAutogenerated\GraphQLController as DummyGraphQLController;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\DocumentNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\Parser;
 use WC_REST_Unit_Test_Case;

 /**
@@ -226,6 +229,109 @@ class GraphQLControllerExecutionTest extends WC_REST_Unit_Test_Case {
 		$this->assertNotEmpty( $response->get_data()['errors'] ?? array() );
 	}

+	/**
+	 * Build a document in which each named fragment spreads the next one twice,
+	 * so the number of spreads reachable from the root doubles with every fragment.
+	 *
+	 * @param int    $fragment_count Number of chained fragments.
+	 * @param string $type_name      Type condition of the fragments.
+	 * @param string $root           Selection set body of the operation (must spread F0).
+	 * @param string $leaf           Selection set body of the last fragment.
+	 */
+	private function build_duplicate_spread_chain( int $fragment_count, string $type_name, string $root, string $leaf ): string {
+		$document = "query Q { {$root} }\n";
+		for ( $i = 0; $i < $fragment_count - 1; $i++ ) {
+			$next      = $i + 1;
+			$document .= "fragment F{$i} on {$type_name} { ...F{$next} ...F{$next} }\n";
+		}
+		$last = $fragment_count - 1;
+
+		return $document . "fragment F{$last} on {$type_name} { {$leaf} }\n";
+	}
+
+	/**
+	 * Upper bound on how many times the request pipeline (validation rules,
+	 * executor, query info extraction) may iterate one fragment's selections:
+	 * a handful of passes that each visit a fragment once (4 when validation
+	 * rejects the document and 6 when it executes, at the time of writing),
+	 * as opposed to once per spread.
+	 */
+	private const MAX_ITERATIONS_PER_FRAGMENT = 32;
+
+	/**
+	 * Make the controller process a pre-parsed document whose fragment
+	 * selection sets count how often they are iterated.
+	 *
+	 * Injected through a QueryCache double so the AST reaches the controller
+	 * as-is (the real cache would re-parse the query string). Resets the
+	 * iteration counter.
+	 *
+	 * @param string $query The GraphQL document.
+	 * @return int The number of fragments in the document.
+	 */
+	private function inject_counting_document( string $query ): int {
+		$document       = Parser::parse( $query, array( 'noLocation' => true ) );
+		$fragment_count = CountingNodeList::instrument_fragments( $document );
+		CountingNodeList::reset();
+
+		$cache = new class( $document ) extends QueryCache {
+			/**
+			 * Constructor.
+			 *
+			 * @param DocumentNode $document The document to hand to the controller.
+			 */
+			public function __construct( private DocumentNode $document ) {}
+
+			/**
+			 * Return the injected document regardless of the request.
+			 *
+			 * @param ?string $query      Ignored.
+			 * @param array   $extensions Ignored.
+			 */
+			public function resolve( ?string $query, array $extensions ): DocumentNode {
+				unset( $query, $extensions );
+				return $this->document;
+			}
+		};
+		$this->sut->init( $cache );
+
+		return $fragment_count;
+	}
+
+	/**
+	 * @testdox handle_request rejects a document whose fragments spread each other twice, visiting each fragment a bounded number of times.
+	 */
+	public function test_handle_request_rejects_duplicate_spread_document_with_bounded_work(): void {
+		$query          = $this->build_duplicate_spread_chain( 24, 'Query', '...F0', '__typename' );
+		$fragment_count = $this->inject_counting_document( $query );
+
+		$response = $this->sut->handle_request( $this->post_request( array( 'query' => $query ) ) );
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( 'Maximum query complexity exceeded.', $response->get_data()['errors'][0]['message'] ?? null );
+		$this->assertLessThanOrEqual( self::MAX_ITERATIONS_PER_FRAGMENT * $fragment_count, CountingNodeList::$iterations );
+	}
+
+	/**
+	 * @testdox handle_request executes a zero-complexity document whose fragments spread each other twice, visiting each fragment a bounded number of times in the resolvers.
+	 */
+	public function test_handle_request_executes_zero_complexity_duplicate_spread_document_with_bounded_work(): void {
+		// `first: 0` keeps the score within the limit, so the document reaches
+		// the resolver and its QueryInfoExtractor call, which must also expand
+		// each fragment only once.
+		$query          = $this->build_duplicate_spread_chain( 24, 'WidgetConnection', 'widgets(first: 0) { ...F0 }', '__typename' );
+		$fragment_count = $this->inject_counting_document( $query );
+
+		$admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		wp_set_current_user( $admin );
+
+		$response = $this->sut->handle_request( $this->post_request( array( 'query' => $query ) ) );
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 'WidgetConnection', $response->get_data()['data']['widgets']['__typename'] ?? null );
+		$this->assertLessThanOrEqual( self::MAX_ITERATIONS_PER_FRAGMENT * $fragment_count, CountingNodeList::$iterations );
+	}
+
 	/**
 	 * @testdox handle_request blocks introspection for low-privilege callers.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/QueryComplexityRuleTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/QueryComplexityRuleTest.php
new file mode 100644
index 00000000000..eaf5772e8bf
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/QueryComplexityRuleTest.php
@@ -0,0 +1,289 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Internal\Api\QueryComplexityRule;
+use Automattic\WooCommerce\Vendor\GraphQL\Error\Error;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\SelectionSetNode;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\Parser;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\CustomScalarType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\ObjectType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\Type;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Schema;
+use Automattic\WooCommerce\Vendor\GraphQL\Validator\DocumentValidator;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for {@see QueryComplexityRule}.
+ *
+ * The rule is exercised through DocumentValidator against a small hand-built
+ * schema, the same way GraphQLControllerBase wires it (stock rules plus ours).
+ */
+class QueryComplexityRuleTest extends WC_Unit_Test_Case {
+	/**
+	 * Number of times the Counted scalar's parseValue callback ran.
+	 *
+	 * @var int
+	 */
+	private int $parse_value_calls = 0;
+
+	/**
+	 * Set up.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->parse_value_calls = 0;
+	}
+
+	/**
+	 * Build the test schema.
+	 *
+	 * type Query { a: String, b: String, c: String, item: Item, items(first: Int): [Item], counted(values: [Counted!]): String }
+	 * type Item { id: Int, name: String }
+	 * scalar Counted (parseValue is instrumented)
+	 *
+	 * `items` carries a complexity callback multiplying the children's score
+	 * by `first`, like WooCommerce's connection fields do.
+	 */
+	private function build_schema(): Schema {
+		$counted = new CustomScalarType(
+			array(
+				'name'         => 'Counted',
+				'serialize'    => static fn( $value ) => $value,
+				'parseValue'   => function ( $value ) {
+					++$this->parse_value_calls;
+					return $value;
+				},
+				'parseLiteral' => static fn( $node ) => $node->value ?? null,
+			)
+		);
+
+		$item = new ObjectType(
+			array(
+				'name'   => 'Item',
+				'fields' => array(
+					'id'   => Type::int(),
+					'name' => Type::string(),
+				),
+			)
+		);
+
+		$query = new ObjectType(
+			array(
+				'name'   => 'Query',
+				'fields' => array(
+					'a'       => Type::string(),
+					'b'       => Type::string(),
+					'c'       => Type::string(),
+					'item'    => $item,
+					'items'   => array(
+						'type'       => Type::listOf( $item ),
+						'args'       => array( 'first' => Type::int() ),
+						'complexity' => static fn( int $children, array $args ): int => ( $args['first'] ?? 1 ) * ( $children + 1 ),
+					),
+					'counted' => array(
+						'type' => Type::string(),
+						'args' => array( 'values' => Type::listOf( Type::nonNull( $counted ) ) ),
+					),
+				),
+			)
+		);
+
+		return new Schema( array( 'query' => $query ) );
+	}
+
+	/**
+	 * Validate a document with the stock rules plus a QueryComplexityRule.
+	 *
+	 * @param string               $query          The GraphQL document.
+	 * @param int                  $max_complexity The complexity limit.
+	 * @param array                $variables      Raw variable values, as sent by the client.
+	 * @param bool                 $only_this_rule When true, validate with the complexity rule alone (no stock rules).
+	 * @param ?QueryComplexityRule $rule           A pre-built (e.g. instrumented) rule instance to use instead of a fresh one.
+	 * @return array{0: Error[], 1: QueryComplexityRule} The validation errors and the rule instance.
+	 */
+	private function validate( string $query, int $max_complexity, array $variables = array(), bool $only_this_rule = false, ?QueryComplexityRule $rule = null ): array {
+		$sut = $rule ?? new QueryComplexityRule( $max_complexity );
+		$sut->setRawVariableValues( $variables );
+
+		$rules   = $only_this_rule ? array() : array_values( DocumentValidator::allRules() );
+		$rules[] = $sut;
+
+		$errors = DocumentValidator::validate( $this->build_schema(), Parser::parse( $query ), $rules );
+
+		return array( $errors, $sut );
+	}
+
+	/**
+	 * Build a document in which each named fragment spreads the next one twice,
+	 * so the number of spreads reachable from the root doubles with every fragment.
+	 *
+	 * @param int    $fragment_count Number of chained fragments.
+	 * @param string $leaf           Selection set body of the last fragment.
+	 */
+	private function build_duplicate_spread_chain( int $fragment_count, string $leaf = 'a' ): string {
+		$document = "query Q { ...F0 }\n";
+		for ( $i = 0; $i < $fragment_count - 1; $i++ ) {
+			$next      = $i + 1;
+			$document .= "fragment F{$i} on Query { ...F{$next} ...F{$next} }\n";
+		}
+		$last = $fragment_count - 1;
+
+		return $document . "fragment F{$last} on Query { {$leaf} }\n";
+	}
+
+	/**
+	 * @testdox Duplicate fragment spreads are scored once per fragment, so a document whose fragments spread each other twice is scored in linear work.
+	 */
+	public function test_duplicate_fragment_spreads_are_scored_once_per_fragment(): void {
+		$fragment_count = 40;
+		$query          = $this->build_duplicate_spread_chain( $fragment_count );
+
+		$sut = new class( 1000 ) extends QueryComplexityRule {
+			/**
+			 * Number of selection sets scored.
+			 *
+			 * @var int
+			 */
+			public int $selection_sets_scored = 0;
+
+			/**
+			 * Count the call, then score as usual.
+			 *
+			 * @param SelectionSetNode $selection_set The selection set to score.
+			 */
+			protected function fieldComplexity( SelectionSetNode $selection_set ): int {
+				++$this->selection_sets_scored;
+				return parent::fieldComplexity( $selection_set );
+			}
+		};
+
+		list( $errors ) = $this->validate( $query, 1000, array(), false, $sut );
+
+		// The operation's selection set plus each fragment's exactly once, rather than once per spread.
+		$this->assertSame( $fragment_count + 1, $sut->selection_sets_scored );
+		$this->assertCount( 1, $errors );
+		$this->assertSame( 'Maximum query complexity exceeded.', $errors[0]->getMessage() );
+		// Memoization must not change the score: the leaf still counts once per spread.
+		$this->assertSame( 2 ** 39, $sut->getQueryComplexity() );
+	}
+
+	/**
+	 * @testdox Fragment spreads are scored exactly as if the fragment had been written inline.
+	 */
+	public function test_fragment_spread_scores_match_inline_expansion(): void {
+		$with_fragments = '{ ...F0 } fragment F0 on Query { ...F1 ...F1 } fragment F1 on Query { a b }';
+		$inline         = '{ a b a b }';
+
+		list( $errors, $sut ) = $this->validate( $with_fragments, 4 );
+		$this->assertSame( array(), $errors );
+		$this->assertSame( 4, $sut->getQueryComplexity() );
+
+		list( , $inline_sut ) = $this->validate( $inline, 4 );
+		$this->assertSame( $inline_sut->getQueryComplexity(), $sut->getQueryComplexity() );
+
+		list( $errors ) = $this->validate( $with_fragments, 3 );
+		$this->assertCount( 1, $errors );
+		$this->assertSame( 'Maximum query complexity exceeded.', $errors[0]->getMessage() );
+	}
+
+	/**
+	 * @testdox The computed score saturates at COMPLEXITY_CEILING instead of overflowing PHP's int.
+	 */
+	public function test_score_saturates_instead_of_overflowing(): void {
+		// 2^69 would overflow a 64-bit int (and surface as a TypeError).
+		$query = $this->build_duplicate_spread_chain( 70 );
+
+		list( $errors, $sut ) = $this->validate( $query, 1000 );
+
+		$this->assertCount( 1, $errors );
+		$this->assertSame( 'Maximum query complexity exceeded.', $errors[0]->getMessage() );
+		$this->assertSame( QueryComplexityRule::COMPLEXITY_CEILING, $sut->getQueryComplexity() );
+	}
+
+	/**
+	 * @testdox A fragment cycle terminates and is scored as exceeding the limit, even without the NoFragmentCycles rule.
+	 */
+	public function test_fragment_cycle_terminates(): void {
+		$query = '{ ...A } fragment A on Query { a ...B } fragment B on Query { b ...A }';
+
+		list( $errors ) = $this->validate( $query, 1000, array(), true );
+
+		$this->assertCount( 1, $errors );
+		$this->assertSame( 'Maximum query complexity exceeded.', $errors[0]->getMessage() );
+	}
+
+	/**
+	 * @testdox Variable values are coerced once per document, not once per @include/@skip directive.
+	 */
+	public function test_variables_are_coerced_once_per_document(): void {
+		$query = 'query Q($values: [Counted!], $flag: Boolean!) {
+			a @include(if: $flag)
+			b @include(if: $flag)
+			c @skip(if: $flag)
+			counted(values: $values)
+		}';
+
+		list( $errors, $sut ) = $this->validate(
+			$query,
+			1000,
+			array(
+				'values' => array( 1, 2, 3 ),
+				'flag'   => true,
+			)
+		);
+
+		$this->assertSame( array(), $errors );
+		// a, b, counted (c is skipped).
+		$this->assertSame( 3, $sut->getQueryComplexity() );
+		// One parseValue call per list element, regardless of how many directives the document carries.
+		$this->assertSame( 3, $this->parse_value_calls );
+	}
+
+	/**
+	 * @testdox Fields excluded by @include / @skip (literal or variable-driven) don't count, including when both directives are present.
+	 */
+	public function test_include_and_skip_directives_exclude_fields(): void {
+		list( $errors, $sut ) = $this->validate( '{ a @include(if: false) b @skip(if: true) c @include(if: true) @skip(if: true) item { id } }', 1000 );
+		$this->assertSame( array(), $errors );
+		$this->assertSame( 2, $sut->getQueryComplexity() );
+
+		list( $errors, $sut ) = $this->validate(
+			'query Q($show: Boolean!) { a @include(if: $show) b @skip(if: $show) }',
+			1000,
+			array( 'show' => false )
+		);
+		$this->assertSame( array(), $errors );
+		$this->assertSame( 1, $sut->getQueryComplexity() );
+	}
+
+	/**
+	 * @testdox Complexity callbacks receive the coerced field arguments, for fields both in operations and inside fragments.
+	 */
+	public function test_complexity_callback_receives_arguments(): void {
+		list( $errors, $sut ) = $this->validate( 'query Q($n: Int) { items(first: $n) { id name } }', 1000, array( 'n' => 10 ) );
+		$this->assertSame( array(), $errors );
+		// 10 * (2 children + 1).
+		$this->assertSame( 30, $sut->getQueryComplexity() );
+
+		list( $errors, $sut ) = $this->validate( '{ ...F } fragment F on Query { items(first: 5) { id } }', 1000 );
+		$this->assertSame( array(), $errors );
+		$this->assertSame( 10, $sut->getQueryComplexity() );
+
+		list( $errors ) = $this->validate( '{ items(first: 100) { id name } }', 100 );
+		$this->assertCount( 1, $errors );
+		$this->assertSame( 'Maximum query complexity exceeded.', $errors[0]->getMessage() );
+	}
+
+	/**
+	 * @testdox Missing required variables surface as a coercion error rather than as a crash.
+	 */
+	public function test_missing_required_variable_is_reported(): void {
+		$this->expectException( Error::class );
+		$this->expectExceptionMessageMatches( '/\$flag/' );
+
+		$this->validate( 'query Q($flag: Boolean!) { a @include(if: $flag) }', 1000, array() );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/QueryDepthRuleTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/QueryDepthRuleTest.php
new file mode 100644
index 00000000000..ebe27d9374c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/QueryDepthRuleTest.php
@@ -0,0 +1,176 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Tests\Internal\Api;
+
+use Automattic\WooCommerce\Internal\Api\QueryDepthRule;
+use Automattic\WooCommerce\Vendor\GraphQL\Error\Error;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\AST\Node;
+use Automattic\WooCommerce\Vendor\GraphQL\Language\Parser;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\ObjectType;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Definition\Type;
+use Automattic\WooCommerce\Vendor\GraphQL\Type\Schema;
+use Automattic\WooCommerce\Vendor\GraphQL\Validator\DocumentValidator;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for {@see QueryDepthRule}.
+ *
+ * Depth is counted the way the stock webonyx rule counts it: the nesting
+ * level (root = 0) of the deepest field that itself has a selection set.
+ * `{ node { leaf } }` has depth 0, `{ node { node { leaf } } }` has depth 1.
+ */
+class QueryDepthRuleTest extends WC_Unit_Test_Case {
+	/**
+	 * Build the test schema: type Query { node: Node, leaf: String } type Node { node: Node, leaf: String }.
+	 */
+	private function build_schema(): Schema {
+		$node = new ObjectType(
+			array(
+				'name'   => 'Node',
+				'fields' => static function () use ( &$node ): array {
+					return array(
+						'node' => $node,
+						'leaf' => Type::string(),
+					);
+				},
+			)
+		);
+
+		$query = new ObjectType(
+			array(
+				'name'   => 'Query',
+				'fields' => array(
+					'node' => $node,
+					'leaf' => Type::string(),
+				),
+			)
+		);
+
+		return new Schema( array( 'query' => $query ) );
+	}
+
+	/**
+	 * Validate a document with the stock rules plus a QueryDepthRule.
+	 *
+	 * @param string          $query          The GraphQL document.
+	 * @param int             $max_depth      The depth limit.
+	 * @param bool            $only_this_rule When true, validate with the depth rule alone (no stock rules).
+	 * @param ?QueryDepthRule $rule           A pre-built (e.g. instrumented) rule instance to use instead of a fresh one.
+	 * @return Error[] The validation errors.
+	 */
+	private function validate( string $query, int $max_depth, bool $only_this_rule = false, ?QueryDepthRule $rule = null ): array {
+		$sut = $rule ?? new QueryDepthRule( $max_depth );
+
+		$rules   = $only_this_rule ? array() : array_values( DocumentValidator::allRules() );
+		$rules[] = $sut;
+
+		return DocumentValidator::validate( $this->build_schema(), Parser::parse( $query ), $rules );
+	}
+
+	/**
+	 * Assert that a document is exactly at the given depth: accepted with that
+	 * limit, rejected with one less.
+	 *
+	 * @param int    $expected_depth The expected depth (must be >= 2, since a limit of 0 disables the rule).
+	 * @param string $query          The GraphQL document.
+	 */
+	private function assert_depth( int $expected_depth, string $query ): void {
+		$this->assertSame( array(), $this->validate( $query, $expected_depth ), "Expected depth {$expected_depth} to be accepted." );
+
+		$errors = $this->validate( $query, $expected_depth - 1 );
+		$this->assertCount( 1, $errors, 'Expected depth ' . ( $expected_depth - 1 ) . ' to be rejected.' );
+		$this->assertSame( 'Maximum query depth exceeded.', $errors[0]->getMessage() );
+	}
+
+	/**
+	 * @testdox Duplicate fragment spreads are walked once per fragment, so a document whose fragments spread each other twice is validated in linear work.
+	 */
+	public function test_duplicate_fragment_spreads_are_walked_once_per_fragment(): void {
+		// Each fragment spreads the next one twice.
+		$fragment_count = 40;
+		$query          = "query Q { ...F0 }\n";
+		for ( $i = 0; $i < $fragment_count - 1; $i++ ) {
+			$next   = $i + 1;
+			$query .= "fragment F{$i} on Query { ...F{$next} ...F{$next} }\n";
+		}
+		$query .= 'fragment F' . ( $fragment_count - 1 ) . " on Query { leaf }\n";
+
+		$sut = new class( 15 ) extends QueryDepthRule {
+			/**
+			 * Number of selection trees walked.
+			 *
+			 * @var int
+			 */
+			public int $trees_walked = 0;
+
+			/**
+			 * Count the call, then walk as usual.
+			 *
+			 * @param Node $node      The node whose selection set is walked.
+			 * @param int  $depth     The depth the node sits at.
+			 * @param int  $max_depth The maximum depth seen so far.
+			 */
+			protected function fieldDepth( Node $node, int $depth = 0, int $max_depth = 0 ): int {
+				++$this->trees_walked;
+				return parent::fieldDepth( $node, $depth, $max_depth );
+			}
+		};
+
+		$errors = $this->validate( $query, 15, false, $sut );
+
+		// The operation plus each fragment exactly once, rather than once per spread.
+		$this->assertSame( $fragment_count + 1, $sut->trees_walked );
+		$this->assertSame( array(), $errors );
+	}
+
+	/**
+	 * @testdox A fragment's depth is counted relative to the position it is spread at.
+	 */
+	public function test_fragment_depth_is_relative_to_spread_position(): void {
+		$fragment = ' fragment F on Node { node { leaf } }';
+
+		$this->assert_depth( 2, '{ node { node { ...F } } }' . $fragment );
+		$this->assert_depth( 3, '{ node { node { node { ...F } } } }' . $fragment );
+	}
+
+	/**
+	 * @testdox The same fragment spread at two different depths counts at the deeper one.
+	 */
+	public function test_same_fragment_at_different_depths_counts_the_deepest(): void {
+		$query = '{ shallow: node { ...F } deep: node { node { ...F } } } fragment F on Node { node { leaf } }';
+
+		$this->assert_depth( 2, $query );
+	}
+
+	/**
+	 * @testdox A fragment with no nested selections adds no depth wherever it is spread.
+	 */
+	public function test_fragment_without_nested_fields_adds_no_depth(): void {
+		$query = '{ node { node { node { ...F } } } } fragment F on Node { leaf }';
+
+		$this->assert_depth( 2, $query );
+	}
+
+	/**
+	 * @testdox Nested fragment spreads compose their relative depths.
+	 */
+	public function test_nested_fragment_spreads_compose(): void {
+		$query = '{ node { ...F } } fragment F on Node { node { ...G } } fragment G on Node { node { node { leaf } } }';
+
+		$this->assert_depth( 3, $query );
+	}
+
+	/**
+	 * @testdox A fragment cycle terminates and is reported as exceeding the limit, even without the NoFragmentCycles rule.
+	 */
+	public function test_fragment_cycle_terminates(): void {
+		$query = '{ ...A } fragment A on Query { node { ...B } } fragment B on Query { node { ...A } }';
+
+		$errors = $this->validate( $query, 100, true );
+
+		$this->assertCount( 1, $errors );
+		$this->assertSame( 'Maximum query depth exceeded.', $errors[0]->getMessage() );
+	}
+}