5 * Contains \Drupal\Tests\Core\Render\RendererTest.
8 namespace Drupal\Tests\Core\Render;
10 use Drupal\Component\Render\MarkupInterface;
11 use Drupal\Core\Access\AccessResult;
12 use Drupal\Core\Access\AccessResultInterface;
13 use Drupal\Core\Cache\Cache;
14 use Drupal\Core\Render\Element;
15 use Drupal\Core\Render\Markup;
16 use Drupal\Core\Template\Attribute;
19 * @coversDefaultClass \Drupal\Core\Render\Renderer
22 class RendererTest extends RendererTestBase {
24 protected $defaultThemeVars = [
27 'languages:language_interface',
31 'max-age' => Cache::PERMANENT,
41 * @dataProvider providerTestRenderBasic
43 public function testRenderBasic($build, $expected, callable $setup_code = NULL) {
44 if (isset($setup_code)) {
45 $setup_code = $setup_code->bindTo($this);
49 if (isset($build['#markup'])) {
50 $this->assertNotInstanceOf(MarkupInterface::class, $build['#markup'], 'The #markup value is not marked safe before rendering.');
52 $render_output = $this->renderer->renderRoot($build);
53 $this->assertSame($expected, (string) $render_output);
54 if ($render_output !== '') {
55 $this->assertInstanceOf(MarkupInterface::class, $render_output, 'Output of render is marked safe.');
56 $this->assertInstanceOf(MarkupInterface::class, $build['#markup'], 'The #markup value is marked safe after rendering.');
61 * Provides a list of render arrays to test basic rendering.
65 public function providerTestRenderBasic() {
68 // Part 1: the most simplistic render arrays possible, none using #theme.
72 // Pass an empty string.
74 // Previously printed, see ::renderTwice for a more integration-like test.
76 ['#markup' => 'foo', '#printed' => TRUE],
79 // Printed in pre_render.
83 '#pre_render' => [[new TestCallables(), 'preRenderPrinted']],
87 // Basic #markup based renderable array.
92 // Basic #plain_text based renderable array.
94 ['#plain_text' => 'foo'],
97 // Mixing #plain_text and #markup based renderable array.
99 ['#plain_text' => '<em>foo</em>', '#markup' => 'bar'],
100 '<em>foo</em>',
102 // Safe strings in #plain_text are still escaped.
104 ['#plain_text' => Markup::create('<em>foo</em>')],
105 '<em>foo</em>',
107 // Renderable child element.
109 ['child' => ['#markup' => 'bar']],
112 // XSS filtering test.
114 ['child' => ['#markup' => "This is <script>alert('XSS')</script> test"]],
115 "This is alert('XSS') test",
117 // XSS filtering test.
121 '#markup' => "This is <script>alert('XSS')</script> test",
122 '#allowed_tags' => ['script'],
125 "This is <script>alert('XSS')</script> test",
127 // XSS filtering test.
131 '#markup' => "This is <script><em>alert('XSS')</em></script> <strong>test</strong>",
132 '#allowed_tags' => ['em', 'strong'],
135 "This is <em>alert('XSS')</em> <strong>test</strong>",
137 // Html escaping test.
141 '#plain_text' => "This is <script><em>alert('XSS')</em></script> <strong>test</strong>",
144 "This is <script><em>alert('XSS')</em></script> <strong>test</strong>",
146 // XSS filtering by default test.
150 '#markup' => "This is <script><em>alert('XSS')</em></script> <strong>test</strong>",
153 "This is <em>alert('XSS')</em> <strong>test</strong>",
155 // Ensure non-XSS tags are not filtered out.
159 '#markup' => "This is <strong><script>alert('not a giraffe')</script></strong> test",
162 "This is <strong>alert('not a giraffe')</strong> test",
164 // #children set but empty, and renderable children.
166 ['#children' => '', 'child' => ['#markup' => 'bar']],
169 // #children set, not empty, and renderable children. #children will be
170 // assumed oto be the rendered child elements, even though the #markup for
173 ['#children' => 'foo', 'child' => ['#markup' => 'bar']],
176 // Ensure that content added to #markup via a #pre_render callback is safe.
180 '#pre_render' => [function ($elements) {
181 $elements['#markup'] .= '<script>alert("bar");</script>';
188 // Test #allowed_tags in combination with #markup and #pre_render.
192 '#allowed_tags' => ['script'],
193 '#pre_render' => [function ($elements) {
194 $elements['#markup'] .= '<script>alert("bar");</script>';
199 'foo<script>alert("bar");</script>',
201 // Ensure output is escaped when adding content to #check_plain through
202 // a #pre_render callback.
205 '#plain_text' => 'foo',
206 '#pre_render' => [function ($elements) {
207 $elements['#plain_text'] .= '<script>alert("bar");</script>';
212 'foo<script>alert("bar");</script>',
215 // Part 2: render arrays using #theme and #theme_wrappers.
217 // Tests that #theme and #theme_wrappers can co-exist on an element.
219 '#theme' => 'common_test_foo',
222 '#theme_wrappers' => ['container'],
223 '#attributes' => ['class' => ['baz']],
225 $setup_code_type_link = function () {
226 $this->setupThemeContainer();
227 $this->themeManager->expects($this->at(0))
229 ->with('common_test_foo', $this->anything())
230 ->willReturnCallback(function ($theme, $vars) {
231 return $vars['#foo'] . $vars['#bar'];
234 $data[] = [$build, '<div class="baz">foobar</div>' . "\n", $setup_code_type_link];
236 // Tests that #theme_wrappers can disambiguate element attributes shared
237 // with rendering methods that build #children by using the alternate
238 // #theme_wrappers attribute override syntax.
241 '#theme_wrappers' => [
243 '#attributes' => ['class' => ['baz']],
246 '#attributes' => ['id' => 'foo'],
247 '#url' => 'https://www.drupal.org',
250 $setup_code_type_link = function () {
251 $this->setupThemeContainer();
252 $this->themeManager->expects($this->at(0))
254 ->with('link', $this->anything())
255 ->willReturnCallback(function ($theme, $vars) {
256 $attributes = new Attribute(['href' => $vars['#url']] + (isset($vars['#attributes']) ? $vars['#attributes'] : []));
257 return '<a' . (string) $attributes . '>' . $vars['#title'] . '</a>';
260 $data[] = [$build, '<div class="baz"><a href="https://www.drupal.org" id="foo">bar</a></div>' . "\n", $setup_code_type_link];
262 // Tests that #theme_wrappers can disambiguate element attributes when the
263 // "base" attribute is not set for #theme.
266 '#url' => 'https://www.drupal.org',
268 '#theme_wrappers' => [
270 '#attributes' => ['class' => ['baz']],
274 $data[] = [$build, '<div class="baz"><a href="https://www.drupal.org">foo</a></div>' . "\n", $setup_code_type_link];
276 // Tests two 'container' #theme_wrappers, one using the "base" attributes
277 // and one using an override.
279 '#attributes' => ['class' => ['foo']],
280 '#theme_wrappers' => [
282 '#attributes' => ['class' => ['bar']],
287 $setup_code = function () {
288 $this->setupThemeContainer($this->any());
290 $data[] = [$build, '<div class="foo"><div class="bar"></div>' . "\n" . '</div>' . "\n", $setup_code];
292 // Tests array syntax theme hook suggestion in #theme_wrappers.
294 '#theme_wrappers' => [['container']],
295 '#attributes' => ['class' => ['foo']],
297 $setup_code = function () {
298 $this->setupThemeContainerMultiSuggestion($this->any());
300 $data[] = [$build, '<div class="foo"></div>' . "\n", $setup_code];
302 // Part 3: render arrays using #markup as a fallback for #theme hooks.
304 // Theme suggestion is not implemented, #markup should be rendered.
306 '#theme' => ['suggestionnotimplemented'],
309 $setup_code = function () {
310 $this->themeManager->expects($this->once())
312 ->with(['suggestionnotimplemented'], $this->anything())
315 $data[] = [$build, 'foo', $setup_code];
317 // Tests unimplemented theme suggestion, child #markup should be rendered.
319 '#theme' => ['suggestionnotimplemented'],
324 $setup_code = function () {
325 $this->themeManager->expects($this->once())
327 ->with(['suggestionnotimplemented'], $this->anything())
330 $data[] = [$build, 'foo', $setup_code];
332 // Tests implemented theme suggestion: #markup should not be rendered.
334 '#theme' => ['common_test_empty'],
337 $theme_function_output = $this->randomContextValue();
338 $setup_code = function () use ($theme_function_output) {
339 $this->themeManager->expects($this->once())
341 ->with(['common_test_empty'], $this->anything())
342 ->willReturn($theme_function_output);
344 $data[] = [$build, $theme_function_output, $setup_code];
346 // Tests implemented theme suggestion: children should not be rendered.
348 '#theme' => ['common_test_empty'],
353 $data[] = [$build, $theme_function_output, $setup_code];
355 // Part 4: handling of #children and child renderable elements.
357 // #theme is implemented so the values of both #children and 'child' will
358 // be ignored - it is the responsibility of the theme hook to render these
361 '#theme' => 'common_test_foo',
362 '#children' => 'baz',
363 'child' => ['#markup' => 'boo'],
365 $setup_code = function () {
366 $this->themeManager->expects($this->once())
368 ->with('common_test_foo', $this->anything())
369 ->willReturn('foobar');
371 $data[] = [$build, 'foobar', $setup_code];
373 // #theme is implemented but #render_children is TRUE. As in the case where
374 // #theme is not set, empty #children means child elements are rendered
377 '#theme' => 'common_test_foo',
379 '#render_children' => TRUE,
384 $setup_code = function () {
385 $this->themeManager->expects($this->never())
388 $data[] = [$build, 'boo', $setup_code];
390 // #theme is implemented but #render_children is TRUE. As in the case where
391 // #theme is not set, #children will take precedence over 'child'.
393 '#theme' => 'common_test_foo',
394 '#children' => 'baz',
395 '#render_children' => TRUE,
400 $setup_code = function () {
401 $this->themeManager->expects($this->never())
404 $data[] = [$build, 'baz', $setup_code];
406 // #theme is implemented but #render_children is TRUE. In this case the
407 // calling code is expecting only the children to be rendered. #prefix and
408 // #suffix should not be inherited for the children.
410 '#theme' => 'common_test_foo',
412 '#prefix' => 'kangaroo',
413 '#suffix' => 'unicorn',
414 '#render_children' => TRUE,
416 '#markup' => 'kitten',
419 $setup_code = function () {
420 $this->themeManager->expects($this->never())
423 $data[] = [$build, 'kitten', $setup_code];
432 public function testRenderSorting() {
433 $first = $this->randomMachineName();
434 $second = $this->randomMachineName();
435 // Build an array with '#weight' set for each element.
439 '#markup' => $second,
446 $output = $this->renderer->renderRoot($elements);
448 // The lowest weight element should appear last in $output.
449 $this->assertTrue(strpos($output, $second) > strpos($output, $first), 'Elements were sorted correctly by weight.');
451 // Confirm that the $elements array has '#sorted' set to TRUE.
452 $this->assertTrue($elements['#sorted'], "'#sorted' => TRUE was added to the array");
454 // Pass $elements through \Drupal\Core\Render\Element::children() and
455 // ensure it remains sorted in the correct order. drupal_render() will
456 // return an empty string if used on the same array in the same request.
457 $children = Element::children($elements);
458 $this->assertTrue(array_shift($children) == 'first', 'Child found in the correct order.');
459 $this->assertTrue(array_shift($children) == 'second', 'Child found in the correct order.');
466 public function testRenderSortingWithSetHashSorted() {
467 $first = $this->randomMachineName();
468 $second = $this->randomMachineName();
469 // The same array structure again, but with #sorted set to TRUE.
473 '#markup' => $second,
481 $output = $this->renderer->renderRoot($elements);
483 // The elements should appear in output in the same order as the array.
484 $this->assertTrue(strpos($output, $second) < strpos($output, $first), 'Elements were not sorted.');
491 * @dataProvider providerAccessValues
493 public function testRenderWithPresetAccess($access) {
495 '#access' => $access,
498 $this->assertAccess($build, $access);
505 * @dataProvider providerAccessValues
507 public function testRenderWithAccessCallbackCallable($access) {
509 '#access_callback' => function () use ($access) {
514 $this->assertAccess($build, $access);
518 * Ensures that the #access property wins over the callable.
523 * @dataProvider providerAccessValues
525 public function testRenderWithAccessPropertyAndCallback($access) {
527 '#access' => $access,
528 '#access_callback' => function () {
533 $this->assertAccess($build, $access);
540 * @dataProvider providerAccessValues
542 public function testRenderWithAccessControllerResolved($access) {
545 case AccessResult::allowed():
546 $method = 'accessResultAllowed';
549 case AccessResult::forbidden():
550 $method = 'accessResultForbidden';
554 $method = 'accessFalse';
558 $method = 'accessTrue';
563 '#access_callback' => 'Drupal\Tests\Core\Render\TestAccessClass::' . $method,
566 $this->assertAccess($build, $access);
573 public function testRenderAccessCacheabilityDependencyInheritance() {
575 '#access' => AccessResult::allowed()->addCacheContexts(['user']),
578 $this->renderer->renderPlain($build);
580 $this->assertEquals(['languages:language_interface', 'theme', 'user'], $build['#cache']['contexts']);
584 * Tests rendering same render array twice.
586 * Tests that a first render returns the rendered output and a second doesn't
587 * because of the #printed property. Also tests that correct metadata has been
588 * set for re-rendering.
593 * @dataProvider providerRenderTwice
595 public function testRenderTwice($build) {
596 $this->assertEquals('kittens', $this->renderer->renderRoot($build));
597 $this->assertEquals('kittens', $build['#markup']);
598 $this->assertEquals(['kittens-147'], $build['#cache']['tags']);
599 $this->assertTrue($build['#printed']);
601 // We don't want to reprint already printed render arrays.
602 $this->assertEquals('', $this->renderer->renderRoot($build));
606 * Provides a list of render array iterations.
610 public function providerRenderTwice() {
614 '#markup' => 'kittens',
616 'tags' => ['kittens-147']
623 '#markup' => 'kittens',
625 'tags' => ['kittens-147'],
632 '#render_children' => TRUE,
634 '#markup' => 'kittens',
636 'tags' => ['kittens-147'],
645 * Ensures that #access is taken in account when rendering #render_children.
647 public function testRenderChildrenAccess() {
650 '#render_children' => TRUE,
652 '#markup' => 'kittens',
656 $this->assertEquals('', $this->renderer->renderRoot($build));
660 * Provides a list of both booleans.
664 public function providerAccessValues() {
668 [AccessResult::forbidden()],
669 [AccessResult::allowed()],
674 * Asserts that a render array with access checking renders correctly.
676 * @param array $build
677 * A render array with either #access or #access_callback.
678 * @param bool $access
679 * Whether the render array is accessible or not.
681 protected function assertAccess($build, $access) {
682 $sensitive_content = $this->randomContextValue();
683 $build['#markup'] = $sensitive_content;
684 if (($access instanceof AccessResultInterface && $access->isAllowed()) || $access === TRUE) {
685 $this->assertSame($sensitive_content, (string) $this->renderer->renderRoot($build));
688 $this->assertSame('', (string) $this->renderer->renderRoot($build));
692 protected function setupThemeContainer($matcher = NULL) {
693 $this->themeManager->expects($matcher ?: $this->at(1))
695 ->with('container', $this->anything())
696 ->willReturnCallback(function ($theme, $vars) {
697 return '<div' . (string) (new Attribute($vars['#attributes'])) . '>' . $vars['#children'] . "</div>\n";
701 protected function setupThemeContainerMultiSuggestion($matcher = NULL) {
702 $this->themeManager->expects($matcher ?: $this->at(1))
704 ->with(['container'], $this->anything())
705 ->willReturnCallback(function ($theme, $vars) {
706 return '<div' . (string) (new Attribute($vars['#attributes'])) . '>' . $vars['#children'] . "</div>\n";
714 public function testRenderWithoutThemeArguments() {
716 '#theme' => 'common_test_foo',
719 $this->themeManager->expects($this->once())
721 ->with('common_test_foo', $this->defaultThemeVars + $element)
722 ->willReturn('foobar');
724 // Test that defaults work.
725 $this->assertEquals($this->renderer->renderRoot($element), 'foobar', 'Defaults work');
732 public function testRenderWithThemeArguments() {
734 '#theme' => 'common_test_foo',
735 '#foo' => $this->randomMachineName(),
736 '#bar' => $this->randomMachineName(),
739 $this->themeManager->expects($this->once())
741 ->with('common_test_foo', $this->defaultThemeVars + $element)
742 ->willReturnCallback(function ($hook, $vars) {
743 return $vars['#foo'] . $vars['#bar'];
746 // Tests that passing arguments to the theme function works.
747 $this->assertEquals($this->renderer->renderRoot($element), $element['#foo'] . $element['#bar'], 'Passing arguments to theme functions works');
753 * @covers \Drupal\Core\Render\RenderCache::get
754 * @covers \Drupal\Core\Render\RenderCache::set
755 * @covers \Drupal\Core\Render\RenderCache::createCacheID
757 public function testRenderCache() {
758 $this->setUpRequest();
759 $this->setupMemoryCache();
761 // Create an empty element.
764 'keys' => ['render_cache_test'],
765 'tags' => ['render_cache_tag'],
770 'keys' => ['render_cache_test_child'],
771 'tags' => ['render_cache_tag_child:1', 'render_cache_tag_child:2'],
777 // Render the element and confirm that it goes through the rendering
778 // process (which will set $element['#printed']).
779 $element = $test_element;
780 $this->renderer->renderRoot($element);
781 $this->assertTrue(isset($element['#printed']), 'No cache hit');
783 // Render the element again and confirm that it is retrieved from the cache
784 // instead (so $element['#printed'] will not be set).
785 $element = $test_element;
786 $this->renderer->renderRoot($element);
787 $this->assertFalse(isset($element['#printed']), 'Cache hit');
789 // Test that cache tags are correctly collected from the render element,
790 // including the ones from its subchild.
793 'render_cache_tag_child:1',
794 'render_cache_tag_child:2',
796 $this->assertEquals($expected_tags, $element['#cache']['tags'], 'Cache tags were collected from the element and its subchild.');
798 // The cache item also has a 'rendered' cache tag.
799 $cache_item = $this->cacheFactory->get('render')->get('render_cache_test:en:stark');
800 $this->assertSame(Cache::mergeTags($expected_tags, ['rendered']), $cache_item->tags);
806 * @covers \Drupal\Core\Render\RenderCache::get
807 * @covers \Drupal\Core\Render\RenderCache::set
808 * @covers \Drupal\Core\Render\RenderCache::createCacheID
810 * @dataProvider providerTestRenderCacheMaxAge
812 public function testRenderCacheMaxAge($max_age, $is_render_cached, $render_cache_item_expire) {
813 $this->setUpRequest();
814 $this->setupMemoryCache();
818 'keys' => ['render_cache_test'],
819 'max-age' => $max_age,
823 $this->renderer->renderRoot($element);
825 $cache_item = $this->cacheFactory->get('render')->get('render_cache_test:en:stark');
826 if (!$is_render_cached) {
827 $this->assertFalse($cache_item);
830 $this->assertNotFalse($cache_item);
831 $this->assertSame($render_cache_item_expire, $cache_item->expire);
835 public function providerTestRenderCacheMaxAge() {
838 [60, TRUE, (int) $_SERVER['REQUEST_TIME'] + 60],
839 [Cache::PERMANENT, TRUE, -1],
844 * Tests that #cache_properties are properly handled.
846 * @param array $expected_results
847 * An associative array of expected results keyed by property name.
851 * @covers \Drupal\Core\Render\RenderCache::get
852 * @covers \Drupal\Core\Render\RenderCache::set
853 * @covers \Drupal\Core\Render\RenderCache::createCacheID
854 * @covers \Drupal\Core\Render\RenderCache::getCacheableRenderArray
856 * @dataProvider providerTestRenderCacheProperties
858 public function testRenderCacheProperties(array $expected_results) {
859 $this->setUpRequest();
860 $this->setupMemoryCache();
862 $element = $original = [
864 'keys' => ['render_cache_test'],
866 // Collect expected property names.
867 '#cache_properties' => array_keys(array_filter($expected_results)),
868 'child1' => ['#markup' => Markup::create('1')],
869 'child2' => ['#markup' => Markup::create('2')],
870 // Mark the value as safe.
871 '#custom_property' => Markup::create('custom_value'),
872 '#custom_property_array' => ['custom value'],
875 $this->renderer->renderRoot($element);
877 $cache = $this->cacheFactory->get('render');
878 $data = $cache->get('render_cache_test:en:stark')->data;
880 // Check that parent markup is ignored when caching children's markup.
881 $this->assertEquals($data['#markup'] === '', (bool) Element::children($data));
883 // Check that the element properties are cached as specified.
884 foreach ($expected_results as $property => $expected) {
885 $cached = !empty($data[$property]);
886 $this->assertEquals($cached, (bool) $expected);
887 // Check that only the #markup key is preserved for children.
889 $this->assertEquals($data[$property], $original[$property]);
892 // #custom_property_array can not be a safe_cache_property.
893 $safe_cache_properties = array_diff(Element::properties(array_filter($expected_results)), ['#custom_property_array']);
894 foreach ($safe_cache_properties as $cache_property) {
895 $this->assertInstanceOf(MarkupInterface::class, $data[$cache_property], "$cache_property is marked as a safe string");
900 * Data provider for ::testRenderCacheProperties().
903 * An array of associative arrays of expected results keyed by property
906 public function providerTestRenderCacheProperties() {
909 [['child1' => 0, 'child2' => 0, '#custom_property' => 0, '#custom_property_array' => 0]],
910 [['child1' => 0, 'child2' => 0, '#custom_property' => 1, '#custom_property_array' => 0]],
911 [['child1' => 0, 'child2' => 1, '#custom_property' => 0, '#custom_property_array' => 0]],
912 [['child1' => 0, 'child2' => 1, '#custom_property' => 1, '#custom_property_array' => 0]],
913 [['child1' => 1, 'child2' => 0, '#custom_property' => 0, '#custom_property_array' => 0]],
914 [['child1' => 1, 'child2' => 0, '#custom_property' => 1, '#custom_property_array' => 0]],
915 [['child1' => 1, 'child2' => 1, '#custom_property' => 0, '#custom_property_array' => 0]],
916 [['child1' => 1, 'child2' => 1, '#custom_property' => 1, '#custom_property_array' => 0]],
917 [['child1' => 1, 'child2' => 1, '#custom_property' => 1, '#custom_property_array' => 1]],
922 * @covers ::addCacheableDependency
924 * @dataProvider providerTestAddCacheableDependency
926 public function testAddCacheableDependency(array $build, $object, array $expected) {
927 $this->renderer->addCacheableDependency($build, $object);
928 $this->assertEquals($build, $expected);
931 public function providerTestAddCacheableDependency() {
933 // Empty render array, typical default cacheability.
936 new TestCacheableDependency([], [], Cache::PERMANENT),
941 'max-age' => Cache::PERMANENT,
945 // Empty render array, some cacheability.
948 new TestCacheableDependency(['user.roles'], ['foo'], Cache::PERMANENT),
951 'contexts' => ['user.roles'],
953 'max-age' => Cache::PERMANENT,
957 // Cacheable render array, some cacheability.
961 'contexts' => ['theme'],
966 new TestCacheableDependency(['user.roles'], ['foo'], Cache::PERMANENT),
969 'contexts' => ['theme', 'user.roles'],
970 'tags' => ['bar', 'foo'],
975 // Cacheable render array, no cacheability.
979 'contexts' => ['theme'],
987 'contexts' => ['theme'],
998 class TestAccessClass {
1000 public static function accessTrue() {
1004 public static function accessFalse() {
1008 public static function accessResultAllowed() {
1009 return AccessResult::allowed();
1012 public static function accessResultForbidden() {
1013 return AccessResult::forbidden();
1018 class TestCallables {
1020 public function preRenderPrinted($elements) {
1021 $elements['#printed'] = TRUE;