5 * Contains \Drupal\Tests\Core\Render\RendererBubblingTest.
8 namespace Drupal\Tests\Core\Render;
10 use Drupal\Core\Cache\MemoryBackend;
11 use Drupal\Core\KeyValueStore\KeyValueMemoryFactory;
12 use Drupal\Core\Lock\NullLockBackend;
13 use Drupal\Core\State\State;
14 use Drupal\Core\Cache\Cache;
17 * @coversDefaultClass \Drupal\Core\Render\Renderer
20 class RendererBubblingTest extends RendererTestBase {
25 protected function setUp() {
26 // Disable the required cache contexts, so that this test can test just the
28 $this->rendererConfig['required_cache_contexts'] = [];
34 * Tests bubbling of assets when NOT using #pre_render callbacks.
36 public function testBubblingWithoutPreRender() {
37 $this->setUpRequest();
38 $this->setupMemoryCache();
40 $this->cacheContextsManager->expects($this->any())
41 ->method('convertTokensToKeys')
42 ->willReturnArgument(0);
44 // Create an element with a child and subchild. Each element loads a
45 // different library using #attached.
47 '#type' => 'container',
49 'keys' => ['simpletest', 'renderer', 'children_attached'],
51 '#attached' => ['library' => ['test/parent']],
55 '#type' => 'container',
56 '#attached' => ['library' => ['test/child']],
59 $element['child']['subchild'] = [
60 '#attached' => ['library' => ['test/subchild']],
61 '#markup' => 'Subchild',
64 // Render the element and verify the presence of #attached JavaScript.
65 $this->renderer->renderRoot($element);
66 $expected_libraries = ['test/parent', 'test/child', 'test/subchild'];
67 $this->assertEquals($element['#attached']['library'], $expected_libraries, 'The element, child and subchild #attached libraries are included.');
69 // Load the element from cache and verify the presence of the #attached
71 $element = ['#cache' => ['keys' => ['simpletest', 'renderer', 'children_attached']]];
72 $this->assertTrue(strlen($this->renderer->renderRoot($element)) > 0, 'The element was retrieved from cache.');
73 $this->assertEquals($element['#attached']['library'], $expected_libraries, 'The element, child and subchild #attached libraries are included.');
77 * Tests cache context bubbling with a custom cache bin.
79 public function testContextBubblingCustomCacheBin() {
80 $bin = $this->randomMachineName();
82 $this->setUpRequest();
83 $this->memoryCache = new MemoryBackend();
84 $custom_cache = new MemoryBackend();
86 $this->cacheFactory->expects($this->atLeastOnce())
89 ->willReturnCallback(function ($requested_bin) use ($bin, $custom_cache) {
90 if ($requested_bin === $bin) {
94 throw new \Exception();
97 $this->cacheContextsManager->expects($this->any())
98 ->method('convertTokensToKeys')
99 ->willReturnArgument(0);
103 'keys' => ['parent'],
104 'contexts' => ['foo'],
107 '#markup' => 'parent',
110 'contexts' => ['bar'],
115 $this->renderer->renderRoot($build);
117 $this->assertRenderCacheItem('parent:foo', [
118 '#cache_redirect' => TRUE,
120 'keys' => ['parent'],
121 'contexts' => ['bar', 'foo'],
130 * Tests cache context bubbling in edge cases, because it affects the CID.
132 * ::testBubblingWithPrerender() already tests the common case.
134 * @dataProvider providerTestContextBubblingEdgeCases
136 public function testContextBubblingEdgeCases(array $element, array $expected_top_level_contexts, array $expected_cache_items) {
137 $this->setUpRequest();
138 $this->setupMemoryCache();
139 $this->cacheContextsManager->expects($this->any())
140 ->method('convertTokensToKeys')
141 ->willReturnArgument(0);
143 $this->renderer->renderRoot($element);
145 $this->assertEquals($expected_top_level_contexts, $element['#cache']['contexts'], 'Expected cache contexts found.');
146 foreach ($expected_cache_items as $cid => $expected_cache_item) {
147 $this->assertRenderCacheItem($cid, $expected_cache_item);
151 public function providerTestContextBubblingEdgeCases() {
154 // Cache contexts of inaccessible children aren't bubbled (because those
155 // children are not rendered at all).
158 'keys' => ['parent'],
161 '#markup' => 'parent',
165 'contexts' => ['foo'],
169 $expected_cache_items = [
175 'max-age' => Cache::PERMANENT,
177 '#markup' => 'parent',
180 $data[] = [$test_element, [], $expected_cache_items];
182 // Assert cache contexts are sorted when they are used to generate a CID.
183 // (Necessary to ensure that different render arrays where the same keys +
184 // set of contexts are present point to the same cache item. Regardless of
185 // the contexts' order. A sad necessity because PHP doesn't have sets.)
188 'keys' => ['set_test'],
192 $expected_cache_items = [
193 'set_test:bar:baz:foo' => [
198 'max-age' => Cache::PERMANENT,
204 ['foo', 'bar', 'baz'],
205 ['foo', 'baz', 'bar'],
206 ['bar', 'foo', 'baz'],
207 ['bar', 'baz', 'foo'],
208 ['baz', 'foo', 'bar'],
209 ['baz', 'bar', 'foo'],
211 foreach ($context_orders as $context_order) {
212 $test_element['#cache']['contexts'] = $context_order;
213 sort($context_order);
214 $expected_cache_items['set_test:bar:baz:foo']['#cache']['contexts'] = $context_order;
215 $data[] = [$test_element, $context_order, $expected_cache_items];
218 // A parent with a certain set of cache contexts is unaffected by a child
219 // that has a subset of those contexts.
222 'keys' => ['parent'],
223 'contexts' => ['foo', 'bar', 'baz'],
225 '#markup' => 'parent',
228 'contexts' => ['foo', 'baz'],
233 $expected_cache_items = [
234 'parent:bar:baz:foo' => [
237 'contexts' => ['bar', 'baz', 'foo'],
241 '#markup' => 'parent',
244 $data[] = [$test_element, ['bar', 'baz', 'foo'], $expected_cache_items];
246 // A parent with a certain set of cache contexts that is a subset of the
247 // cache contexts of a child gets a redirecting cache item for the cache ID
248 // created pre-bubbling (without the child's additional cache contexts). It
249 // points to a cache item with a post-bubbling cache ID (i.e. with the
250 // child's additional cache contexts).
251 // Furthermore, the redirecting cache item also includes the children's
252 // cache tags, since changes in the children may cause those children to get
253 // different cache contexts and therefore cause different cache contexts to
254 // be stored in the redirecting cache item.
257 'keys' => ['parent'],
258 'contexts' => ['foo'],
259 'tags' => ['yar', 'har'],
261 '#markup' => 'parent',
264 'contexts' => ['bar'],
265 'tags' => ['fiddle', 'dee'],
270 $expected_cache_items = [
272 '#cache_redirect' => TRUE,
274 // The keys + contexts this redirects to.
275 'keys' => ['parent'],
276 'contexts' => ['bar', 'foo'],
277 'tags' => ['dee', 'fiddle', 'har', 'yar'],
279 'max-age' => Cache::PERMANENT,
282 'parent:bar:foo' => [
285 'contexts' => ['bar', 'foo'],
286 'tags' => ['dee', 'fiddle', 'har', 'yar'],
287 'max-age' => Cache::PERMANENT,
289 '#markup' => 'parent',
292 $data[] = [$test_element, ['bar', 'foo'], $expected_cache_items];
294 // Ensure that bubbleable metadata has been collected from children and set
295 // correctly to the main level of the render array. That ensures that correct
296 // bubbleable metadata exists if render array gets rendered multiple times.
299 'keys' => ['parent'],
300 'tags' => ['yar', 'har']
302 '#markup' => 'parent',
304 '#render_children' => TRUE,
307 'contexts' => ['foo'],
308 'tags' => ['fiddle', 'dee'],
311 'library' => ['foo/bar']
317 $expected_cache_items = [
319 '#attached' => ['library' => ['foo/bar']],
321 'contexts' => ['foo'],
322 'tags' => ['dee', 'fiddle', 'har', 'yar'],
323 'max-age' => Cache::PERMANENT,
325 '#markup' => 'parent',
328 $data[] = [$test_element, ['foo'], $expected_cache_items];
334 * Tests the self-healing of the redirect with conditional cache contexts.
336 public function testConditionalCacheContextBubblingSelfHealing() {
337 $current_user_role = &$this->currentUserRole;
339 $this->setUpRequest();
340 $this->setupMemoryCache();
344 'keys' => ['parent'],
347 '#markup' => 'parent',
350 'contexts' => ['user.roles'],
354 '#access_callback' => function () use (&$current_user_role) {
355 // Only role A cannot access this subtree.
356 return $current_user_role !== 'A';
359 'contexts' => ['foo'],
361 // A lower max-age; the redirecting cache item should be updated.
364 'grandgrandchild' => [
365 '#access_callback' => function () use (&$current_user_role) {
366 // Only role C can access this subtree.
367 return $current_user_role === 'C';
370 'contexts' => ['bar'],
372 // A lower max-age; the redirecting cache item should be updated.
380 // Request 1: role A, the grandchild isn't accessible => bubbled cache
381 // contexts: user.roles.
382 $element = $test_element;
383 $current_user_role = 'A';
384 $this->renderer->renderRoot($element);
385 $this->assertRenderCacheItem('parent', [
386 '#cache_redirect' => TRUE,
388 'keys' => ['parent'],
389 'contexts' => ['user.roles'],
390 'tags' => ['a', 'b'],
392 'max-age' => Cache::PERMANENT,
395 $this->assertRenderCacheItem('parent:r.A', [
398 'contexts' => ['user.roles'],
399 'tags' => ['a', 'b'],
400 'max-age' => Cache::PERMANENT,
402 '#markup' => 'parent',
405 // Request 2: role B, the grandchild is accessible => bubbled cache
406 // contexts: foo, user.roles + merged max-age: 1800.
407 $element = $test_element;
408 $current_user_role = 'B';
409 $this->renderer->renderRoot($element);
410 $this->assertRenderCacheItem('parent', [
411 '#cache_redirect' => TRUE,
413 'keys' => ['parent'],
414 'contexts' => ['foo', 'user.roles'],
415 'tags' => ['a', 'b', 'c'],
420 $this->assertRenderCacheItem('parent:foo:r.B', [
423 'contexts' => ['foo', 'user.roles'],
424 'tags' => ['a', 'b', 'c'],
427 '#markup' => 'parent',
430 // Request 3: role A again, the grandchild is inaccessible again => bubbled
431 // cache contexts: user.roles; but that's a subset of the already-bubbled
432 // cache contexts, so nothing is actually changed in the redirecting cache
433 // item. However, the cache item we were looking for in request 1 is
434 // technically the same one we're looking for now (it's the exact same
435 // request), but with one additional cache context. This is necessary to
436 // avoid "cache ping-pong". (Requests 1 and 3 are identical, but without the
437 // right merging logic to handle request 2, the redirecting cache item would
438 // toggle between only the 'user.roles' cache context and both the 'foo'
439 // and 'user.roles' cache contexts, resulting in a cache miss every time.)
440 $element = $test_element;
441 $current_user_role = 'A';
442 $this->renderer->renderRoot($element);
443 $this->assertRenderCacheItem('parent', [
444 '#cache_redirect' => TRUE,
446 'keys' => ['parent'],
447 'contexts' => ['foo', 'user.roles'],
448 'tags' => ['a', 'b', 'c'],
453 $this->assertRenderCacheItem('parent:foo:r.A', [
456 'contexts' => ['foo', 'user.roles'],
457 'tags' => ['a', 'b'],
458 // Note that the max-age here is unaffected. When role A, the grandchild
459 // is never rendered, so neither is its max-age of 1800 present here,
460 // despite 1800 being the max-age of the redirecting cache item.
461 'max-age' => Cache::PERMANENT,
463 '#markup' => 'parent',
466 // Request 4: role C, both the grandchild and the grandgrandchild are
467 // accessible => bubbled cache contexts: foo, bar, user.roles + merged
469 $element = $test_element;
470 $current_user_role = 'C';
471 $this->renderer->renderRoot($element);
472 $final_parent_cache_item = [
473 '#cache_redirect' => TRUE,
475 'keys' => ['parent'],
476 'contexts' => ['bar', 'foo', 'user.roles'],
477 'tags' => ['a', 'b', 'c', 'd'],
482 $this->assertRenderCacheItem('parent', $final_parent_cache_item);
483 $this->assertRenderCacheItem('parent:bar:foo:r.C', [
486 'contexts' => ['bar', 'foo', 'user.roles'],
487 'tags' => ['a', 'b', 'c', 'd'],
490 '#markup' => 'parent',
493 // Request 5: role A again, verifying the merging like we did for request 3.
494 $element = $test_element;
495 $current_user_role = 'A';
496 $this->renderer->renderRoot($element);
497 $this->assertRenderCacheItem('parent', $final_parent_cache_item);
498 $this->assertRenderCacheItem('parent:bar:foo:r.A', [
501 'contexts' => ['bar', 'foo', 'user.roles'],
502 'tags' => ['a', 'b'],
503 // Note that the max-age here is unaffected. When role A, the grandchild
504 // is never rendered, so neither is its max-age of 1800 present here,
505 // nor the grandgrandchild's max-age of 300, despite 300 being the
506 // max-age of the redirecting cache item.
507 'max-age' => Cache::PERMANENT,
509 '#markup' => 'parent',
512 // Request 6: role B again, verifying the merging like we did for request 3.
513 $element = $test_element;
514 $current_user_role = 'B';
515 $this->renderer->renderRoot($element);
516 $this->assertRenderCacheItem('parent', $final_parent_cache_item);
517 $this->assertRenderCacheItem('parent:bar:foo:r.B', [
520 'contexts' => ['bar', 'foo', 'user.roles'],
521 'tags' => ['a', 'b', 'c'],
522 // Note that the max-age here is unaffected. When role B, the
523 // grandgrandchild is never rendered, so neither is its max-age of 300
524 // present here, despite 300 being the max-age of the redirecting cache
528 '#markup' => 'parent',
533 * Tests bubbling of bubbleable metadata added by #pre_render callbacks.
535 * @dataProvider providerTestBubblingWithPrerender
537 public function testBubblingWithPrerender($test_element) {
538 $this->setUpRequest();
539 $this->setupMemoryCache();
541 // Mock the State service.
542 $memory_state = new State(new KeyValueMemoryFactory(), new MemoryBackend('test'), new NullLockBackend());
543 \Drupal::getContainer()->set('state', $memory_state);
544 $this->controllerResolver->expects($this->any())
545 ->method('getControllerFromDefinition')
546 ->willReturnArgument(0);
548 // Simulate the theme system/Twig: a recursive call to Renderer::render(),
549 // just like the theme system or a Twig template would have done.
550 $this->themeManager->expects($this->any())
552 ->willReturnCallback(function ($hook, $vars) {
553 return $this->renderer->render($vars['foo']);
556 // ::bubblingPreRender() verifies that a #pre_render callback for a render
557 // array that is cacheable and …
558 // - … is cached does NOT get called. (Also mock a render cache item.)
559 // - … is not cached DOES get called.
560 \Drupal::state()->set('bubbling_nested_pre_render_cached', FALSE);
561 \Drupal::state()->set('bubbling_nested_pre_render_uncached', FALSE);
562 $this->memoryCache->set('cached_nested', ['#markup' => 'Cached nested!', '#attached' => [], '#cache' => ['contexts' => [], 'tags' => []]]);
564 // Simulate the rendering of an entire response (i.e. a root call).
565 $output = $this->renderer->renderRoot($test_element);
567 // First, assert the render array is of the expected form.
568 $this->assertEquals('Cache context!Cache tag!Asset!Placeholder!barquxNested!Cached nested!', trim($output), 'Expected HTML generated.');
569 $this->assertEquals(['child.cache_context'], $test_element['#cache']['contexts'], 'Expected cache contexts found.');
570 $this->assertEquals(['child:cache_tag'], $test_element['#cache']['tags'], 'Expected cache tags found.');
571 $expected_attached = [
572 'drupalSettings' => ['foo' => 'bar'],
573 'placeholders' => [],
575 $this->assertEquals($expected_attached, $test_element['#attached'], 'Expected attachments found.');
577 // Second, assert that #pre_render callbacks are only executed if they don't
578 // have a render cache hit (and hence a #pre_render callback for a render
579 // cached item cannot bubble more metadata).
580 $this->assertTrue(\Drupal::state()->get('bubbling_nested_pre_render_uncached'));
581 $this->assertFalse(\Drupal::state()->get('bubbling_nested_pre_render_cached'));
585 * Provides two test elements: one without, and one with the theme system.
589 public function providerTestBubblingWithPrerender() {
592 // Test element without theme.
596 '#pre_render' => [__NAMESPACE__ . '\\BubblingTest::bubblingPreRender'],
601 // Test element with theme.
604 '#theme' => 'common_test_render_element',
606 '#pre_render' => [__NAMESPACE__ . '\\BubblingTest::bubblingPreRender'],
615 * Tests that an element's cache keys cannot be changed during its rendering.
617 public function testOverWriteCacheKeys() {
618 $this->setUpRequest();
619 $this->setupMemoryCache();
621 // Ensure a logic exception
624 'keys' => ['llama', 'bar'],
626 '#pre_render' => [__NAMESPACE__ . '\\BubblingTest::bubblingCacheOverwritePrerender'],
628 $this->setExpectedException(\LogicException::class, 'Cache keys may not be changed after initial setup. Use the contexts property instead to bubble additional metadata.');
629 $this->renderer->renderRoot($data);
638 * #pre_render callback for testBubblingWithPrerender().
640 public static function bubblingPreRender($elements) {
642 'child_cache_context' => [
644 'contexts' => ['child.cache_context'],
646 '#markup' => 'Cache context!',
648 'child_cache_tag' => [
650 'tags' => ['child:cache_tag'],
652 '#markup' => 'Cache tag!',
656 'drupalSettings' => ['foo' => 'bar'],
658 '#markup' => 'Asset!',
660 'child_placeholder' => [
661 '#create_placeholder' => TRUE,
662 '#lazy_builder' => [__CLASS__ . '::bubblingPlaceholder', ['bar', 'qux']],
664 'child_nested_pre_render_uncached' => [
665 '#cache' => ['keys' => ['uncached_nested']],
666 '#pre_render' => [__CLASS__ . '::bubblingNestedPreRenderUncached'],
668 'child_nested_pre_render_cached' => [
669 '#cache' => ['keys' => ['cached_nested']],
670 '#pre_render' => [__CLASS__ . '::bubblingNestedPreRenderCached'],
677 * #pre_render callback for testBubblingWithPrerender().
679 public static function bubblingNestedPreRenderUncached($elements) {
680 \Drupal::state()->set('bubbling_nested_pre_render_uncached', TRUE);
681 $elements['#markup'] = 'Nested!';
686 * #pre_render callback for testBubblingWithPrerender().
688 public static function bubblingNestedPreRenderCached($elements) {
689 \Drupal::state()->set('bubbling_nested_pre_render_cached', TRUE);
694 * #lazy_builder callback for testBubblingWithPrerender().
696 public static function bubblingPlaceholder($foo, $baz) {
698 '#markup' => 'Placeholder!' . $foo . $baz,
703 * #pre_render callback for testOverWriteCacheKeys().
705 public static function bubblingCacheOverwritePrerender($elements) {
706 // Overwrite the #cache entry with new data.
707 $elements['#cache'] = [
708 'keys' => ['llama', 'foo'],
710 $elements['#markup'] = 'Setting cache keys just now!';