From 7ec35d9af273c5977d29d341e8f3e070a3328205 Mon Sep 17 00:00:00 2001
From: catch <catch@35733.no-reply.drupal.org>
Date: Mon, 26 Feb 2024 16:36:03 +0000
Subject: [PATCH] Issue #3304772 by tstoeckler, kksandr, Murz, smustgrave:
 Cache tags from Computed fields do not bubble up to Entity render array

---
 core/lib/Drupal/Core/Field/FormatterBase.php  | 12 ++-
 .../EntityTestComputedFieldTest.php           |  1 +
 .../src/Entity/EntityTestComputedField.php    | 11 +++
 .../ComputedTestCacheableIntegerItemList.php  | 36 +++++++++
 .../ComputedTestCacheableStringItemList.php   |  4 +
 .../EntityTestComputedFieldNormalizerTest.php |  6 ++
 .../Entity/EntityComputedFieldTest.php        | 76 +++++++++++++++++++
 7 files changed, 145 insertions(+), 1 deletion(-)
 create mode 100644 core/modules/system/tests/modules/entity_test/src/Plugin/Field/ComputedTestCacheableIntegerItemList.php
 create mode 100644 core/modules/system/tests/src/Functional/Entity/EntityComputedFieldTest.php

diff --git a/core/lib/Drupal/Core/Field/FormatterBase.php b/core/lib/Drupal/Core/Field/FormatterBase.php
index a0ed3ea11e71..aa415d39cad1 100644
--- a/core/lib/Drupal/Core/Field/FormatterBase.php
+++ b/core/lib/Drupal/Core/Field/FormatterBase.php
@@ -2,6 +2,8 @@
 
 namespace Drupal\Core\Field;
 
+use Drupal\Core\Cache\CacheableDependencyInterface;
+use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Language\LanguageInterface;
 use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
@@ -88,8 +90,16 @@ public function view(FieldItemListInterface $items, $langcode = NULL) {
     }
     $elements = $this->viewElements($items, $langcode);
 
+    // Field item lists, in particular for computed fields, may carry cacheable
+    // metadata which must be bubbled.
+    if ($items instanceof CacheableDependencyInterface) {
+      (new CacheableMetadata())
+        ->addCacheableDependency($items)
+        ->applyTo($elements);
+    }
+
     // If there are actual renderable children, use #theme => field, otherwise,
