RendererPlaceholdersTest.php

Same filename and directory in other branches
  1. 11.x core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php
  2. 10 core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php
  3. 9 core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php
  4. 8.9.x core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php

Namespace

Drupal\Tests\Core\Render

File

core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php

View source
<?php

declare (strict_types=1);
namespace Drupal\Tests\Core\Render;

use Drupal\Component\Utility\Crypt;
use Drupal\Component\Utility\Html;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Render\Markup;
use Drupal\Core\Render\PlaceholderingRenderCache;
use Drupal\Core\Render\RenderCache;
use Drupal\Core\Render\RenderContext;
use Drupal\Core\Render\Renderer;
use Drupal\Core\Security\TrustedCallbackInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\MockObject\Builder\InvocationMocker;

/**
 * Tests Drupal\Core\Render\Renderer.
 */
class RendererPlaceholdersTest extends RendererTestBase {
  
  /**
   * {@inheritdoc}
   */
  protected function setUp() : void {
    // Disable the required cache contexts, so that this test can test just the
    // placeholder replacement behavior.
    $this->rendererConfig['required_cache_contexts'] = [];
    parent::setUp();
  }
  
  /**
   * Provides the two classes of placeholders: cacheable and uncacheable.
   *
   * I.e. with or without #cache[keys].
   *
   * Also, different types:
   * - A) automatically generated placeholder
   *   - 1) manually triggered (#create_placeholder = TRUE)
   *   - 2) automatically triggered (based on max-age = 0 at the top level)
   *   - 3) automatically triggered (based on high cardinality cache contexts at
   *        the top level)
   *   - 4) automatically triggered (based on high-invalidation frequency cache
   *        tags at the top level)
   *   - 5) automatically triggered (based on max-age = 0 in its subtree, i.e.
   *        via bubbling)
   *   - 6) automatically triggered (based on high cardinality cache contexts in
   *        its subtree, i.e. via bubbling)
   *   - 7) automatically triggered (based on high-invalidation frequency cache
   *        tags in its subtree, i.e. via bubbling)
   * - B) manually generated placeholder
   *
   * So, in total 2*8 = 16 permutations. (On one axis: uncacheable vs.
   * uncacheable = 2; on the other axis: A1–7 and B = 8.)
   *
   * @todo Case A5 is not yet supported by core. So that makes for only 14
   *   permutations currently, instead of 16. That will be done in
   *   https://www.drupal.org/node/2559847
   *
   * @return array
   *   An array of test cases.
   */
  public static function providerPlaceholders() : array {
    $args = [
      static::randomContextValue(),
    ];
    $generate_placeholder_markup = function ($cache_keys = NULL) use ($args) {
      $token_render_array = [
        '#lazy_builder' => [
          'Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callback',
          $args,
        ],
      ];
      if (is_array($cache_keys)) {
        $token_render_array['#cache']['keys'] = $cache_keys;
      }
      $token = Crypt::hashBase64(serialize($token_render_array));
      // \Drupal\Core\Render\Markup::create() is necessary as the render
      // system would mangle this markup. As this is exactly what happens at
      // runtime this is a valid use-case.
      return Markup::create('<drupal-render-placeholder callback="Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callback" arguments="0=' . $args[0] . '" token="' . $token . '"></drupal-render-placeholder>');
    };
    $extract_placeholder_render_array = function ($placeholder_render_array) {
      return array_intersect_key($placeholder_render_array, [
        '#lazy_builder' => TRUE,
        '#cache' => TRUE,
      ]);
    };
    // Note the presence of '#create_placeholder'.
    $base_element_a1 = [
      '#attached' => [
        'drupalSettings' => [
          'foo' => 'bar',
        ],
      ],
      'placeholder' => [
        '#cache' => [
          'contexts' => [],
        ],
        '#create_placeholder' => TRUE,
        '#lazy_builder' => [
          'Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callback',
          $args,
        ],
      ],
    ];
    // Note the absence of '#create_placeholder', presence of max-age=0 at the
    // top level.
    $base_element_a2 = [
      '#attached' => [
        'drupalSettings' => [
          'foo' => 'bar',
        ],
      ],
      'placeholder' => [
        '#cache' => [
          'contexts' => [],
          'max-age' => 0,
        ],
        '#lazy_builder' => [
          'Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callback',
          $args,
        ],
      ],
    ];
    // Note the absence of '#create_placeholder', presence of high cardinality
    // cache context at the top level.
    $base_element_a3 = [
      '#attached' => [
        'drupalSettings' => [
          'foo' => 'bar',
        ],
      ],
      'placeholder' => [
        '#cache' => [
          'contexts' => [
            'user',
          ],
        ],
        '#lazy_builder' => [
          'Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callback',
          $args,
        ],
      ],
    ];
    // Note the absence of '#create_placeholder', presence of high-invalidation
    // frequency cache tag at the top level.
    $base_element_a4 = [
      '#attached' => [
        'drupalSettings' => [
          'foo' => 'bar',
        ],
      ],
      'placeholder' => [
        '#cache' => [
          'contexts' => [],
          'tags' => [
            'current-temperature',
          ],
        ],
        '#lazy_builder' => [
          'Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callback',
          $args,
        ],
      ],
    ];
    // Note the absence of '#create_placeholder', presence of max-age=0 created
    // by the #lazy_builder callback.
    // @todo in https://www.drupal.org/node/2559847
    //   $base_element_a5 = [];
    // Note the absence of '#create_placeholder', presence of high cardinality
    // cache context created by the #lazy_builder callback.
    // @see \Drupal\Tests\Core\Render\PlaceholdersTest::callbackPerUser()
    $base_element_a6 = [
      '#attached' => [
        'drupalSettings' => [
          'foo' => 'bar',
        ],
      ],
      'placeholder' => [
        '#cache' => [
          'contexts' => [],
        ],
        '#lazy_builder' => [
          'Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callbackPerUser',
          $args,
        ],
      ],
    ];
    // Note the absence of '#create_placeholder', presence of high-invalidation
    // frequency cache tag created by the #lazy_builder callback.
    // @see \Drupal\Tests\Core\Render\PlaceholdersTest::callbackTagCurrentTemperature()
    $base_element_a7 = [
      '#attached' => [
        'drupalSettings' => [
          'foo' => 'bar',
        ],
      ],
      'placeholder' => [
        '#cache' => [
          'contexts' => [],
        ],
        '#lazy_builder' => [
          'Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callbackTagCurrentTemperature',
          $args,
        ],
      ],
    ];
    // Note the absence of '#create_placeholder', but the presence of
    // '#attached[placeholders]'.
    $base_element_b = [
      '#markup' => $generate_placeholder_markup(),
      '#attached' => [
        'drupalSettings' => [
          'foo' => 'bar',
        ],
        'placeholders' => [
          (string) $generate_placeholder_markup() => [
            '#lazy_builder' => [
              'Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callback',
              $args,
            ],
          ],
        ],
      ],
    ];
    $keys = [
      'placeholder',
      'output',
      'can',
      'be',
      'render',
      'cached',
      'too',
    ];
    $cases = [];
    // Case one: render array that has a placeholder that is:
    // - automatically created, but manually triggered (#create_placeholder =
    //   TRUE)
    // - uncacheable
    $element_without_cache_keys = $base_element_a1;
    $expected_placeholder_render_array = $extract_placeholder_render_array($base_element_a1['placeholder']);
    $cases[] = [
      $element_without_cache_keys,
      $args,
      $expected_placeholder_render_array,
      FALSE,
      [],
      [],
      [],
    ];
    // Case two: render array that has a placeholder that is:
    // - automatically created, but manually triggered (#create_placeholder =
    //   TRUE)
    // - cacheable
    $element_with_cache_keys = $base_element_a1;
    $element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
    $expected_placeholder_render_array['#cache']['keys'] = $keys;
    $cases[] = [
      $element_with_cache_keys,
      $args,
      $expected_placeholder_render_array,
      $keys,
      [],
      [],
      [
        '#markup' => '<p>This is a rendered placeholder!</p>',
        '#attached' => [
          'drupalSettings' => [
            'dynamic_animal' => $args[0],
          ],
        ],
        '#cache' => [
          'contexts' => [],
          'tags' => [],
          'max-age' => Cache::PERMANENT,
        ],
      ],
    ];
    // Case three: render array that has a placeholder that is:
    // - automatically created, and automatically triggered due to max-age=0
    // - uncacheable
    $element_without_cache_keys = $base_element_a2;
    $expected_placeholder_render_array = $extract_placeholder_render_array($base_element_a2['placeholder']);
    $cases[] = [
      $element_without_cache_keys,
      $args,
      $expected_placeholder_render_array,
      FALSE,
      [],
      [],
      [],
    ];
    // Case four: render array that has a placeholder that is:
    // - automatically created, but automatically triggered due to max-age=0
    // - cacheable
    $element_with_cache_keys = $base_element_a2;
    $element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
    $expected_placeholder_render_array['#cache']['keys'] = $keys;
    $cases[] = [
      $element_with_cache_keys,
      $args,
      $expected_placeholder_render_array,
      FALSE,
      [],
      [],
      [],
    ];
    // Case five: render array that has a placeholder that is:
    // - automatically created, and automatically triggered due to high
    //   cardinality cache contexts
    // - uncacheable
    $element_without_cache_keys = $base_element_a3;
    $expected_placeholder_render_array = $extract_placeholder_render_array($base_element_a3['placeholder']);
    $cases[] = [
      $element_without_cache_keys,
      $args,
      $expected_placeholder_render_array,
      FALSE,
      [],
      [],
      [],
    ];
    // Case six: render array that has a placeholder that is:
    // - automatically created, and automatically triggered due to high
    //   cardinality cache contexts
    // - cacheable
    $element_with_cache_keys = $base_element_a3;
    $element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
    $expected_placeholder_render_array['#cache']['keys'] = $keys;
    $cases[] = [
      $element_with_cache_keys,
      $args,
      $expected_placeholder_render_array,
      $keys,
      [],
      [],
      [
        '#markup' => '<p>This is a rendered placeholder!</p>',
        '#attached' => [
          'drupalSettings' => [
            'dynamic_animal' => $args[0],
          ],
        ],
        '#cache' => [
          'contexts' => [
            'user',
          ],
          'tags' => [],
          'max-age' => Cache::PERMANENT,
        ],
      ],
    ];
    // Case seven: render array that has a placeholder that is:
    // - automatically created, and automatically triggered due to high
    //   invalidation frequency cache tags
    // - uncacheable
    $element_without_cache_keys = $base_element_a4;
    $expected_placeholder_render_array = $extract_placeholder_render_array($base_element_a4['placeholder']);
    $cases[] = [
      $element_without_cache_keys,
      $args,
      $expected_placeholder_render_array,
      FALSE,
      [],
      [],
      [],
    ];
    // Case eight: render array that has a placeholder that is:
    // - automatically created, and automatically triggered due to high
    //   invalidation frequency cache tags
    // - cacheable
    $element_with_cache_keys = $base_element_a4;
    $element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
    $expected_placeholder_render_array['#cache']['keys'] = $keys;
    $cases[] = [
      $element_with_cache_keys,
      $args,
      $expected_placeholder_render_array,
      $keys,
      [],
      [],
      [
        '#markup' => '<p>This is a rendered placeholder!</p>',
        '#attached' => [
          'drupalSettings' => [
            'dynamic_animal' => $args[0],
          ],
        ],
        '#cache' => [
          'contexts' => [],
          'tags' => [
            'current-temperature',
          ],
          'max-age' => Cache::PERMANENT,
        ],
      ],
    ];
    // Case nine: render array that DOES NOT have a placeholder that is:
    // - NOT created, despite max-age=0 that is bubbled
    // - uncacheable
    // (because the render element with #lazy_builder does not have #cache[keys]
    // and hence the max-age=0 bubbles up further)
    // @todo in https://www.drupal.org/node/2559847
    // Case ten: render array that has a placeholder that is:
    // - automatically created, and automatically triggered due to max-age=0
    //   that is bubbled
    // - cacheable
    // @todo in https://www.drupal.org/node/2559847
    // Case eleven: render array that DOES NOT have a placeholder that is:
    // - NOT created, despite high cardinality cache contexts that are bubbled
    // - uncacheable
    $element_without_cache_keys = $base_element_a6;
    $expected_placeholder_render_array = $extract_placeholder_render_array($base_element_a6['placeholder']);
    $cases[] = [
      $element_without_cache_keys,
      $args,
      $expected_placeholder_render_array,
      FALSE,
      [
        'user',
      ],
      [],
      [],
    ];
    // Case twelve: render array that has a placeholder that is:
    // - automatically created, and automatically triggered due to high
    //   cardinality cache contexts that are bubbled
    // - cacheable
    $element_with_cache_keys = $base_element_a6;
    $element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
    $expected_placeholder_render_array['#cache']['keys'] = $keys;
    $cases[] = [
      $element_with_cache_keys,
      $args,
      $expected_placeholder_render_array,
      $keys,
      [
        'user',
      ],
      [],
      [
        '#markup' => '<p>This is a rendered placeholder!</p>',
        '#attached' => [
          'drupalSettings' => [
            'dynamic_animal' => $args[0],
          ],
        ],
        '#cache' => [
          'contexts' => [
            'user',
          ],
          'tags' => [],
          'max-age' => Cache::PERMANENT,
        ],
      ],
    ];
    // Case thirteen: render array that has a placeholder that is:
    // - automatically created, and automatically triggered due to high
    //   invalidation frequency cache tags that are bubbled
    // - uncacheable
    $element_without_cache_keys = $base_element_a7;
    $expected_placeholder_render_array = $extract_placeholder_render_array($base_element_a7['placeholder']);
    $cases[] = [
      $element_without_cache_keys,
      $args,
      $expected_placeholder_render_array,
      FALSE,
      [],
      [
        'current-temperature',
      ],
      [],
    ];
    // Case fourteen: render array that has a placeholder that is:
    // - automatically created, and automatically triggered due to high
    //   invalidation frequency cache tags that are bubbled
    // - cacheable
    $element_with_cache_keys = $base_element_a7;
    $element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
    $expected_placeholder_render_array['#cache']['keys'] = $keys;
    $cases[] = [
      $element_with_cache_keys,
      $args,
      $expected_placeholder_render_array,
      $keys,
      [],
      [],
      [
        '#markup' => '<p>This is a rendered placeholder!</p>',
        '#attached' => [
          'drupalSettings' => [
            'dynamic_animal' => $args[0],
          ],
        ],
        '#cache' => [
          'contexts' => [],
          'tags' => [
            'current-temperature',
          ],
          'max-age' => Cache::PERMANENT,
        ],
      ],
    ];
    // Case fifteen: render array that has a placeholder that is:
    // - manually created
    // - uncacheable
    $x = $base_element_b;
    $expected_placeholder_render_array = $x['#attached']['placeholders'][(string) $generate_placeholder_markup()];
    self::assertArrayNotHasKey('#cache', $expected_placeholder_render_array);
    $cases[] = [
      $x,
      $args,
      $expected_placeholder_render_array,
      FALSE,
      [],
      [],
      [],
    ];
    // Case sixteen: render array that has a placeholder that is:
    // - manually created
    // - cacheable
    $x = $base_element_b;
    $x['#markup'] = $placeholder_markup = $generate_placeholder_markup($keys);
    $placeholder_markup = (string) $placeholder_markup;
    $x['#attached']['placeholders'] = [
      $placeholder_markup => [
        '#lazy_builder' => [
          'Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callback',
          $args,
        ],
        '#cache' => [
          'keys' => $keys,
        ],
      ],
    ];
    $expected_placeholder_render_array = $x['#attached']['placeholders'][$placeholder_markup];
    $cases[] = [
      $x,
      $args,
      $expected_placeholder_render_array,
      $keys,
      [],
      [],
      [
        '#markup' => '<p>This is a rendered placeholder!</p>',
        '#attached' => [
          'drupalSettings' => [
            'dynamic_animal' => $args[0],
          ],
        ],
        '#cache' => [
          'contexts' => [],
          'tags' => [],
          'max-age' => Cache::PERMANENT,
        ],
      ],
    ];
    return $cases;
  }
  
