diff --git a/ambientimpact_media/composer.json b/ambientimpact_media/composer.json
index 845c0ed..bbc4b82 100644
--- a/ambientimpact_media/composer.json
+++ b/ambientimpact_media/composer.json
@@ -21,11 +21,11 @@
"extra": {
"patches": {
"drupal/core": {
- "Add a hook to modify oEmbed resource data (core 9.3.x) [#3042423]: https://www.drupal.org/project/drupal/issues/3042423#comment-14333467": "https://www.drupal.org/files/issues/2021-12-08/3042423-43.patch",
- "Apply width and height attributes to responsive image tag (core 9.3.x) [#3192234]: https://www.drupal.org/project/drupal/issues/3192234#comment-14296101": "https://www.drupal.org/files/issues/2021-11-18/3192234-116.patch"
+ "Add a hook to modify oEmbed resource data (core 9.3.x) [#3042423]: https://www.drupal.org/project/drupal/issues/3042423#comment-14333467": "https://raw.githubusercontent.com/Ambient-Impact/drupal-modules/tree/4.x/ambientimpact_media/patches/drupal/core/3042423-43.patch",
+ "Apply width and height attributes to responsive image tag (core 9.3.x) [#3192234]: https://www.drupal.org/project/drupal/issues/3192234#comment-14296101": "https://raw.githubusercontent.com/Ambient-Impact/drupal-modules/tree/4.x/ambientimpact_media/patches/drupal/core/3192234-116.patch"
},
"drupal/image_field_caption": {
- "Caption required incorrectly based on alt field required https://www.drupal.org/project/image_field_caption/issues/3181263 ": "https://www.drupal.org/files/issues/2020-11-07/image_field_caption_caption_required_alt_required_3181263-1.patch"
+ "Caption required incorrectly based on alt field required: https://www.drupal.org/project/image_field_caption/issues/3181263#comment-13895775": "https://raw.githubusercontent.com/Ambient-Impact/drupal-modules/tree/4.x/ambientimpact_media/patches/drupal/image_field_caption/image_field_caption_caption_required_alt_required_3181263-1.patch"
}
}
}
diff --git a/ambientimpact_media/patches/drupal/core/3042423-43.patch b/ambientimpact_media/patches/drupal/core/3042423-43.patch
new file mode 100644
index 0000000..c277293
--- /dev/null
+++ b/ambientimpact_media/patches/drupal/core/3042423-43.patch
@@ -0,0 +1,156 @@
+diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt
+index 40ebb8e9d1..059401acbe 100644
+--- a/core/misc/cspell/dictionary.txt
++++ b/core/misc/cspell/dictionary.txt
+@@ -632,6 +632,7 @@ hookname
+ horizontalrule
+ hosters
+ hostnames
++hqdefault
+ hreflang
+ hreflangs
+ hrefs
+@@ -807,6 +808,7 @@ maxage
+ maxdepth
+ maximumred
+ maxlifetime
++maxresdefault
+ maxsize
+ maynot
+ mbytes
+diff --git a/core/modules/media/media.api.php b/core/modules/media/media.api.php
+index 93244f58a8..5f7b4c7aec 100644
+--- a/core/modules/media/media.api.php
++++ b/core/modules/media/media.api.php
+@@ -20,6 +20,21 @@ function hook_media_source_info_alter(array &$sources) {
+ $sources['youtube']['label'] = t('Youtube rocks!');
+ }
+
++/**
++ * Alters the information provided by the oEmbed resource url.
++ *
++ * @param array $data
++ * Data provided by the oEmbed resource.
++ * @param $url
++ * The oEmbed resource URL.
++ */
++function hook_oembed_resource_data_alter(array &$data, $url) {
++ if (strpos($url, 'youtube.com/oembed') !== FALSE) {
++ // Get the maximum resolution thumbnail from YouTube.
++ $data['thumbnail_url'] = str_replace('hqdefault', 'maxresdefault', $data['thumbnail_url']);
++ }
++}
++
+ /**
+ * Alters an oEmbed resource URL before it is fetched.
+ *
+diff --git a/core/modules/media/media.services.yml b/core/modules/media/media.services.yml
+index 847e9e3d48..b4119f1de0 100644
+--- a/core/modules/media/media.services.yml
++++ b/core/modules/media/media.services.yml
+@@ -16,7 +16,7 @@ services:
+ arguments: ['@http_client', '@config.factory', '@datetime.time', '@keyvalue', '@logger.factory']
+ media.oembed.resource_fetcher:
+ class: Drupal\media\OEmbed\ResourceFetcher
+- arguments: ['@http_client', '@media.oembed.provider_repository', '@cache.default']
++ arguments: ['@http_client', '@media.oembed.provider_repository', '@module_handler', '@cache.default']
+ media.oembed.iframe_url_helper:
+ class: Drupal\media\IFrameUrlHelper
+ arguments: ['@router.request_context', '@private_key']
+diff --git a/core/modules/media/src/OEmbed/ResourceFetcher.php b/core/modules/media/src/OEmbed/ResourceFetcher.php
+index 39e7dd147f..4e0c40b9bc 100644
+--- a/core/modules/media/src/OEmbed/ResourceFetcher.php
++++ b/core/modules/media/src/OEmbed/ResourceFetcher.php
+@@ -4,6 +4,7 @@
+
+ use Drupal\Component\Serialization\Json;
+ use Drupal\Core\Cache\CacheBackendInterface;
++use Drupal\Core\Extension\ModuleHandlerInterface;
+ use GuzzleHttp\ClientInterface;
+ use GuzzleHttp\Exception\TransferException;
+ use GuzzleHttp\RequestOptions;
+@@ -27,6 +28,13 @@ class ResourceFetcher implements ResourceFetcherInterface {
+ */
+ protected $providers;
+
++ /**
++ * The module handler service.
++ *
++ * @var \Drupal\Core\Extension\ModuleHandlerInterface
++ */
++ protected $moduleHandler;
++
+ /**
+ * The cache backend.
+ *
+@@ -41,10 +49,12 @@ class ResourceFetcher implements ResourceFetcherInterface {
+ * The HTTP client.
+ * @param \Drupal\media\OEmbed\ProviderRepositoryInterface $providers
+ * The oEmbed provider repository service.
++ * @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler
++ * The module handler service.
+ * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
+ * The cache backend.
+ */
+- public function __construct(ClientInterface $http_client, ProviderRepositoryInterface $providers, CacheBackendInterface $cache_backend = NULL) {
++ public function __construct(ClientInterface $http_client, ProviderRepositoryInterface $providers, ModuleHandlerInterface $moduleHandler, CacheBackendInterface $cache_backend = NULL) {
+ $this->httpClient = $http_client;
+ $this->providers = $providers;
+ if (empty($cache_backend)) {
+@@ -52,6 +62,7 @@ public function __construct(ClientInterface $http_client, ProviderRepositoryInte
+ @trigger_error('Passing NULL as the $cache_backend parameter to ' . __METHOD__ . '() is deprecated in drupal:9.3.0 and is removed from drupal:10.0.0. See https://www.drupal.org/node/3223594', E_USER_DEPRECATED);
+ }
+ $this->cacheBackend = $cache_backend;
++ $this->moduleHandler = $moduleHandler;
+ }
+
+ /**
+@@ -92,6 +103,8 @@ public function fetchResource($url) {
+ throw new ResourceException('The oEmbed resource could not be decoded.', $url);
+ }
+
++ $this->moduleHandler->alter('oembed_resource_data', $data, $url);
++
+ $this->cacheBackend->set($cache_id, $data);
+
+ return $this->createResource($data, $url);
+diff --git a/core/modules/media/tests/src/Kernel/ResourceFetcherTest.php b/core/modules/media/tests/src/Kernel/ResourceFetcherTest.php
+index e68810f348..6e408f8517 100644
+--- a/core/modules/media/tests/src/Kernel/ResourceFetcherTest.php
++++ b/core/modules/media/tests/src/Kernel/ResourceFetcherTest.php
+@@ -21,7 +21,8 @@ public function testDeprecations(): void {
+ $this->expectDeprecation('Passing NULL as the $cache_backend parameter to Drupal\media\OEmbed\ResourceFetcher::__construct() is deprecated in drupal:9.3.0 and is removed from drupal:10.0.0. See https://www.drupal.org/node/3223594');
+ new ResourceFetcher(
+ $this->container->get('http_client'),
+- $this->createMock('\Drupal\media\OEmbed\ProviderRepositoryInterface')
++ $this->createMock('\Drupal\media\OEmbed\ProviderRepositoryInterface'),
++ $this->createMock('\Drupal\Core\Extension\ModuleHandlerInterface')
+ );
+ }
+
+diff --git a/core/modules/media/tests/src/Unit/ResourceFetcherTest.php b/core/modules/media/tests/src/Unit/ResourceFetcherTest.php
+index 8cd96683c9..327a628a65 100644
+--- a/core/modules/media/tests/src/Unit/ResourceFetcherTest.php
++++ b/core/modules/media/tests/src/Unit/ResourceFetcherTest.php
+@@ -43,6 +43,7 @@ public function testFetchTimeout(): void {
+ $fetcher = new ResourceFetcher(
+ $client->reveal(),
+ $this->createMock('\Drupal\media\OEmbed\ProviderRepositoryInterface'),
++ $this->createMock('\Drupal\Core\Extension\ModuleHandlerInterface'),
+ new NullBackend('default')
+ );
+ $fetcher->fetchResource($url);
+@@ -80,7 +81,12 @@ public function testUnknownContentTypeHeader(): void {
+ ]);
+ $providers = $this->createMock('\Drupal\media\OEmbed\ProviderRepositoryInterface');
+
+- $fetcher = new ResourceFetcher($client, $providers, new NullBackend('default'));
++ $fetcher = new ResourceFetcher(
++ $client,
++ $providers,
++ $this->createMock('\Drupal\Core\Extension\ModuleHandlerInterface'),
++ new NullBackend('default')
++ );
+ /** @var \Drupal\media\OEmbed\Resource $resource */
+ $resource = $fetcher->fetchResource('valid');
+ // The resource should have been successfully decoded as JSON.
diff --git a/ambientimpact_media/patches/drupal/core/3192234-116.patch b/ambientimpact_media/patches/drupal/core/3192234-116.patch
new file mode 100644
index 0000000..1a65761
--- /dev/null
+++ b/ambientimpact_media/patches/drupal/core/3192234-116.patch
@@ -0,0 +1,740 @@
+diff --git a/core/modules/responsive_image/config/schema/responsive_image.schema.yml b/core/modules/responsive_image/config/schema/responsive_image.schema.yml
+index f05a8e2608..8fae617593 100644
+--- a/core/modules/responsive_image/config/schema/responsive_image.schema.yml
++++ b/core/modules/responsive_image/config/schema/responsive_image.schema.yml
+@@ -67,3 +67,10 @@ field.formatter.settings.responsive_image:
+ image_link:
+ type: string
+ label: 'Link image to'
++ image_loading:
++ type: mapping
++ label: 'Image loading settings'
++ mapping:
++ attribute:
++ type: string
++ label: 'Loading attribute'
+diff --git a/core/modules/responsive_image/responsive_image.module b/core/modules/responsive_image/responsive_image.module
+index 6bd0e51f28..69523d2e7e 100644
+--- a/core/modules/responsive_image/responsive_image.module
++++ b/core/modules/responsive_image/responsive_image.module
+@@ -32,11 +32,11 @@ function responsive_image_help($route_name, RouteMatchInterface $route_match) {
+ $output .= '
' . t('Fallback image style') . '
';
+ $output .= '
' . t('The fallback image style is typically the smallest size image you expect to appear in this space. Because the responsive images module uses the Picturefill library so that responsive images can work in older browsers, the fallback image should only appear on a site if an error occurs.') . '
';
+ $output .= '
' . t('Breakpoint groups: viewport sizing vs art direction') . '
';
+- $output .= '
' . t('The breakpoint group typically only needs a single breakpoint with an empty media query in order to do viewport sizing. Multiple breakpoints are used for changing the crop or aspect ratio of images at different viewport sizes, which is often referred to as art direction. Once you select a breakpoint group, you can choose which breakpoints to use for the responsive image style. By default, the option do not use this breakpoint is selected for each breakpoint. See the help page of the Breakpoint module for more information.', [':breakpoint_help' => Url::fromRoute('help.page', ['name' => 'breakpoint'])->toString()]) . '
';
++ $output .= '
' . t('The breakpoint group typically only needs a single breakpoint with an empty media query in order to do viewport sizing. Multiple breakpoints are used for changing the crop or aspect ratio of images at different viewport sizes, which is often referred to as art direction. A new breakpoint group should be created for each aspect ratio to avoid content shift. Once you select a breakpoint group, you can choose which breakpoints to use for the responsive image style. By default, the option do not use this breakpoint is selected for each breakpoint. See the help page of the Breakpoint module for more information.', [':breakpoint_help' => Url::fromRoute('help.page', ['name' => 'breakpoint'])->toString()]) . '
';
+ $output .= '
' . t('Breakpoint settings: sizes vs image styles') . '
';
+- $output .= '
' . t('While you have the option to provide only one image style per breakpoint, the sizes option allows you to provide more options to browsers as to which image file it can display, even when using multiple breakpoints for art direction. Breakpoints are defined in the configuration files of the theme.') . '
';
++ $output .= '
' . t('While you have the option to provide only one image style per breakpoint, the sizes attribute allows you to provide more options to browsers as to which image file it can display. If using sizes field and art direction, all selected image styles should use the same aspect ratio to avoid content shifting. Breakpoints are defined in the configuration files of the theme.') . '
';
+ $output .= '
' . t('Sizes field') . '
';
+- $output .= '
' . t('Once the sizes option is selected, you can let the browser know the size of this image in relation to the site layout, using the Sizes field. For a hero image that always fills the entire screen, you could simply enter 100vw, which means 100% of the viewport width. For an image that fills 90% of the screen for small viewports, but only fills 40% of the screen when the viewport is larger than 40em (typically 640px), you could enter "(min-width: 40em) 40vw, 90vw" in the Sizes field. The last item in the comma-separated list is the smallest viewport size: other items in the comma-separated list should have a media condition paired with an image width. Media conditions are similar to a media query, often a min-width paired with a viewport width using em or px units: e.g. (min-width: 640px) or (min-width: 40em). This is paired with the image width at that viewport size using px, em or vw units. The vw unit is viewport width and is used instead of a percentage because the percentage always refers to the width of the entire viewport.') . '
';
++ $output .= '
' . t('The sizes attribute paired with the srcset attribute provides information on how much space these images take up within the viewport at different browser breakpoints, but the aspect ratios should remain the same across those breakpoints. Once the sizes option is selected, you can let the browser know the size of this image in relation to the site layout, using the Sizes field. For a hero image that always fills the entire screen, you could simply enter 100vw, which means 100% of the viewport width. For an image that fills 90% of the screen for small viewports, but only fills 40% of the screen when the viewport is larger than 40em (typically 640px), you could enter "(min-width: 40em) 40vw, 90vw" in the Sizes field. The last item in the comma-separated list is the smallest viewport size: other items in the comma-separated list should have a media condition paired with an image width. Media conditions are similar to a media query, often a min-width paired with a viewport width using em or px units: e.g. (min-width: 640px) or (min-width: 40em). This is paired with the image width at that viewport size using px, em or vw units. The vw unit is viewport width and is used instead of a percentage because the percentage always refers to the width of the entire viewport.') . '
';
+ $output .= '
' . t('Image styles for sizes') . '
';
+ $output .= '
' . t('Below the Sizes field you can choose multiple image styles so the browser can choose the best image file size to fill the space defined in the Sizes field. Typically you will want to use image styles that resize your image to have options that range from the smallest px width possible for the space the image will appear in to the largest px width possible, with a variety of widths in between. You may want to provide image styles with widths that are 1.5x to 2x the space available in the layout to account for high resolution screens. Image styles can be defined on the Image styles page that is provided by the Image module.', [':image_styles' => Url::fromRoute('entity.image_style.collection')->toString(), ':image_help' => Url::fromRoute('help.page', ['name' => 'image'])->toString()]) . '
';
+ $output .= '';
+@@ -212,6 +212,15 @@ function template_preprocess_responsive_image(&$variables) {
+ }
+ $variables['img_element']['#attributes'] = $variables['attributes'];
+ }
++
++ // Get width and height from fallback responsive image style and transfer them
++ // to img tag so browser can do aspect ratio calculation and prevent
++ // recalculation of layout on image load.
++ if (isset($variables['width'], $variables['height'])) {
++ $dimensions = responsive_image_get_image_dimensions($responsive_image_style->getFallbackImageStyle(), ['width' => $variables['width'], 'height' => $variables['height']], $variables['uri']);
++ $variables['img_element']['#width'] = $dimensions['width'];
++ $variables['img_element']['#height'] = $dimensions['height'];
++ }
+ }
+
+ /**
+@@ -403,13 +412,15 @@ function _responsive_image_build_source_attributes(array $variables, BreakpointI
+ // be used. We multiply it by 100 so multipliers with up to two decimals
+ // can be used.
+ $srcset[intval(mb_substr($multiplier, 0, -1) * 100)] = _responsive_image_image_style_url($image_style_mapping['image_mapping'], $variables['uri']) . ' ' . $multiplier;
++ $dimensions = responsive_image_get_image_dimensions($image_style_mapping['image_mapping'], ['width' => $width, 'height' => $height], $variables['uri']);
+ break;
+ }
+ }
+ // Sort the srcset from small to large image width or multiplier.
+ ksort($srcset);
++ $srcset = array_unique($srcset);
+ $source_attributes = new Attribute([
+- 'srcset' => implode(', ', array_unique($srcset)),
++ 'srcset' => implode(', ', $srcset),
+ ]);
+ $media_query = trim($breakpoint->getMediaQuery());
+ if (!empty($media_query)) {
+@@ -421,6 +432,21 @@ function _responsive_image_build_source_attributes(array $variables, BreakpointI
+ if (!empty($sizes)) {
+ $source_attributes->setAttribute('sizes', implode(',', array_unique($sizes)));
+ }
++ // The images used in a particular srcset attribute should all have the same
++ // aspect ratio. The sizes attribute paired with the srcset attribute provides
++ // information on how much space these images take up within the viewport at
++ // different breakpoints, but the aspect ratios should remain the same across
++ // those breakpoints. Multiple source elements can be used for art direction,
++ // where aspect ratios should change at particular breakpoints. Each source
++ // element can still have srcset and sizes attributes to handle variations for
++ // that particular aspect ratio. Because the same aspect ratio is assumed for
++ // all images in a srcset, dimensions are always added to the source
++ // attribute. Within srcset, images are sorted from largest to smallest in
++ // terms of the real dimension of the image.
++ if (!empty($dimensions['width']) && !empty($dimensions['height'])) {
++ $source_attributes->setAttribute('width', $dimensions['width']);
++ $source_attributes->setAttribute('height', $dimensions['height']);
++ }
+ return $source_attributes;
+ }
+
+diff --git a/core/modules/responsive_image/responsive_image.post_update.php b/core/modules/responsive_image/responsive_image.post_update.php
+index 70093a9eda..4fc6f00cac 100644
+--- a/core/modules/responsive_image/responsive_image.post_update.php
++++ b/core/modules/responsive_image/responsive_image.post_update.php
+@@ -13,3 +13,24 @@ function responsive_image_removed_post_updates() {
+ 'responsive_image_post_update_recreate_dependencies' => '9.0.0',
+ ];
+ }
++
++/**
++ * Add the image loading settings to responsive image field formatter instances.
++ */
++function responsive_image_post_update_image_loading_attribute() {
++ $storage = \Drupal::entityTypeManager()->getStorage('entity_view_display');
++ foreach ($storage->loadMultiple() as $id => $view_display) {
++ $changed = FALSE;
++ /** @var \Drupal\Core\Entity\Display\EntityViewDisplayInterface $view_display */
++ foreach ($view_display->getComponents() as $field => $component) {
++ if (isset($component['type']) && ($component['type'] === 'responsive_image')) {
++ $component['settings']['image_loading']['attribute'] = 'lazy';
++ $view_display->setComponent($field, $component);
++ $changed = TRUE;
++ }
++ }
++ if ($changed) {
++ $view_display->save();
++ }
++ }
++}
+diff --git a/core/modules/responsive_image/src/Plugin/Field/FieldFormatter/ResponsiveImageFormatter.php b/core/modules/responsive_image/src/Plugin/Field/FieldFormatter/ResponsiveImageFormatter.php
+index 331c278e0c..ff9dd44ae9 100644
+--- a/core/modules/responsive_image/src/Plugin/Field/FieldFormatter/ResponsiveImageFormatter.php
++++ b/core/modules/responsive_image/src/Plugin/Field/FieldFormatter/ResponsiveImageFormatter.php
+@@ -118,6 +118,9 @@ public static function defaultSettings() {
+ return [
+ 'responsive_image_style' => '',
+ 'image_link' => '',
++ 'image_loading' => [
++ 'attribute' => 'lazy',
++ ],
+ ] + parent::defaultSettings();
+ }
+
+@@ -125,6 +128,8 @@ public static function defaultSettings() {
+ * {@inheritdoc}
+ */
+ public function settingsForm(array $form, FormStateInterface $form_state) {
++ $elements = parent::settingsForm($form, $form_state);
++
+ $responsive_image_options = [];
+ $responsive_image_styles = $this->responsiveImageStyleStorage->loadMultiple();
+ uasort($responsive_image_styles, '\Drupal\responsive_image\Entity\ResponsiveImageStyle::sort');
+@@ -148,6 +153,27 @@ public function settingsForm(array $form, FormStateInterface $form_state) {
+ ],
+ ];
+
++ $image_loading = $this->getSetting('image_loading');
++ $elements['image_loading'] = [
++ '#type' => 'details',
++ '#title' => $this->t('Image loading'),
++ '#weight' => 10,
++ '#description' => $this->t('Image assets are rendered with native browser loading attribute of (loading="lazy") by default. This improves performance by allowing modern browsers to lazily load images without JavaScript. It is sometimes desirable to override this default to force browsers to download an image as soon as possible using the "eager" value instead.'),
++ ];
++ $loading_attribute_options = [
++ 'lazy' => $this->t('Lazy'),
++ 'eager' => $this->t('Eager'),
++ ];
++ $elements['image_loading']['attribute'] = [
++ '#title' => $this->t('Lazy loading attribute'),
++ '#type' => 'select',
++ '#default_value' => $image_loading['attribute'],
++ '#options' => $loading_attribute_options,
++ '#description' => $this->t('Select the lazy loading attribute for images. Learn more.', [
++ ':link' => 'https://html.spec.whatwg.org/multipage/urls-and-fetching.html#lazy-loading-attributes',
++ ]),
++ ];
++
+ $link_types = [
+ 'content' => t('Content'),
+ 'file' => t('File'),
+@@ -186,7 +212,12 @@ public function settingsSummary() {
+ $summary[] = t('Select a responsive image style.');
+ }
+
+- return $summary;
++ $image_loading = $this->getSetting('image_loading');
++ $summary[] = $this->t('Loading attribute: @attribute', [
++ '@attribute' => $image_loading['attribute'],
++ ]);
++
++ return array_merge($summary, parent::settingsSummary());
+ }
+
+ /**
+@@ -239,6 +270,9 @@ public function viewElements(FieldItemListInterface $items, $langcode) {
+ $item_attributes = $item->_attributes;
+ unset($item->_attributes);
+
++ $image_loading_settings = $this->getSetting('image_loading');
++ $item_attributes['loading'] = $image_loading_settings['attribute'];
++
+ $elements[$delta] = [
+ '#theme' => 'responsive_image_formatter',
+ '#item' => $item,
+diff --git a/core/modules/responsive_image/tests/modules/responsive_image_test_module/config/schema/responsive_image_test_module.schema.yml b/core/modules/responsive_image/tests/modules/responsive_image_test_module/config/schema/responsive_image_test_module.schema.yml
+index 6208012735..549a1bb928 100644
+--- a/core/modules/responsive_image/tests/modules/responsive_image_test_module/config/schema/responsive_image_test_module.schema.yml
++++ b/core/modules/responsive_image/tests/modules/responsive_image_test_module/config/schema/responsive_image_test_module.schema.yml
+@@ -9,3 +9,10 @@ field.formatter.settings.responsive_image_test:
+ image_link:
+ type: string
+ label: 'Link image to'
++ image_loading:
++ type: mapping
++ label: 'Image loading settings'
++ mapping:
++ attribute:
++ type: string
++ label: 'Loading attribute'
+diff --git a/core/modules/responsive_image/tests/src/Functional/ResponsiveImageFieldDisplayTest.php b/core/modules/responsive_image/tests/src/Functional/ResponsiveImageFieldDisplayTest.php
+index b56b027fec..73142c793b 100644
+--- a/core/modules/responsive_image/tests/src/Functional/ResponsiveImageFieldDisplayTest.php
++++ b/core/modules/responsive_image/tests/src/Functional/ResponsiveImageFieldDisplayTest.php
+@@ -318,6 +318,11 @@ protected function doTestResponsiveImageFieldFormatters($scheme, $empty_styles =
+ $medium_style = ImageStyle::load('medium');
+ $this->assertSession()->responseContains($this->fileUrlGenerator->transformRelative($medium_style->buildUrl($image_uri)) . ' 220w, ' . $this->fileUrlGenerator->transformRelative($large_style->buildUrl($image_uri)) . ' 360w');
+ $this->assertSession()->responseContains('media="(min-width: 851px)"');
++ // Assert the output of the 'width' attribute.
++ $this->assertSession()->responseContains('width="360"');
++ // Assert the output of the 'height' attribute.
++ $this->assertSession()->responseContains('height="240"');
++ $this->assertSession()->responseContains('loading="lazy"');
+ }
+ $this->assertSession()->responseContains('/styles/large/');
+ $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'config:responsive_image.styles.style_one');
+@@ -334,6 +339,9 @@ protected function doTestResponsiveImageFieldFormatters($scheme, $empty_styles =
+ '#theme' => 'image',
+ '#alt' => $alt,
+ '#uri' => $this->fileUrlGenerator->transformRelative($large_style->buildUrl($image->getSource())),
++ '#width' => 360,
++ '#height' => 240,
++ '#attributes' => ['loading' => 'lazy'],
+ ];
+ // The image.html.twig template has a newline after the tag but
+ // responsive-image.html.twig doesn't have one after the fallback image, so
+@@ -417,9 +425,9 @@ public function testResponsiveImageFieldFormattersEmptyMediaQuery() {
+ }
+
+ /**
+- * Tests responsive image formatter on node display with one source.
++ * Tests responsive image formatter on node display with one and two sources.
+ */
+- public function testResponsiveImageFieldFormattersOneSource() {
++ public function testResponsiveImageFieldFormattersMultipleSources() {
+ $this->responsiveImgStyle
+ // Test the output of an empty media query.
+ ->addImageStyleMapping('responsive_image_test_module.empty', '1x', [
+@@ -445,6 +453,10 @@ public function testResponsiveImageFieldFormattersOneSource() {
+ 'settings' => [
+ 'image_link' => '',
+ 'responsive_image_style' => 'style_one',
++ 'image_loading' => [
++ // Test the image loading default option can be overridden.
++ 'attribute' => 'eager',
++ ],
+ ],
+ ];
+ $display = \Drupal::service('entity_display.repository')
+@@ -455,12 +467,26 @@ public function testResponsiveImageFieldFormattersOneSource() {
+ // View the node.
+ $this->drupalGet('node/' . $nid);
+
+- // Assert the media attribute is present if it has a value.
++ // Assert the img tag has medium and large images.
+ $large_style = ImageStyle::load('large');
+ $medium_style = ImageStyle::load('medium');
+ $node = $node_storage->load($nid);
+ $image_uri = File::load($node->{$field_name}->target_id)->getFileUri();
+- $this->assertSession()->responseContains('fileUrlGenerator->transformRelative($medium_style->buildUrl($image_uri));
++ $large_transform_url = $this->fileUrlGenerator->transformRelative($large_style->buildUrl($image_uri));
++ $this->assertSession()->responseMatches('//');
++
++ $this->responsiveImgStyle
++ // Test the output of an empty media query.
++ ->addImageStyleMapping('responsive_image_test_module.wide', '1x', [
++ 'image_mapping_type' => 'image_style',
++ 'image_mapping' => 'large',
++ ])
++ ->save();
++
++ // Assert the picture tag has source tags that include dimensions.
++ $this->drupalGet('node/' . $nid);
++ $this->assertSession()->responseMatches('/