-    // let access cacheability metadata pass through for correct bubbling.
+    // let cacheability metadata pass through for correct bubbling.
     if (Element::children($elements)) {
       $entity = $items->getEntity();
       $entity_type = $entity->getEntityTypeId();
diff --git a/core/modules/jsonapi/tests/src/Functional/EntityTestComputedFieldTest.php b/core/modules/jsonapi/tests/src/Functional/EntityTestComputedFieldTest.php
index e3f9a9838dd4..33c855c8a97c 100644
--- a/core/modules/jsonapi/tests/src/Functional/EntityTestComputedFieldTest.php
+++ b/core/modules/jsonapi/tests/src/Functional/EntityTestComputedFieldTest.php
@@ -114,6 +114,7 @@ protected function getExpectedDocument() {
           'drupal_internal__id' => 1,
           'computed_string_field' => NULL,
           'computed_test_cacheable_string_field' => 'computed test cacheable string field',
+          'computed_test_cacheable_integer_field' => 0,
         ],
         'relationships' => [
           'computed_reference_field' => [
diff --git a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestComputedField.php b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestComputedField.php
index 6cf035700278..9ade6b8344a5 100644
--- a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestComputedField.php
+++ b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestComputedField.php
@@ -6,6 +6,7 @@
 use Drupal\Core\Field\BaseFieldDefinition;
 use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\entity_test\Plugin\Field\ComputedReferenceTestFieldItemList;
+use Drupal\entity_test\Plugin\Field\ComputedTestCacheableIntegerItemList;
 use Drupal\entity_test\Plugin\Field\ComputedTestCacheableStringItemList;
 use Drupal\entity_test\Plugin\Field\ComputedTestFieldItemList;
 
@@ -49,12 +50,22 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
       ->setSetting('target_type', 'entity_test')
       ->setClass(ComputedReferenceTestFieldItemList::class);
 
+    // Cacheable metadata can either be provided via the field item properties
+    // or via the field item list class directly. Add a computed string field
+    // which does the former and a computed integer field which does the latter.
     $fields['computed_test_cacheable_string_field'] = BaseFieldDefinition::create('computed_test_cacheable_string_item')
       ->setLabel(new TranslatableMarkup('Computed Cacheable String Field Test'))
       ->setComputed(TRUE)
       ->setClass(ComputedTestCacheableStringItemList::class)
       ->setReadOnly(FALSE)
       ->setInternal(FALSE);
+    $fields['computed_test_cacheable_integer_field'] = BaseFieldDefinition::create('integer')
+      ->setLabel(new TranslatableMarkup('Computed Cacheable Integer Field Test'))
+      ->setComputed(TRUE)
+      ->setClass(ComputedTestCacheableIntegerItemList::class)
+      ->setReadOnly(FALSE)
+      ->setInternal(FALSE)
+      ->setDisplayOptions('view', ['weight' => 10]);
 
     return $fields;
   }
diff --git a/core/modules/system/tests/modules/entity_test/src/Plugin/Field/ComputedTestCacheableIntegerItemList.php b/core/modules/system/tests/modules/entity_test/src/Plugin/Field/ComputedTestCacheableIntegerItemList.php
new file mode 100644
index 000000000000..6c9a85b9aed7
--- /dev/null
+++ b/core/modules/system/tests/modules/entity_test/src/Plugin/Field/ComputedTestCacheableIntegerItemList.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\entity_test\Plugin\Field;
+
+use Drupal\Core\Cache\CacheableDependencyInterface;
+use Drupal\Core\Cache\CacheableDependencyTrait;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Field\FieldItemList;
+use Drupal\Core\TypedData\ComputedItemListTrait;
+
+/**
+ * Item list class for computed cacheable string field.
+ *
+ * This class sets the cacheable metadata on the field item list directly.
+ *
+ * @see \Drupal\entity_test\Plugin\Field\ComputedTestCacheableStringItemList
+ */
+class ComputedTestCacheableIntegerItemList extends FieldItemList implements CacheableDependencyInterface {
+
+  use CacheableDependencyTrait, ComputedItemListTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function computeValue() {
+    $value = \Drupal::state()->get('entity_test_computed_integer_value', 0);
+    $item = $this->createItem(0, $value);
+    $cacheability = (new CacheableMetadata())
+      ->setCacheContexts(['url.query_args:computed_test_cacheable_integer_field'])
+      ->setCacheTags(['field:computed_test_cacheable_integer_field'])
+      ->setCacheMaxAge(31536000);
+    $this->setCacheability($cacheability);
+    $this->list[0] = $item;
+  }
+
+}
diff --git a/core/modules/system/tests/modules/entity_test/src/Plugin/Field/ComputedTestCacheableStringItemList.php b/core/modules/system/tests/modules/entity_test/src/Plugin/Field/ComputedTestCacheableStringItemList.php
index 21aaa9f7b0c7..307409babcee 100644
--- a/core/modules/system/tests/modules/entity_test/src/Plugin/Field/ComputedTestCacheableStringItemList.php
+++ b/core/modules/system/tests/modules/entity_test/src/Plugin/Field/ComputedTestCacheableStringItemList.php
@@ -8,6 +8,10 @@
 
 /**
  * Item list class for computed cacheable string field.
+ *
+ *  This class sets the cacheable metadata on the field item properties.
+ *
+ * @see \Drupal\entity_test\Plugin\Field\ComputedTestCacheableIntegerItemList
  */
 class ComputedTestCacheableStringItemList extends FieldItemList {
 
diff --git a/core/modules/system/tests/modules/entity_test/tests/src/Functional/Rest/EntityTestComputedFieldNormalizerTest.php b/core/modules/system/tests/modules/entity_test/tests/src/Functional/Rest/EntityTestComputedFieldNormalizerTest.php
index b6c914e32d09..2090db49aa60 100644
--- a/core/modules/system/tests/modules/entity_test/tests/src/Functional/Rest/EntityTestComputedFieldNormalizerTest.php
+++ b/core/modules/system/tests/modules/entity_test/tests/src/Functional/Rest/EntityTestComputedFieldNormalizerTest.php
@@ -56,6 +56,12 @@ protected function getExpectedNormalizedEntity() {
         'value' => 'computed test cacheable string field',
       ],
     ];
+    // @see \Drupal\entity_test\Plugin\Field\ComputedTestCacheableIntegerItemList::computeValue().
+    $expected['computed_test_cacheable_integer_field'] = [
+      [
+        'value' => 0,
+      ],
+    ];
 
     $expected['uuid'] = [
       0 => [
diff --git a/core/modules/system/tests/src/Functional/Entity/EntityComputedFieldTest.php b/core/modules/system/tests/src/Functional/Entity/EntityComputedFieldTest.php
new file mode 100644
index 000000000000..56e939986f7f
--- /dev/null
+++ b/core/modules/system/tests/src/Functional/Entity/EntityComputedFieldTest.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Drupal\Tests\system\Functional\Entity;
+
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\State\StateInterface;
+use Drupal\entity_test\Entity\EntityTestComputedField;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests that entities with computed fields work correctly.
+ *
+ * @group Entity
+ */
+class EntityComputedFieldTest extends BrowserTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  protected static $modules = ['entity_test'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'olivero';
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected StateInterface $state;
+
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->state = $this->container->get('state');
+  }
+
+  /**
+   * Tests that formatters bubble the cacheable metadata of computed fields.
+   */
+  public function testFormatterComputedFieldCacheableMetadata() {
+    $this->drupalLogin($this->drupalCreateUser(['administer entity_test content']));
+
+    $entity = EntityTestComputedField::create([
+      'name' => 'Test entity with a cacheable, computed field',
+    ]);
+    $entity->save();
+
+    $this->state->set('entity_test_computed_integer_value', 2024);
+    $this->drupalGet($entity->toUrl('canonical')->toString());
+    $field_item_selector = '.field--name-computed-test-cacheable-integer-field .field__item';
+    $this->assertSession()->elementTextEquals('css', $field_item_selector, 2024);
+    $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Contexts', 'url.query_args:computed_test_cacheable_integer_field');
+    $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'field:computed_test_cacheable_integer_field');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache-Max-Age', "31536000");
+
+    $this->state->set('entity_test_computed_integer_value', 2025);
+    $this->drupalGet($entity->toUrl('canonical')->toString());
+    $this->assertSession()->elementTextEquals('css', $field_item_selector, 2024);
+    $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Contexts', 'url.query_args:computed_test_cacheable_integer_field');
+    $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'field:computed_test_cacheable_integer_field');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache-Max-Age', "31536000");
+
+    Cache::invalidateTags(['field:computed_test_cacheable_integer_field']);
+    $this->drupalGet($entity->toUrl('canonical')->toString());
+    $this->assertSession()->elementTextEquals('css', $field_item_selector, 2025);
+    $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Contexts', 'url.query_args:computed_test_cacheable_integer_field');
+    $this->assertSession()->responseHeaderContains('X-Drupal-Cache-Tags', 'field:computed_test_cacheable_integer_field');
+    $this->assertSession()->responseHeaderEquals('X-Drupal-Cache-Max-Age', "31536000");
+  }
+
+}
-- 
GitLab