  /**
   * Generates an element with a placeholder.
   *
   * @return array
   *   An array containing:
   *   - A render array containing a placeholder.
   *   - The context used for that #lazy_builder callback.
   */
  protected function generatePlaceholderElement() : array {
    $args = [
      static::randomContextValue(),
    ];
    $test_element = [];
    $test_element['#attached']['drupalSettings']['foo'] = 'bar';
    $test_element['placeholder']['#cache']['keys'] = [
      'placeholder',
      'output',
      'can',
      'be',
      'render',
      'cached',
      'too',
    ];
    $test_element['placeholder']['#cache']['contexts'] = [];
    $test_element['placeholder']['#create_placeholder'] = TRUE;
    $test_element['placeholder']['#lazy_builder'] = [
      'Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callback',
      $args,
    ];
    return [
      $test_element,
      $args,
    ];
  }
  
  /**
   * @param false|array $cache_keys
   *   The cache keys.
   * @param array $expected_data
   *   A render array with the expected values.
   *
   * @internal
   */
  protected function assertPlaceholderRenderCache($cache_keys, array $expected_data) : void {
    if ($cache_keys !== FALSE) {
      $cached = $this->memoryCache
        ->get($cache_keys, CacheableMetadata::createFromRenderArray($expected_data));
      $cached_element = $cached->data;
      $this->assertEquals($expected_data, $cached_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by the placeholder being replaced.');
    }
  }
  
  /**
   * Tests uncacheable parent.
   *
   * @legacy-covers ::render
   * @legacy-covers ::doRender
   */
  public function testUncacheableParent(array $element, array $args, array $expected_placeholder_render_array, array|false $placeholder_cache_keys, array $bubbled_cache_contexts, array $bubbled_cache_tags, array $placeholder_expected_render_cache_array) : void {
    if ($placeholder_cache_keys) {
      $this->setUpMemoryCache();
    }
    else {
      $this->setUpUnusedCache();
    }
    $this->setUpRequest('GET');
    // No #cache on parent element.
    $element['#prefix'] = '<p>#cache disabled</p>';
    $output = $this->renderer
      ->renderRoot($element);
    $this->assertSame('<p>#cache disabled</p><p>This is a rendered placeholder!</p>', (string) $output, 'Output is overridden.');
    $this->assertSame('<p>#cache disabled</p><p>This is a rendered placeholder!</p>', (string) $element['#markup'], '#markup is overridden.');
    $expected_js_settings = [
      'foo' => 'bar',
      'dynamic_animal' => $args[0],
    ];
    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.');
    $this->assertPlaceholderRenderCache($placeholder_cache_keys, $placeholder_expected_render_cache_array);
  }
  
  /**
   * Tests cacheable parent.
   *
   * @legacy-covers ::render
   * @legacy-covers ::doRender
   * @legacy-covers \Drupal\Core\Render\RenderCache::get
   * @legacy-covers \Drupal\Core\Render\RenderCache::set
   */
  public function testCacheableParent(array $test_element, array $args, array $expected_placeholder_render_array, array|false $placeholder_cache_keys, array $bubbled_cache_contexts, array $bubbled_cache_tags, array $placeholder_expected_render_cache_array) : void {
    $element = $test_element;
    $this->setupMemoryCache();
    $this->setUpRequest('GET');
    $token = Crypt::hashBase64(serialize($expected_placeholder_render_array));
    $placeholder_callback = $expected_placeholder_render_array['#lazy_builder'][0];
    $expected_placeholder_markup = '<drupal-render-placeholder callback="' . $placeholder_callback . '" arguments="0=' . $args[0] . '" token="' . $token . '"></drupal-render-placeholder>';
    $this->assertSame($expected_placeholder_markup, Html::normalize($expected_placeholder_markup), 'Placeholder unaltered by Html::normalize() which is used by FilterHtmlCorrector.');
    // GET request: #cache enabled, cache miss.
    $element['#cache'] = [
      'keys' => [
        'placeholder_test_GET',
      ],
    ];
    $element['#prefix'] = '<p>#cache enabled, GET</p>';
    $output = $this->renderer
      ->renderRoot($element);
    $this->assertSame('<p>#cache enabled, GET</p><p>This is a rendered placeholder!</p>', (string) $output, 'Output is overridden.');
    $this->assertTrue(isset($element['#printed']), 'No cache hit');
    $this->assertSame('<p>#cache enabled, GET</p><p>This is a rendered placeholder!</p>', (string) $element['#markup'], '#markup is overridden.');
    $expected_js_settings = [
      'foo' => 'bar',
      'dynamic_animal' => $args[0],
    ];
    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.');
    $this->assertPlaceholderRenderCache($placeholder_cache_keys, $placeholder_expected_render_cache_array);
    // GET request: validate cached data.
    $cached = $this->memoryCache
      ->get([
      'placeholder_test_GET',
    ], CacheableMetadata::createFromRenderArray($test_element));
    // There are three edge cases, where the shape of the render cache item for
    // the parent (with CID 'placeholder_test_GET') is vastly different. These
    // are the cases where:
    // - the placeholder is uncacheable (because it has no #cache[keys]), and;
    // - cacheability metadata that meets auto_placeholder_conditions is bubbled
    $has_uncacheable_lazy_builder = !isset($test_element['placeholder']['#cache']['keys']) && isset($test_element['placeholder']['#lazy_builder']);
    // Edge cases: always where both bubbling of an auto-placeholdering
    // condition happens from within a #lazy_builder that is uncacheable.
    // - uncacheable + A5 (cache max-age)
    // @todo in https://www.drupal.org/node/2559847
    // - uncacheable + A6 (cache context)
    $edge_case_a6_uncacheable = $has_uncacheable_lazy_builder && $test_element['placeholder']['#lazy_builder'][0] === 'Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callbackPerUser';
    // - uncacheable + A7 (cache tag)
    $edge_case_a7_uncacheable = $has_uncacheable_lazy_builder && $test_element['placeholder']['#lazy_builder'][0] === 'Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callbackTagCurrentTemperature';
    // The redirect-cacheable edge case: a high-cardinality cache context is
    // bubbled from a #lazy_builder callback for an uncacheable placeholder. The
    // element containing the uncacheable placeholder has cache keys set, and
    // due to the bubbled cache contexts it creates a cache redirect.
    if ($edge_case_a6_uncacheable) {
      $cached_element = $cached->data;
      $expected_element = [
        '#markup' => '<p>#cache enabled, GET</p><p>This is a rendered placeholder!</p>',
        '#attached' => [
          'drupalSettings' => [
            'foo' => 'bar',
            'dynamic_animal' => $args[0],
          ],
        ],
        '#cache' => [
          'contexts' => $bubbled_cache_contexts,
          'tags' => [],
          'max-age' => Cache::PERMANENT,
        ],
      ];
      $this->assertEquals($expected_element, $cached_element, 'The parent is render cached with a redirect in ase a cache context is bubbled from an uncacheable child (no #cache[keys]) with a #lazy_builder.');
    }
    elseif ($edge_case_a7_uncacheable) {
      $cached_element = $cached->data;
      $expected_element = [
        '#markup' => '<p>#cache enabled, GET</p><p>This is a rendered placeholder!</p>',
        '#attached' => [
          'drupalSettings' => [
            'foo' => 'bar',
            'dynamic_animal' => $args[0],
          ],
        ],
        '#cache' => [
          'contexts' => [],
          'tags' => $bubbled_cache_tags,
          'max-age' => Cache::PERMANENT,
        ],
      ];
      $this->assertEquals($expected_element, $cached_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by placeholder #lazy_builder callbacks.');
    }
    else {
      $cached_element = $cached->data;
      $expected_element = [
        '#markup' => '<p>#cache enabled, GET</p>' . $expected_placeholder_markup,
        '#attached' => [
          'drupalSettings' => [
            'foo' => 'bar',
          ],
          'placeholders' => [
            $expected_placeholder_markup => [
              '#lazy_builder' => [
                'Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callback',
                $args,
              ],
            ],
          ],
        ],
        '#cache' => [
          'contexts' => [],
          'tags' => $bubbled_cache_tags,
          'max-age' => Cache::PERMANENT,
        ],
      ];
      $expected_element['#attached']['placeholders'][$expected_placeholder_markup] = $expected_placeholder_render_array;
      $this->assertEquals($expected_element, $cached_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by placeholder #lazy_builder callbacks.');
    }
    // GET request: #cache enabled, cache hit.
    $element = $test_element;
    $element['#cache'] = [
      'keys' => [
        'placeholder_test_GET',
      ],
    ];
    $element['#prefix'] = '<p>#cache enabled, GET</p>';
    $output = $this->renderer
      ->renderRoot($element);
    $this->assertSame('<p>#cache enabled, GET</p><p>This is a rendered placeholder!</p>', (string) $output, 'Output is overridden.');
    $this->assertFalse(isset($element['#printed']), 'Cache hit');
    $this->assertSame('<p>#cache enabled, GET</p><p>This is a rendered placeholder!</p>', (string) $element['#markup'], '#markup is overridden.');
    $expected_js_settings = [
      'foo' => 'bar',
      'dynamic_animal' => $args[0],
    ];
    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.');
  }
  
  /**
   * Tests cacheable parent with post request.
   *
   * @legacy-covers ::render
   * @legacy-covers ::doRender
   * @legacy-covers \Drupal\Core\Render\RenderCache::get
   * @legacy-covers ::replacePlaceholders
   */
  public function testCacheableParentWithPostRequest(array $test_element, array $args, array $expected_placeholder_render_array, array|false $placeholder_cache_keys, array $bubbled_cache_contexts, array $bubbled_cache_tags, array $placeholder_expected_render_cache_array) : void {
    $this->setUpMemoryCache();
    // Verify behavior when handling a non-GET request, e.g. a POST request:
    // also in that case, placeholders must be replaced.
    $this->setUpRequest('POST');
    // POST request: #cache enabled, cache miss.
    $element = $test_element;
    $element['#cache'] = [
      'keys' => [
        'placeholder_test_POST',
      ],
    ];
    $element['#prefix'] = '<p>#cache enabled, POST</p>';
    $output = $this->renderer
      ->renderRoot($element);
    $this->assertSame('<p>#cache enabled, POST</p><p>This is a rendered placeholder!</p>', (string) $output, 'Output is overridden.');
    $this->assertTrue(isset($element['#printed']), 'No cache hit');
    $this->assertSame('<p>#cache enabled, POST</p><p>This is a rendered placeholder!</p>', (string) $element['#markup'], '#markup is overridden.');
    $expected_js_settings = [
      'foo' => 'bar',
      'dynamic_animal' => $args[0],
    ];
    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.');
    // Even when the child element's placeholder is cacheable, it should not
    // generate a render cache item.
    $this->assertPlaceholderRenderCache(FALSE, []);
  }
  
  /**
   * Tests placeholdering disabled for post requests.
   *
   * @legacy-covers ::render
   * @legacy-covers ::doRender
   * @legacy-covers \Drupal\Core\Render\RenderCache::get
   * @legacy-covers \Drupal\Core\Render\PlaceholderingRenderCache::get
   * @legacy-covers \Drupal\Core\Render\PlaceholderingRenderCache::set
   * @legacy-covers ::replacePlaceholders
   */
  public function testPlaceholderingDisabledForPostRequests(array $test_element, array $args, array $expected_placeholder_render_array, array|false $placeholder_cache_keys, array $bubbled_cache_contexts, array $bubbled_cache_tags, array $placeholder_expected_render_cache_array) : void {
    if ($placeholder_cache_keys && !empty($test_element['placeholder']['#cache']['keys'])) {
      $this->setUpMemoryCache();
    }
    else {
      $this->setUpUnusedCache();
    }
    $this->setUpRequest('POST');
    $element = $test_element;
    // Render without replacing placeholders, to allow this test to see which
    // #attached[placeholders] there are, if any.
    $this->renderer
      ->executeInRenderContext(new RenderContext(), function () use (&$element) {
      return $this->renderer
        ->render($element);
    });
    // Only test cases where the placeholders have been specified manually are
    // allowed to have placeholders. This means that of the different situations
    // listed in providerPlaceholders(), only type B can have attached
    // placeholders. Everything else, whether:
    // 1. manual placeholdering
    // 2. automatic placeholdering via already-present cacheability metadata
    // 3. automatic placeholdering via bubbled cacheability metadata
    // All three of those should NOT result in placeholders.
    if (!isset($test_element['#attached']['placeholders'])) {
      $this->assertFalse(isset($element['#attached']['placeholders']), 'No placeholders created.');
    }
  }
  
  /**
   * Tests a placeholder that adds another placeholder.
   *
   * E.g. when rendering a node in a placeholder the rendering of that node
   * needs a placeholder of its own to be executed (to render the node links).
   *
   * @legacy-covers ::render
   * @legacy-covers ::doRender
   * @legacy-covers ::replacePlaceholders
   */
  public function testRecursivePlaceholder() : void {
    $args = [
      static::randomContextValue(),
    ];
    $element = [];
    $element['#create_placeholder'] = TRUE;
    $element['#lazy_builder'] = [
      'Drupal\\Tests\\Core\\Render\\RecursivePlaceholdersTest::callback',
      $args,
    ];
    $output = $this->renderer
      ->renderRoot($element);
    $this->assertEquals('<p>This is a rendered placeholder!</p>', $output, 'The output has been modified by the indirect, recursive placeholder #lazy_builder callback.');
    $this->assertSame((string) $element['#markup'], '<p>This is a rendered placeholder!</p>', '#markup is overridden by the indirect, recursive placeholder #lazy_builder callback.');
    $expected_js_settings = [
      'dynamic_animal' => $args[0],
    ];
    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified by the indirect, recursive placeholder #lazy_builder callback.');
  }
  
  /**
   * Tests invalid lazy builder.
   *
   * @legacy-covers ::render
   * @legacy-covers ::doRender
   */
  public function testInvalidLazyBuilder() : void {
    $element = [];
    $element['#lazy_builder'] = '\\Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callback';
    $this->expectException(\AssertionError::class);
    $this->expectExceptionMessage('The #lazy_builder property must have an array as a value.');
    $this->renderer
      ->renderRoot($element);
  }
  
  /**
   * Tests invalid lazy builder arguments.
   *
   * @legacy-covers ::render
   * @legacy-covers ::doRender
   */
  public function testInvalidLazyBuilderArguments() : void {
    $element = [];
    $element['#lazy_builder'] = [
      '\\Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callback',
      'arg1',
      'arg2',
    ];
    $this->expectException(\AssertionError::class);
    $this->expectExceptionMessage('The #lazy_builder property must have an array as a value, containing two values: the callback, and the arguments for the callback.');
    $this->renderer
      ->renderRoot($element);
  }
  
  /**
   * Tests scalar lazy builder callback context.
   *
   * @see testNonScalarLazyBuilderCallbackContext
   * @legacy-covers ::render
   * @legacy-covers ::doRender
   */
  public function testScalarLazyBuilderCallbackContext() : void {
    $element = [];
    $element['#lazy_builder'] = [
      '\\Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callback',
      [
        'string' => 'foo',
        'bool' => TRUE,
        'int' => 1337,
        'float' => 3.14,
        'null' => NULL,
      ],
    ];
    $result = $this->renderer
      ->renderRoot($element);
    $this->assertInstanceOf('\\Drupal\\Core\\Render\\Markup', $result);
    $this->assertEquals('<p>This is a rendered placeholder!</p>', (string) $result);
  }
  
  /**
   * Tests non scalar lazy builder callback context.
   *
   * @legacy-covers ::render
   * @legacy-covers ::doRender
   */
  public function testNonScalarLazyBuilderCallbackContext() : void {
    $element = [];
    $element['#lazy_builder'] = [
      '\\Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callback',
      [
        'string' => 'foo',
        'bool' => TRUE,
        'int' => 1337,
        'float' => 3.14,
        'null' => NULL,
        // Array is not one of the scalar types.
'array' => [
          'hi!',
        ],
      ],
    ];
    $this->expectException(\AssertionError::class);
    $this->expectExceptionMessage("A #lazy_builder callback's context may only contain scalar values or NULL.");
    $this->renderer
      ->renderRoot($element);
  }
  
  /**
   * Tests children plus builder.
   *
   * @legacy-covers ::render
   * @legacy-covers ::doRender
   */
  public function testChildrenPlusBuilder() : void {
    $element = [];
    $element['#lazy_builder'] = [
      'Drupal\\Tests\\Core\\Render\\RecursivePlaceholdersTest::callback',
      [],
    ];
    $element['child_a']['#markup'] = 'Oh hai!';
    $element['child_b']['#markup'] = 'goodbye';
    $this->expectException(\AssertionError::class);
    $this->expectExceptionMessage('When a #lazy_builder callback is specified, no children can exist; all children must be generated by the #lazy_builder callback. You specified the following children: child_a, child_b.');
    $this->renderer
      ->renderRoot($element);
  }
  
  /**
   * Tests properties plus builder.
   *
   * @legacy-covers ::render
   * @legacy-covers ::doRender
   */
  public function testPropertiesPlusBuilder() : void {
    $element = [];
    $element['#lazy_builder'] = [
      'Drupal\\Tests\\Core\\Render\\RecursivePlaceholdersTest::callback',
      [],
    ];
    $element['#llama'] = '#awesome';
    $element['#piglet'] = '#cute';
    $this->expectException(\AssertionError::class);
    $this->expectExceptionMessage('When a #lazy_builder callback is specified, no properties can exist; all properties must be generated by the #lazy_builder callback. You specified the following properties: #llama, #piglet.');
    $this->renderer
      ->renderRoot($element);
  }
  
  /**
   * Tests create placeholder property without lazy builder.
   *
   * @legacy-covers ::render
   * @legacy-covers ::doRender
   */
  public function testCreatePlaceholderPropertyWithoutLazyBuilder() : void {
    $element = [];
    $element['#create_placeholder'] = TRUE;
    $this->expectException(\LogicException::class);
    $this->expectExceptionMessage('When #create_placeholder is set, a #lazy_builder callback must be present as well.');
    $this->renderer
      ->renderRoot($element);
  }
  
  /**
   * Tests that an error is thrown if a lazy builder doesn't return an array.
   */
  public function testNonArrayReturnFromLazyBuilder() : void {
    $element = [
      '#lazy_builder' => [
        '\\Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callbackNonArrayReturn',
        [],
      ],
    ];
    $this->expectException('AssertionError');
    $this->expectExceptionMessage("#lazy_builder callbacks must return a valid renderable array, got boolean from \\Drupal\\Tests\\Core\\Render\\PlaceholdersTest::callbackNonArrayReturn");
    $this->renderer
      ->renderRoot($element);
  }
  
  /**
   * Tests the creation of an element with a child and a sub-child.
   *
   * Each element has the same #lazy_builder callback, but with different
   * contexts. They don't modify markup, only attach additional drupalSettings.
   *
   * @legacy-covers ::render
   * @legacy-covers ::doRender
   * @legacy-covers \Drupal\Core\Render\RenderCache::get
   * @legacy-covers ::replacePlaceholders
   */
  public function testRenderChildrenPlaceholdersDifferentArguments() : void {
    $this->setUpRequest();
    $this->setupMemoryCache();
    $this->cacheContextsManager
      ->expects($this->any())
      ->method('convertTokensToKeys')
      ->willReturnArgument(0);
    $this->callableResolver
      ->expects($this->any())
      ->method('getCallableFromDefinition')
      ->willReturnArgument(0);
    $this->setupThemeManagerForDetails();
    $args_1 = [
      'foo',
      TRUE,
    ];
    $args_2 = [
      'bar',
      TRUE,
    ];
    $args_3 = [
      'baz',
      TRUE,
    ];
    $test_element = $this->generatePlaceholdersWithChildrenTestElement($args_1, $args_2, $args_3);
    $element = $test_element;
    $output = $this->renderer
      ->renderRoot($element);
    $expected_output = <<<HTML
    <details>
      <summary>Parent</summary>
      <div class="details-wrapper"><details>
      <summary>Child</summary>
      <div class="details-wrapper">Subchild</div>
    </details></div>
    </details>
    HTML;
    $this->assertSame($expected_output, (string) $output, 'Output is not overridden.');
    $this->assertTrue(isset($element['#printed']), 'No cache hit');
    $this->assertSame($expected_output, (string) $element['#markup'], '#markup is not overridden.');
    $expected_js_settings = [
      'foo' => 'bar',
      'dynamic_animal' => [
        $args_1[0] => TRUE,
        $args_2[0] => TRUE,
        $args_3[0] => TRUE,
      ],
    ];
    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each placeholder #lazy_builder callback exist.');
    // GET request: validate cached data.
    $cached_element = $this->memoryCache
      ->get([
      'test',
      'renderer',
      'children_placeholders',
    ], CacheableMetadata::createFromRenderArray($element))->data;
    $expected_element = [
      '#attached' => [
        'drupalSettings' => [
          'foo' => 'bar',
        ],
        'placeholders' => [
          'parent-x-parent' => [
            '#lazy_builder' => [
              __NAMESPACE__ . '\\PlaceholdersTest::callback',
              $args_1,
            ],
          ],
          'child-x-child' => [
            '#lazy_builder' => [
              __NAMESPACE__ . '\\PlaceholdersTest::callback',
              $args_2,
            ],
          ],
          'subchild-x-subchild' => [
            '#lazy_builder' => [
              __NAMESPACE__ . '\\PlaceholdersTest::callback',
              $args_3,
            ],
          ],
        ],
      ],
      '#cache' => [
        'contexts' => [],
        'tags' => [],
        'max-age' => Cache::PERMANENT,
      ],
    ];
    // Verify that the correct data is cached: the stored #markup is not
    // affected by placeholder #lazy_builder callbacks.
    $dom = Html::load($cached_element['#markup']);
    $xpath = new \DOMXPath($dom);
    $this->assertNotEmpty($xpath->query('//details/summary[text()="Parent"]')->length);
    $this->assertNotEmpty($xpath->query('//details/div[@class="details-wrapper"]/details/summary[text()="Child"]')->length);
    $this->assertNotEmpty($xpath->query('//details/div[@class="details-wrapper"]/details/div[@class="details-wrapper" and text()="Subchild"]')->length);
    // Remove markup because it's compared above in the xpath.
    unset($cached_element['#markup']);
    $this->assertEquals($cached_element, $expected_element, 'The correct data is cached: the stored #attached properties are not affected by placeholder #lazy_builder callbacks.');
    // GET request: #cache enabled, cache hit.
    $element = $test_element;
    $output = $this->renderer
      ->renderRoot($element);
    $this->assertSame($expected_output, (string) $output, 'Output is not overridden.');
    $this->assertFalse(isset($element['#printed']), 'Cache hit');
    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each placeholder #lazy_builder callback exist.');
    // Use the exact same element, but now unset #cache; ensure we get the same
    // result.
    unset($test_element['#cache']);
    $element = $test_element;
    $output = $this->renderer
      ->renderRoot($element);
    $this->assertSame($expected_output, (string) $output, 'Output is not overridden.');
    $this->assertSame($expected_output, (string) $element['#markup'], '#markup is not overridden.');
    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each #lazy_builder callback exist.');
  }
  
  /**
   * Tests the creation of an element with a lazy_builder_preview.
   *
   * @legacy-covers ::render
   * @legacy-covers ::doRender
   * @legacy-covers \Drupal\Core\Render\RenderCache::get
   * @legacy-covers ::replacePlaceholders
   */
  public function testRenderLazyBuilderPreview() : void {
    $this->setUpRequest();
    $this->setupMemoryCache();
    $this->renderCache = new TestPlaceholderingRenderCache($this->requestStack, $this->cacheFactory, $this->cacheContextsManager, $this->placeholderGenerator);
    $this->renderer = new Renderer($this->callableResolver, $this->themeManager, $this->elementInfo, $this->placeholderGenerator, $this->renderCache, $this->requestStack, $this->rendererConfig);
    $this->cacheContextsManager
      ->expects($this->any())
      ->method('convertTokensToKeys')
      ->willReturnArgument(0);
    $this->callableResolver
      ->expects($this->any())
      ->method('getCallableFromDefinition')
      ->willReturnArgument(0);
    $test_element = $this->generatePlaceholderWithLazyBuilderPreview();
    $element1 = $element2 = $test_element;
    // Render the element twice so that it is in the render cache.
    $this->renderer
      ->renderRoot($element1);
    $this->renderer
      ->renderRoot($element2);
    $placeholder_string = (string) $this->renderCache->placeholderElements[0]['#markup'];
    $this->assertSame($this->renderCache->placeholderElements[0]['#attached']['placeholders'][$placeholder_string]['#preview'], [
      '#markup' => 'Lazy Builder Preview',
    ]);
  }
  
  /**
   * Generates an element with a lazy builder and preview.
   */
  public function generatePlaceholderWithLazyBuilderPreview() : array {
    return [
      '#cache' => [
        'keys' => [
          'test_render',
        ],
      ],
      '#lazy_builder' => [
        __NAMESPACE__ . '\\PlaceholdersTest::callbackPerUser',
        [
          'foo',
        ],
      ],
      '#lazy_builder_preview' => [
        '#markup' => 'Lazy Builder Preview',
      ],
    ];
  }
  
  /**
   * Generates an element with placeholders at 3 levels.
   *
   * @param array $args_1
   *   The arguments for the placeholder at level 1.
   * @param array $args_2
   *   The arguments for the placeholder at level 2.
   * @param array $args_3
   *   The arguments for the placeholder at level 3.
   *
   * @return array
   *   The generated render array for testing.
   */
  protected function generatePlaceholdersWithChildrenTestElement(array $args_1, array $args_2, array $args_3) : array {
    $test_element = [
      '#type' => 'details',
      '#cache' => [
        'keys' => [
          'test',
          'renderer',
          'children_placeholders',
        ],
      ],
      '#title' => 'Parent',
      '#attached' => [
        'drupalSettings' => [
          'foo' => 'bar',
        ],
        'placeholders' => [
          'parent-x-parent' => [
            '#lazy_builder' => [
              __NAMESPACE__ . '\\PlaceholdersTest::callback',
              $args_1,
            ],
          ],
        ],
      ],
    ];
    $test_element['child'] = [
      '#type' => 'details',
      '#attached' => [
        'placeholders' => [
          'child-x-child' => [
            '#lazy_builder' => [
              __NAMESPACE__ . '\\PlaceholdersTest::callback',
              $args_2,
            ],
          ],
        ],
      ],
      '#title' => 'Child',
    ];
    $test_element['child']['subchild'] = [
      '#attached' => [
        'placeholders' => [
          'subchild-x-subchild' => [
            '#lazy_builder' => [
              __NAMESPACE__ . '\\PlaceholdersTest::callback',
              $args_3,
            ],
          ],
        ],
      ],
      '#markup' => 'Subchild',
    ];
    return $test_element;
  }
  
  /**
   * Sets up the theme manager for the <details>-tag.
   *
   * @return \Drupal\Core\Theme\ThemeManagerInterface|\PHPUnit\Framework\MockObject\Builder\InvocationMocker
   *   The mocked theme manager.
   */
  protected function setupThemeManagerForDetails() : InvocationMocker {
    return $this->themeManager
      ->expects($this->any())
      ->method('render')
      ->willReturnCallback(function ($theme, array $vars) {
      $output = <<<'EOS'
      <details>
        <summary>{{ title }}</summary>
        <div class="details-wrapper">{{ children }}</div>
      </details>
      EOS;
      $output = str_replace([
        '{{ title }}',
        '{{ children }}',
      ], [
        $vars['#title'],
        $vars['#children'],
      ], $output);
      return $output;
    });
  }

}

/**
 * @see \Drupal\Tests\Core\Render\RendererPlaceholdersTest::testRecursivePlaceholder()
 */
class RecursivePlaceholdersTest implements TrustedCallbackInterface {
  
  /**
   * Render API callback: Bubbles another placeholder.
   *
   * This function is assigned as a #lazy_builder callback.
   *
   * @param string $animal
   *   An animal.
   *
   * @return array
   *   A renderable array.
   */
  public static function callback($animal) : array {
    return [
      'another' => [
        '#create_placeholder' => TRUE,
        '#lazy_builder' => [
          PlaceholdersTest::class . '::callback',
          [
            $animal,
          ],
        ],
      ],
    ];
  }
  
  /**
   * {@inheritdoc}
   */
  public static function trustedCallbacks() : array {
    return [
      'callback',
    ];
  }

}

/**
 * Class for testing the placeholdering render cache.
 */
class TestPlaceholderingRenderCache extends PlaceholderingRenderCache {
  
  /**
   * The placeholder elements created during rendering.
   */
  public array $placeholderElements = [];
  protected function createPlaceholderAndRemember(array $rendered_elements, array $pre_bubbling_elements) {
    $placeholder_element = parent::createPlaceholderAndRemember($rendered_elements, $pre_bubbling_elements);
    $this->placeholderElements[] = $placeholder_element;
    return $placeholder_element;
  }

}

Classes

Title Deprecated Summary
RecursivePlaceholdersTest
RendererPlaceholdersTest Tests Drupal\Core\Render\Renderer.
TestPlaceholderingRenderCache Class for testing the placeholdering render cache.

Buggy or inaccurate documentation? Please file an issue. Need support? Need help programming? Connect with the Drupal community.