 core/includes/common.inc                           |   22 +-
 core/lib/Drupal/Core/Render/BubbleableMetadata.php |    2 +-
 core/lib/Drupal/Core/Render/Renderer.php           |   28 +-
 core/lib/Drupal/Core/Render/RendererInterface.php  |   25 +
 .../modules/system/src/Tests/Common/RenderTest.php |  967 -------------
 .../Drupal/Tests/Core/Render/RendererTest.php      | 1496 ++++++++++++++++++++
 6 files changed, 1550 insertions(+), 990 deletions(-)

diff --git a/core/includes/common.inc b/core/includes/common.inc
index 23266b5..12b2d3c 100644
--- a/core/includes/common.inc
+++ b/core/includes/common.inc
@@ -23,6 +23,7 @@
 use Drupal\Core\Asset\AttachedAssets;
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Language\LanguageInterface;
+use Drupal\Core\Render\Renderer;
 use Drupal\Core\Site\Settings;
 use Drupal\Core\Url;
 use Symfony\Component\HttpFoundation\Response;
@@ -33,7 +34,6 @@
 use Drupal\Core\Routing\GeneratorNotInitializedException;
 use Drupal\Core\Template\Attribute;
 use Drupal\Core\Render\Element;
-use Drupal\Core\Render\Renderer;
 use Drupal\Core\Session\AnonymousUserSession;
 
 /**
@@ -1366,25 +1366,7 @@ function show(&$element) {
  * @see \Drupal\Core\Render\Renderer::getFromCache()
  */
 function drupal_render_cache_generate_placeholder($callback, array &$context) {
-  if (is_string($callback) && strpos($callback, '::') === FALSE) {
-    /** @var \Drupal\Core\Controller\ControllerResolverInterface $controller_resolver */
-    $controller_resolver = \Drupal::service('controller_resolver');
-    $callable = \Drupal::service('controller_resolver')->getControllerFromDefinition($callback);
-  }
-  else {
-   $callable = $callback;
-  }
-
-  if (!is_callable($callable)) {
-    throw new Exception(t('$callable must be a callable function or of the form service_id:method.'));
-  }
-
-  // Generate a unique token if one is not already provided.
-  $context += array(
-    'token' => \Drupal\Component\Utility\Crypt::randomBytesBase64(55),
-  );
-
-  return '<drupal-render-cache-placeholder callback="' . $callback . '" token="' . $context['token'] . '"></drupal-render-cache-placeholder>';
+  return \Drupal::service('renderer')->generateCachePlaceholder($callback, $context);
 }
 
 /**
diff --git a/core/lib/Drupal/Core/Render/BubbleableMetadata.php b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
index c56b421..2f62b44 100644
--- a/core/lib/Drupal/Core/Render/BubbleableMetadata.php
+++ b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
@@ -70,7 +70,7 @@ public function __construct(array $tags = [], array $attached = [], array $post_
   public function merge(BubbleableMetadata $other) {
     $result = new BubbleableMetadata();
     $result->tags = Cache::mergeTags($this->tags, $other->tags);
-    $result->attached = drupal_merge_attached($this->attached, $other->attached);
+    $result->attached = Renderer::mergeAttachments($this->attached, $other->attached);
     $result->postRenderCache = NestedArray::mergeDeep($this->postRenderCache, $other->postRenderCache);
     return $result;
   }
diff --git a/core/lib/Drupal/Core/Render/Renderer.php b/core/lib/Drupal/Core/Render/Renderer.php
index e0253b6..85a15f9 100644
--- a/core/lib/Drupal/Core/Render/Renderer.php
+++ b/core/lib/Drupal/Core/Render/Renderer.php
@@ -7,6 +7,7 @@
 
 namespace Drupal\Core\Render;
 
+use Drupal\Component\Utility\Crypt;
 use Drupal\Component\Utility\NestedArray;
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Cache\CacheContexts;
@@ -344,7 +345,7 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
     $this->updateStack($elements);
 
     // Cache the processed element if #cache is set.
-    if (isset($elements['#cache'])) {
+    if (isset($elements['#cache']) && $elements['#cache'] !== ['tags' => []]) {
       $this->cacheSet($elements);
     }
 
@@ -491,7 +492,7 @@ protected function cacheGet(array $elements) {
     }
     $bin = isset($elements['#cache']['bin']) ? $elements['#cache']['bin'] : 'render';
 
-    if (!empty($cid) && $cache = $this->cacheFactory->get($bin)->get($cid)) {
+    if (!empty($cid) && ($cache_bin = $this->cacheFactory->get($bin)) && $cache = $cache_bin->get($cid)) {
       $cached_element = $cache->data;
       // Return the cached element.
       return $cached_element;
@@ -599,4 +600,27 @@ public static function mergeAttachments(array $a, array $b) {
     return NestedArray::mergeDeep($a, $b);
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function generateCachePlaceholder($callback, array &$context) {
+    if (is_string($callback) && strpos($callback, '::') === FALSE) {
+      $callable = $this->controllerResolver->getControllerFromDefinition($callback);
+    }
+    else {
+      $callable = $callback;
+    }
+
+    if (!is_callable($callable)) {
+      throw new \InvalidArgumentException('$callable must be a callable function or of the form service_id:method.');
+    }
+
+    // Generate a unique token if one is not already provided.
+    $context += [
+      'token' => Crypt::randomBytesBase64(55),
+    ];
+
+    return '<drupal-render-cache-placeholder callback="' . $callback . '" token="' . $context['token'] . '"></drupal-render-cache-placeholder>';
+  }
+
 }
diff --git a/core/lib/Drupal/Core/Render/RendererInterface.php b/core/lib/Drupal/Core/Render/RendererInterface.php
index 06f4968..8e1c758 100644
--- a/core/lib/Drupal/Core/Render/RendererInterface.php
+++ b/core/lib/Drupal/Core/Render/RendererInterface.php
@@ -348,4 +348,29 @@ public static function mergeBubbleableMetadata(array $a, array $b);
    */
   public static function mergeAttachments(array $a, array $b);
 
+  /**
+   * Generates a render cache placeholder.
+   *
+   * This is used by drupal_pre_render_render_cache_placeholder() to generate
+   * placeholders, but should also be called by #post_render_cache callbacks that
+   * want to replace the placeholder with the final markup.
+   *
+   * @param string $callback
+   *   The #post_render_cache callback that will replace the placeholder with its
+   *   eventual markup.
+   * @param array $context
+   *   An array providing context for the #post_render_cache callback. This array
+   *   will be altered to provide a 'token' key/value pair, if not already
+   *   provided, to uniquely identify the generated placeholder.
+   *
+   * @return string
+   *   The generated placeholder HTML.
+   *
+   * @throws \InvalidArgumentException
+   *   Thrown when no valid callable got passed in.
+   *
+   * @see \Drupal\Core\Render\Renderer::getFromCache()
+   */
+  public function generateCachePlaceholder($callback, array &$context);
+
 }
diff --git a/core/modules/system/src/Tests/Common/RenderTest.php b/core/modules/system/src/Tests/Common/RenderTest.php
index b137d19..b87a1d3 100644
--- a/core/modules/system/src/Tests/Common/RenderTest.php
+++ b/core/modules/system/src/Tests/Common/RenderTest.php
@@ -28,385 +28,6 @@ class RenderTest extends KernelTestBase {
   public static $modules = array('system', 'common_test');
 
   /**
-   * Tests the output drupal_render() for some elementary input values.
-   */
-  function testDrupalRenderBasics() {
-    $types = array(
-      array(
-        'name' => 'null',
-        'value' => NULL,
-        'expected' => '',
-      ),
-      array(
-        'name' => 'no value',
-        'expected' => '',
-      ),
-      array(
-        'name' => 'empty string',
-        'value' => '',
-        'expected' => '',
-      ),
-      array(
-        'name' => 'no access',
-        'value' => array(
-          '#markup' => 'foo',
-          '#access' => FALSE,
-        ),
-        'expected' => '',
-      ),
-      array(
-        'name' => 'access denied via callback',
-        'value' => array(
-          '#markup' => 'foo',
-          '#access_callback' => 'is_bool',
-        ),
-        'expected' => '',
-      ),
-      array(
-        'name' => 'access granted via callback',
-        'value' => array(
-          '#markup' => 'foo',
-          '#access_callback' => 'is_array',
-        ),
-        'expected' => 'foo',
-      ),
-      array(
-        'name' => 'access FALSE is honored',
-        'value' => array(
-          '#markup' => 'foo',
-          '#access' => FALSE,
-          '#access_callback' => 'is_array',
-        ),
-        'expected' => '',
-      ),
-      array(
-        'name' => 'previously printed',
-        'value' => array(
-          '#markup' => 'foo',
-          '#printed' => TRUE,
-        ),
-        'expected' => '',
-      ),
-      array(
-        'name' => 'printed in prerender',
-        'value' => array(
-          '#markup' => 'foo',
-          '#pre_render' => array('common_test_drupal_render_printing_pre_render'),
-        ),
-        'expected' => '',
-      ),
-
-      // Test that #theme and #theme_wrappers can co-exist on an element.
-      array(
-        'name' => '#theme and #theme_wrappers basic',
-        'value' => array(
-          '#theme' => 'common_test_foo',
-          '#foo' => 'foo',
-          '#bar' => 'bar',
-          '#theme_wrappers' => array('container'),
-          '#attributes' => array('class' => array('baz')),
-        ),
-        'expected' => '<div class="baz">foobar</div>' . "\n",
-      ),
-      // Test that #theme_wrappers can disambiguate element attributes shared
-      // with rendering methods that build #children by using the alternate
-      // #theme_wrappers attribute override syntax.
-      array(
-        'name' => '#theme and #theme_wrappers attribute disambiguation',
-        'value' => array(
-          '#type' => 'link',
-          '#theme_wrappers' => array(
-            'container' => array(
-              '#attributes' => array('class' => array('baz')),
-            ),
-          ),
-          '#attributes' => array('id' => 'foo'),
-          '#url' => Url::fromUri('http://drupal.org'),
-          '#title' => 'bar',
-        ),
-        'expected' => '<div class="baz"><a href="http://drupal.org" id="foo">bar</a></div>' . "\n",
-      ),
-      // Test that #theme_wrappers can disambiguate element attributes when the
-      // "base" attribute is not set for #theme.
-      array(
-        'name' => '#theme_wrappers attribute disambiguation with undefined #theme attribute',
-        'value' => array(
-          '#type' => 'link',
-          '#url' => Url::fromUri('http://drupal.org'),
-          '#title' => 'foo',
-          '#theme_wrappers' => array(
-            'container' => array(
-              '#attributes' => array('class' => array('baz')),
-            ),
-          ),
-        ),
-        'expected' => '<div class="baz"><a href="http://drupal.org">foo</a></div>' . "\n",
-      ),
-      // Two 'container' #theme_wrappers, one using the "base" attributes and
-      // one using an override.
-      array(
-        'name' => 'Two #theme_wrappers container hooks with different attributes',
-        'value' => array(
-          '#attributes' => array('class' => array('foo')),
-          '#theme_wrappers' => array(
-            'container' => array(
-              '#attributes' => array('class' => array('bar')),
-            ),
-            'container',
-          ),
-        ),
-        'expected' => '<div class="foo"><div class="bar"></div>' . "\n" . '</div>' . "\n",
-      ),
-      // Array syntax theme hook suggestion in #theme_wrappers.
-      array(
-        'name' => '#theme_wrappers implements an array style theme hook suggestion',
-        'value' => array(
-          '#theme_wrappers' => array(array('container')),
-          '#attributes' => array('class' => array('foo')),
-        ),
-        'expected' => '<div class="foo"></div>' . "\n",
-      ),
-
-      // Test handling of #markup as a fallback for #theme hooks.
-      // Simple #markup with no theme.
-      array(
-        'name' => 'basic #markup based renderable array',
-        'value' => array('#markup' => 'foo'),
-        'expected' => 'foo',
-      ),
-      // Theme suggestion is not implemented, #markup should be rendered.
-      array(
-        'name' => '#markup fallback for #theme suggestion not implemented',
-        'value' => array(
-          '#theme' => array('suggestionnotimplemented'),
-          '#markup' => 'foo',
-        ),
-        'expected' => 'foo',
-      ),
-      // Theme suggestion is not implemented, child #markup should be rendered.
-      array(
-        'name' => '#markup fallback for child elements, #theme suggestion not implemented',
-        'value' => array(
-          '#theme' => array('suggestionnotimplemented'),
-          'child' => array(
-            '#markup' => 'foo',
-          ),
-        ),
-        'expected' => 'foo',
-      ),
-      // Theme suggestion is implemented but returns empty string, #markup
-      // should not be rendered.
-      array(
-        'name' => 'Avoid #markup if #theme is implemented but returns an empty string',
-        'value' => array(
-          '#theme' => array('common_test_empty'),
-          '#markup' => 'foo',
-        ),
-        'expected' => '',
-      ),
-      // Theme suggestion is implemented but returns empty string, children
-      // should not be rendered.
-      array(
-        'name' => 'Avoid rendering child elements if #theme is implemented but returns an empty string',
-        'value' => array(
-          '#theme' => array('common_test_empty'),
-          'child' => array(
-            '#markup' => 'foo',
-          ),
-        ),
-        'expected' => '',
-      ),
-
-      // Test handling of #children and child renderable elements.
-      // #theme is not set, #children is not set and the array has children.
-      array(
-        'name' => '#theme is not set, #children is not set and array has children',
-        'value' => array(
-          'child' => array('#markup' => 'bar'),
-        ),
-        'expected' => 'bar',
-      ),
-      // #theme is not set, #children is set but empty and the array has
-      // children.
-      array(
-        'name' => '#theme is not set, #children is an empty string and array has children',
-        'value' => array(
-          '#children' => '',
-          'child' => array('#markup' => 'bar'),
-        ),
-        'expected' => 'bar',
-      ),
-      // #theme is not set, #children is not empty and will be assumed to be the
-      // rendered child elements even though the #markup for 'child' differs.
-      array(
-        'name' => '#theme is not set, #children is set and array has children',
-        'value' => array(
-          '#children' => 'foo',
-          'child' => array('#markup' => 'bar'),
-        ),
-        'expected' => 'foo',
-      ),
-      // #theme is implemented so the values of both #children and 'child' will
-      // be ignored - it is the responsibility of the theme hook to render these
-      // if appropriate.
-      array(
-        'name' => '#theme is implemented, #children is set and array has children',
-        'value' => array(
-          '#theme' => 'common_test_foo',
-          '#children' => 'baz',
-          'child' => array('#markup' => 'boo'),
-        ),
-        'expected' => 'foobar',
-      ),
-      // #theme is implemented but #render_children is TRUE. As in the case
-      // where #theme is not set, empty #children means child elements are
-      // rendered recursively.
-      array(
-        'name' => '#theme is implemented, #render_children is TRUE, #children is empty and array has children',
-        'value' => array(
-          '#theme' => 'common_test_foo',
-          '#children' => '',
-          '#render_children' => TRUE,
-          'child' => array(
-            '#markup' => 'boo',
-          ),
-        ),
-        'expected' => 'boo',
-      ),
-      // #theme is implemented but #render_children is TRUE. As in the case
-      // where #theme is not set, #children will take precedence over 'child'.
-      array(
-        'name' => '#theme is implemented, #render_children is TRUE, #children is set and array has children',
-        'value' => array(
-          '#theme' => 'common_test_foo',
-          '#children' => 'baz',
-          '#render_children' => TRUE,
-          'child' => array(
-            '#markup' => 'boo',
-          ),
-        ),
-        'expected' => 'baz',
-      ),
-    );
-
-    foreach($types as $type) {
-      $this->assertIdentical(drupal_render($type['value']), $type['expected'], '"' . $type['name'] . '" input rendered correctly by drupal_render().');
-    }
-  }
-
-  /**
-   * Tests sorting by weight.
-   */
-  function testDrupalRenderSorting() {
-    $first = $this->randomMachineName();
-    $second = $this->randomMachineName();
-    // Build an array with '#weight' set for each element.
-    $elements = array(
-      'second' => array(
-        '#weight' => 10,
-        '#markup' => $second,
-      ),
-      'first' => array(
-        '#weight' => 0,
-        '#markup' => $first,
-      ),
-    );
-    $output = drupal_render($elements);
-
-    // The lowest weight element should appear last in $output.
-    $this->assertTrue(strpos($output, $second) > strpos($output, $first), 'Elements were sorted correctly by weight.');
-
-    // Confirm that the $elements array has '#sorted' set to TRUE.
-    $this->assertTrue($elements['#sorted'], "'#sorted' => TRUE was added to the array");
-
-    // Pass $elements through \Drupal\Core\Render\Element::children() and
-    // ensure it remains sorted in the correct order. drupal_render() will
-    // return an empty string if used on the same array in the same request.
-    $children = Element::children($elements);
-    $this->assertTrue(array_shift($children) == 'first', 'Child found in the correct order.');
-    $this->assertTrue(array_shift($children) == 'second', 'Child found in the correct order.');
-
-
-    // The same array structure again, but with #sorted set to TRUE.
-    $elements = array(
-      'second' => array(
-        '#weight' => 10,
-        '#markup' => $second,
-      ),
-      'first' => array(
-        '#weight' => 0,
-        '#markup' => $first,
-      ),
-      '#sorted' => TRUE,
-    );
-    $output = drupal_render($elements);
-
-    // The elements should appear in output in the same order as the array.
-    $this->assertTrue(strpos($output, $second) < strpos($output, $first), 'Elements were not sorted.');
-  }
-
-  /**
-   * Tests #attached functionality in children elements.
-   */
-  function testDrupalRenderChildrenAttached() {
-    // The cache system is turned off for POST requests.
-    $request_method = \Drupal::request()->getMethod();
-    \Drupal::request()->setMethod('GET');
-
-    // Create an element with a child and subchild. Each element loads a
-    // different library using #attached.
-    $element = array(
-      '#type' => 'container',
-      '#cache' => array(
-        'keys' => array('simpletest', 'drupal_render', 'children_attached'),
-      ),
-      '#attached' => ['library' => ['test/parent']],
-      '#title' => 'Parent',
-    );
-    $element['child'] = array(
-      '#type' => 'container',
-      '#attached' => ['library' => ['test/child']],
-      '#title' => 'Child',
-    );
-    $element['child']['subchild'] = array(
-      '#attached' => ['library' => ['test/subchild']],
-      '#markup' => 'Subchild',
-    );
-
-    // Render the element and verify the presence of #attached JavaScript.
-    drupal_render($element);
-    $expected_libraries = ['test/parent', 'test/child', 'test/subchild'];
-    $this->assertEqual($element['#attached']['library'], $expected_libraries, 'The element, child and subchild #attached libraries are included.');
-
-    // Load the element from cache and verify the presence of the #attached
-    // JavaScript.
-    $element = array('#cache' => array('keys' => array('simpletest', 'drupal_render', 'children_attached')));
-    $this->assertTrue(strlen(drupal_render($element)) > 0, 'The element was retrieved from cache.');
-    $this->assertEqual($element['#attached']['library'], $expected_libraries, 'The element, child and subchild #attached libraries are included.');
-
-    // Restore the previous request method.
-    \Drupal::request()->setMethod($request_method);
-  }
-
-  /**
-   * Tests passing arguments to the theme function.
-   */
-  function testDrupalRenderThemeArguments() {
-    $element = array(
-      '#theme' => 'common_test_foo',
-    );
-    // Test that defaults work.
-    $this->assertEqual(drupal_render($element), 'foobar', 'Defaults work');
-    $element = array(
-      '#theme' => 'common_test_foo',
-      '#foo' => $this->randomMachineName(),
-      '#bar' => $this->randomMachineName(),
-    );
-    // Tests that passing arguments to the theme function works.
-    $this->assertEqual(drupal_render($element), $element['#foo'] . $element['#bar'], 'Passing arguments to theme functions works');
-  }
-
-  /**
    * Tests theme preprocess functions being able to attach assets.
    */
   function testDrupalRenderThemePreprocessAttached() {
@@ -432,352 +53,6 @@ function testDrupalRenderThemePreprocessAttached() {
   }
 
   /**
-   * Tests caching of an empty render item.
-   */
-  function testDrupalRenderCache() {
-    // The cache system is turned off for POST requests.
-    $request_method = \Drupal::request()->getMethod();
-    \Drupal::request()->setMethod('GET');
-
-    // Create an empty element.
-    $test_element = array(
-      '#cache' => array(
-        'cid' => 'render_cache_test',
-        'tags' => array('render_cache_tag'),
-      ),
-      '#markup' => '',
-      'child' => array(
-        '#cache' => array(
-          'cid' => 'render_cache_test_child',
-          'tags' => array('render_cache_tag_child:1', 'render_cache_tag_child:2'),
-        ),
-        '#markup' => '',
-      ),
-    );
-
-    // Render the element and confirm that it goes through the rendering
-    // process (which will set $element['#printed']).
-    $element = $test_element;
-    drupal_render($element);
-    $this->assertTrue(isset($element['#printed']), 'No cache hit');
-
-    // Render the element again and confirm that it is retrieved from the cache
-    // instead (so $element['#printed'] will not be set).
-    $element = $test_element;
-    drupal_render($element);
-    $this->assertFalse(isset($element['#printed']), 'Cache hit');
-
-    // Test that cache tags are correctly collected from the render element,
-    // including the ones from its subchild.
-    $expected_tags = array(
-      'render_cache_tag',
-      'render_cache_tag_child:1',
-      'render_cache_tag_child:2',
-      'rendered',
-    );
-    $this->assertEqual($expected_tags, $element['#cache']['tags'], 'Cache tags were collected from the element and its subchild.');
-
-    // Restore the previous request method.
-    \Drupal::request()->setMethod($request_method);
-  }
-
-  /**
-   * Tests post-render cache callbacks functionality.
-   */
-  function testDrupalRenderPostRenderCache() {
-    $context = array('foo' => $this->randomContextValue());
-    $test_element = array();
-    $test_element['#markup'] = '';
-    $test_element['#attached']['drupalSettings']['foo'] = 'bar';
-    $test_element['#post_render_cache']['common_test_post_render_cache'] = array(
-      $context
-    );
-
-    // #cache disabled.
-    $element = $test_element;
-    $element['#markup'] = '<p>#cache disabled</p>';
-    $output = drupal_render_root($element);
-    $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
-    $this->assertIdentical($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
-    $expected_js_settings = [
-      'foo' => 'bar',
-      'common_test' => $context,
-    ];
-    $this->assertIdentical($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.');
-
-    // The cache system is turned off for POST requests.
-    $request_method = \Drupal::request()->getMethod();
-    \Drupal::request()->setMethod('GET');
-
-    // GET request: #cache enabled, cache miss.
-    $element = $test_element;
-    $element['#cache'] = array('cid' => 'post_render_cache_test_GET');
-    $element['#markup'] = '<p>#cache enabled, GET</p>';
-    $output = drupal_render_root($element);
-    $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
-    $this->assertTrue(isset($element['#printed']), 'No cache hit');
-    $this->assertIdentical($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
-    $expected_js_settings = [
-      'foo' => 'bar',
-      'common_test' => $context,
-    ];
-    $this->assertIdentical($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.');
-
-    // GET request: validate cached data.
-    $element = array('#cache' => array('cid' => 'post_render_cache_test_GET'));
-    $cached_element = \Drupal::cache('render')->get('post_render_cache_test_GET')->data;
-    $expected_element = array(
-      '#markup' => '<p>#cache enabled, GET</p>',
-      '#attached' => $test_element['#attached'],
-      '#post_render_cache' => $test_element['#post_render_cache'],
-      '#cache' => array('tags' => array('rendered')),
-    );
-    $this->assertIdentical($cached_element, $expected_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by #post_render_cache callbacks.');
-
-    // GET request: #cache enabled, cache hit.
-    $element['#cache'] = array('cid' => 'post_render_cache_test_GET');
-    $element['#markup'] = '<p>#cache enabled, GET</p>';
-    $output = drupal_render_root($element);
-    $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
-    $this->assertFalse(isset($element['#printed']), 'Cache hit');
-    $this->assertIdentical($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
-    $expected_js_settings = [
-      'foo' => 'bar',
-      'common_test' => $context,
-    ];
-    $this->assertIdentical($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.');
-
-    // Verify behavior when handling a non-GET request, e.g. a POST request:
-    // also in that case, #post_render_cache callbacks must be called.
-    \Drupal::request()->setMethod('POST');
-
-    // POST request: #cache enabled, cache miss.
-    $element = $test_element;
-    $element['#cache'] = array('cid' => 'post_render_cache_test_POST');
-    $element['#markup'] = '<p>#cache enabled, POST</p>';
-    $output = drupal_render_root($element);
-    $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
-    $this->assertTrue(isset($element['#printed']), 'No cache hit');
-    $this->assertIdentical($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
-    $expected_js_settings = [
-      'foo' => 'bar',
-      'common_test' => $context,
-    ];
-    $this->assertIdentical($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.');
-
-    // POST request: Ensure no data was cached.
-    $cached_element = \Drupal::cache('render')->get('post_render_cache_test_POST');
-    $this->assertFalse($cached_element, 'No data is cached because this is a POST request.');
-
-    // Restore the previous request method.
-    \Drupal::request()->setMethod($request_method);
-  }
-
-  /**
-   * Tests post-render cache callbacks functionality in children elements.
-   */
-  function testDrupalRenderChildrenPostRenderCache() {
-    // The cache system is turned off for POST requests.
-    $request_method = \Drupal::request()->getMethod();
-    \Drupal::request()->setMethod('GET');
-
-    // Test case 1.
-    // Create an element with a child and subchild. Each element has the same
-    // #post_render_cache callback, but with different contexts.
-    $context_1 = array('foo' => $this->randomContextValue());
-    $context_2 = array('bar' => $this->randomContextValue());
-    $context_3 = array('baz' => $this->randomContextValue());
-    $test_element = array(
-      '#type' => 'details',
-      '#open' => TRUE,
-      '#cache' => array(
-        'keys' => array('simpletest', 'drupal_render', 'children_post_render_cache'),
-      ),
-      '#post_render_cache' => array(
-        'common_test_post_render_cache' => array($context_1)
-      ),
-      '#title' => 'Parent',
-      '#attached' => array(
-        'drupalSettings' => [
-          'foo' => 'bar',
-        ],
-      ),
-    );
-    $test_element['child'] = array(
-      '#type' => 'details',
-      '#open' => TRUE,
-      '#post_render_cache' => array(
-        'common_test_post_render_cache' => array($context_2)
-      ),
-      '#title' => 'Child',
-    );
-    $test_element['child']['subchild'] = array(
-      '#post_render_cache' => array(
-        'common_test_post_render_cache' => array($context_3)
-      ),
-      '#markup' => 'Subchild',
-    );
-    $element = $test_element;
-    $output = drupal_render_root($element);
-    $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
-    $this->assertTrue(isset($element['#printed']), 'No cache hit');
-    $this->assertIdentical($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
-    $expected_js_settings = [
-      'foo' => 'bar',
-      'common_test' => $context_1 + $context_2 + $context_3,
-    ];
-    $this->assertIdentical($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each #post_render_cache callback exist.');
-
-    // GET request: validate cached data.
-    $cached_element = \Drupal::cache('render')->get('simpletest:drupal_render:children_post_render_cache')->data;
-    $expected_element = array(
-      '#attached' => array(
-        'drupalSettings' => [
-          'foo' => 'bar',
-        ],
-        'library' => array(
-          'core/drupal.collapse',
-          'core/drupal.collapse',
-        ),
-      ),
-      '#post_render_cache' => array(
-        'common_test_post_render_cache' => array(
-          $context_1,
-          $context_2,
-          $context_3,
-        )
-      ),
-      '#cache' => array('tags' => array('rendered')),
-    );
-
-    $dom = Html::load($cached_element['#markup']);
-    $xpath = new \DOMXPath($dom);
-    $parent = $xpath->query('//details[@class="form-wrapper" and @open="open"]/summary[@role="button" and @aria-expanded and text()="Parent"]')->length;
-    $child =  $xpath->query('//details[@class="form-wrapper" and @open="open"]/div[@class="details-wrapper"]/details[@class="form-wrapper" and @open="open"]/summary[@role="button" and @aria-expanded and text()="Child"]')->length;
-    $subchild = $xpath->query('//details[@class="form-wrapper" and @open="open"]/div[@class="details-wrapper"]/details[@class="form-wrapper" and @open="open"]/div [@class="details-wrapper" and text()="Subchild"]')->length;
-    $this->assertTrue($parent && $child && $subchild, 'The correct data is cached: the stored #markup is not affected by #post_render_cache callbacks.');
-
-    // Remove markup because it's compared above in the xpath.
-    unset($cached_element['#markup']);
-    $this->assertIdentical($cached_element, $expected_element, 'The correct data is cached: the stored #attached properties are not affected by #post_render_cache callbacks.');
-
-    // GET request: #cache enabled, cache hit.
-    $element = $test_element;
-    $output = drupal_render_root($element);
-    $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
-    $this->assertFalse(isset($element['#printed']), 'Cache hit');
-    $this->assertIdentical($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each #post_render_cache callback exist.');
-
-    // Test case 2.
-    // Use the exact same element, but now unset #cache.
-    unset($test_element['#cache']);
-    $element = $test_element;
-    $output = drupal_render_root($element);
-    $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
-    $this->assertIdentical($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
-    $this->assertIdentical($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each #post_render_cache callback exist.');
-
-    // Test case 3.
-    // Create an element with a child and subchild. Each element has the same
-    // #post_render_cache callback, but with different contexts. Both the
-    // parent and the child elements have #cache set. The cached parent element
-    // must contain the pristine child element, i.e. unaffected by its
-    // #post_render_cache callbacks. I.e. the #post_render_cache callbacks may
-    // not yet have run, or otherwise the cached parent element would contain
-    // personalized data, thereby breaking the render cache.
-    $element = $test_element;
-    $element['#cache']['keys'] = array('simpletest', 'drupal_render', 'children_post_render_cache', 'nested_cache_parent');
-    $element['child']['#cache']['keys'] = array('simpletest', 'drupal_render', 'children_post_render_cache', 'nested_cache_child');
-    $output = drupal_render_root($element);
-    $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
-    $this->assertTrue(isset($element['#printed']), 'No cache hit');
-    $this->assertIdentical($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
-    $this->assertIdentical($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each #post_render_cache callback exist.');
-
-    // GET request: validate cached data for both the parent and child.
-    $cached_parent_element = \Drupal::cache('render')->get('simpletest:drupal_render:children_post_render_cache:nested_cache_parent')->data;
-    $cached_child_element = \Drupal::cache('render')->get('simpletest:drupal_render:children_post_render_cache:nested_cache_child')->data;
-    $expected_parent_element = array(
-      '#attached' => array(
-        'drupalSettings' => [
-          'foo' => 'bar',
-        ],
-        'library' => array(
-          'core/drupal.collapse',
-          'core/drupal.collapse',
-        ),
-      ),
-      '#post_render_cache' => array(
-        'common_test_post_render_cache' => array(
-          $context_1,
-          $context_2,
-          $context_3,
-        )
-      ),
-      '#cache' => array('tags' => array('rendered')),
-    );
-
-    $dom = Html::load($cached_parent_element['#markup']);
-    $xpath = new \DOMXPath($dom);
-    $parent = $xpath->query('//details[@class="form-wrapper" and @open="open"]/summary[@role="button" and @aria-expanded and text()="Parent"]')->length;
-    $child =  $xpath->query('//details[@class="form-wrapper" and @open="open"]/div[@class="details-wrapper"]/details[@class="form-wrapper" and @open="open"]/summary[@role="button" and @aria-expanded and text()="Child"]')->length;
-    $subchild = $xpath->query('//details[@class="form-wrapper" and @open="open"]/div[@class="details-wrapper"]/details[@class="form-wrapper" and @open="open"]/div [@class="details-wrapper" and text()="Subchild"]')->length;
-    $this->assertTrue($parent && $child && $subchild, 'The correct data is cached for the parent: the stored #markup is not affected by #post_render_cache callbacks.');
-
-    // Remove markup because it's compared above in the xpath.
-    unset($cached_parent_element['#markup']);
-    $this->assertIdentical($cached_parent_element, $expected_parent_element, 'The correct data is cached for the parent: the stored #attached properties are not affected by #post_render_cache callbacks.');
-
-    $expected_child_element = array(
-      '#attached' => array(
-        'library' => array(
-          'core/drupal.collapse',
-        ),
-      ),
-      '#post_render_cache' => array(
-        'common_test_post_render_cache' => array(
-          $context_2,
-          $context_3,
-        )
-      ),
-      '#cache' => array('tags' => array('rendered')),
-    );
-
-    $dom = Html::load($cached_child_element['#markup']);
-    $xpath = new \DOMXPath($dom);
-    $child =  $xpath->query('//details[@class="form-wrapper" and @open="open"]/summary[@role="button" and @aria-expanded and text()="Child"]')->length;
-    $subchild = $xpath->query('//details[@class="form-wrapper" and @open="open"]/div [@class="details-wrapper" and text()="Subchild"]')->length;
-    $this->assertTrue($child && $subchild, 'The correct data is cached for the child: the stored #markup is not affected by #post_render_cache callbacks.');
-
-    // Remove markup because it's compared above in the xpath.
-    unset($cached_child_element['#markup']);
-    $this->assertIdentical($cached_child_element, $expected_child_element, 'The correct data is cached for the child: the stored #attached properties are not affected by #post_render_cache callbacks.');
-
-    // GET request: #cache enabled, cache hit, parent element.
-    $element = $test_element;
-    $element['#cache']['keys'] = array('simpletest', 'drupal_render', 'children_post_render_cache', 'nested_cache_parent');
-    $output = drupal_render_root($element);
-    $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
-    $this->assertFalse(isset($element['#printed']), 'Cache hit');
-    $this->assertIdentical($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each #post_render_cache callback exist.');
-
-    // GET request: #cache enabled, cache hit, child element.
-    $element = $test_element;
-    $element['child']['#cache']['keys'] = array('simpletest', 'drupal_render', 'children_post_render_cache', 'nested_cache_child');
-    $element = $element['child'];
-    $output = drupal_render_root($element);
-    $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
-    $this->assertFalse(isset($element['#printed']), 'Cache hit');
-    $expected_js_settings = [
-      'common_test' => $context_2 + $context_3,
-    ];
-    $this->assertIdentical($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each #post_render_cache callback exist.');
-
-    // Restore the previous request method.
-    \Drupal::request()->setMethod($request_method);
-  }
-
-  /**
    * Tests post-render cache-integrated 'render_cache_placeholder' element.
    */
   function testDrupalRenderRenderCachePlaceholder() {
@@ -1013,146 +288,6 @@ function testDrupalRenderChildElementRenderCachePlaceholder() {
   }
 
   /**
-   * Tests a #post_render_cache callback that adds another #post_render_cache
-   * callback.
-   *
-   * E.g. when rendering a node in a #post_render_cache callback, the rendering
-   * of that node needs a #post_render_cache callback of its own to be executed
-   * (to render the node links).
-   */
-  function testRecursivePostRenderCache() {
-    $context = array('foo' => $this->randomContextValue());
-    $element = [];
-    $element['#markup'] = '';
-    $element['#post_render_cache']['common_test_post_render_cache_recursion'] = array(
-      $context
-    );
-
-    $output = drupal_render_root($element);
-    $this->assertEqual('<p>overridden</p>', $output, 'The output has been modified by the indirect, recursive #post_render_cache callback.');
-    $this->assertIdentical($element['#markup'], '<p>overridden</p>', '#markup is overridden by the indirect, recursive #post_render_cache callback.');
-    $expected_js_settings = [
-      'common_test' => $context,
-    ];
-    $this->assertIdentical($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified by the indirect, recursive #post_render_cache callback.');
-  }
-
-  /**
-   * #pre_render callback for testDrupalRenderBubbling().
-   */
-  public static function bubblingPreRender($elements) {
-    $callback = 'Drupal\system\Tests\Common\RenderTest::bubblingPostRenderCache';
-    $context = array(
-      'foo' => 'bar',
-      'baz' => 'qux',
-    );
-    $placeholder = drupal_render_cache_generate_placeholder($callback, $context);
-    $elements += array(
-      'child_cache_tag' => array(
-        '#cache' => array(
-          'tags' => array('child:cache_tag'),
-        ),
-        '#markup' => 'Cache tag!',
-      ),
-      'child_asset' => array(
-        '#attached' => array(
-          'drupalSettings' => array('foo' => 'bar'),
-        ),
-        '#markup' => 'Asset!',
-      ),
-      'child_post_render_cache' => array(
-        '#post_render_cache' => array(
-          $callback => array(
-            $context,
-          ),
-        ),
-        '#markup' => $placeholder,
-      ),
-      'child_nested_pre_render_uncached' => array(
-        '#cache' => array('cid' => 'uncached_nested'),
-        '#pre_render' => array('Drupal\system\Tests\Common\RenderTest::bubblingNestedPreRenderUncached'),
-      ),
-      'child_nested_pre_render_cached' => array(
-        '#cache' => array('cid' => 'cached_nested'),
-        '#pre_render' => array('Drupal\system\Tests\Common\RenderTest::bubblingNestedPreRenderCached'),
-      ),
-    );
-    return $elements;
-  }
-
-  /**
-   * #pre_render callback for testDrupalRenderBubbling().
-   */
-  public static function bubblingNestedPreRenderUncached($elements) {
-    \Drupal::state()->set('bubbling_nested_pre_render_uncached', TRUE);
-    $elements['#markup'] = 'Nested!';
-    return $elements;
-  }
-
-  /**
-   * #pre_render callback for testDrupalRenderBubbling().
-   */
-  public static function bubblingNestedPreRenderCached($elements) {
-    \Drupal::state()->set('bubbling_nested_pre_render_cached', TRUE);
-    return $elements;
-  }
-
-  /**
-   * #post_render_cache callback for testDrupalRenderBubbling().
-   */
-  public static function bubblingPostRenderCache(array $element, array $context) {
-    $callback = 'Drupal\system\Tests\Common\RenderTest::bubblingPostRenderCache';
-    $placeholder = drupal_render_cache_generate_placeholder($callback, $context);
-    $element['#markup'] = str_replace($placeholder, 'Post-render cache!' . $context['foo'] . $context['baz'], $element['#markup']);
-    return $element;
-  }
-
-  /**
-   * Tests bubbling of assets, cache tags and post-render cache callbacks when
-   * they are added by #pre_render callbacks.
-   */
-  function testDrupalRenderBubbling() {
-    $verify_result= function ($test_element) {
-      \Drupal::state()->set('bubbling_nested_pre_render_uncached', FALSE);
-      \Drupal::state()->set('bubbling_nested_pre_render_cached', FALSE);
-      \Drupal::cache('render')->set('cached_nested', array('#markup' => 'Cached nested!', '#attached' => array(), '#cache' => array('tags' => array()), '#post_render_cache' => array()));
-      \Drupal::cache('render')->delete('uncached_nested');
-
-      $output = drupal_render_root($test_element);
-      // Assert top-level.
-      $this->assertEqual('Cache tag!Asset!Post-render cache!barquxNested!Cached nested!', trim($output), 'Expected HTML generated.');
-      $this->assertEqual(array('child:cache_tag'), $test_element['#cache']['tags'], 'Expected cache tags found.');
-      $expected_attached = array(
-        'drupalSettings' => array('foo' => 'bar'),
-      );
-      $this->assertEqual($expected_attached, $test_element['#attached'], 'Expected assets found.');
-      $this->assertEqual([], $test_element['#post_render_cache'], '#post_render_cache property is empty after rendering');
-
-      // Ensure that #pre_render callbacks are only executed if they don't have
-      // a render cache hit.
-      $this->assertTrue(\Drupal::state()->get('bubbling_nested_pre_render_uncached'));
-      $this->assertFalse(\Drupal::state()->get('bubbling_nested_pre_render_cached'));
-    };
-
-    $this->pass('Test <strong>without</strong> theming/Twig.');
-    $test_element_without_theme = array(
-      'foo' => array(
-        '#pre_render' => array(array(get_class($this), 'bubblingPreRender')),
-      ),
-    );
-    $verify_result($test_element_without_theme);
-
-    $this->pass('Test <strong>with</strong> theming/Twig.');
-    $test_element_with_theme = array(
-      '#theme' => 'common_test_render_element',
-      'foo' => array(
-        '#pre_render' => array(array(get_class($this), 'bubblingPreRender')),
-      ),
-    );
-    $verify_result($test_element_with_theme);
-  }
-
-  /**
    * Generates a random context value for the post-render cache tests.
    *
    * The #context array used by the post-render cache callback will generally
@@ -1189,106 +324,4 @@ public function testDrupalProcessAttached() {
     }
   }
 
-  /**
-   * Tests \Drupal\Core\Render\Renderer::renderPlain().
-   */
-  public function testRenderPlain() {
-    $renderer = \Drupal::service('renderer');
-
-    $complex_child_markup = '<p>Imagine this is a render array for an entity.</p>';
-    $parent_markup = '<p>Rendered!</p>';
-
-    $complex_child_template = [
-      '#markup' => $complex_child_markup,
-      '#attached' => [
-        'library' => [
-          'core/drupal',
-        ],
-      ],
-      '#cache' => [
-        'tags' => [
-          'test:complex_child',
-        ],
-      ],
-      '#post_render_cache' => [
-        'common_test_post_render_cache' => [
-          ['foo' => $this->randomString()],
-        ],
-      ],
-    ];
-
-    // Case 1: ::renderRoot() with nested ::renderRoot().
-    $this->pass('Renderer::renderRoot() may not be called inside of another Renderer::renderRoot() call, this must trigger an exception.');
-    try {
-      $complex_child = $complex_child_template;
-      $page = [
-        'content' => [
-          '#pre_render' => [
-            function () use ($renderer, $complex_child) {
-              $renderer->renderRoot($complex_child);
-            }
-          ],
-          '#suffix' => $parent_markup,
-        ]
-      ];
-      $renderer->renderRoot($page);
-      $this->fail('No exception triggered.');
-    }
-    catch (\LogicException $e) {
-      $this->pass('Exception triggered.');
-    }
-
-    // Case 2: ::renderRoot() with nested ::render().
-    $this->pass('Renderer::render() may be called from anywhere, including from inside of another Renderer::renderRoot() call. Bubbling must be performed.');
-    try {
-      $complex_child = $complex_child_template;
-      $page = [
-        'content' => [
-          '#pre_render' => [
-            function ($elements) use ($renderer, $complex_child, $complex_child_markup, $parent_markup) {
-              $elements['#markup'] = $renderer->render($complex_child);
-              $this->assertEqual($complex_child_markup, $elements['#markup'], 'Rendered complex child output as expected, without the #post_render_cache callback executed.');
-              return $elements;
-            }
-          ],
-          '#suffix' => $parent_markup,
-        ]
-      ];
-      $output = $renderer->renderRoot($page);
-      $this->pass('No exception triggered.');
-      $this->assertEqual('<p>overridden</p>', $output, 'Rendered output as expected, with the #post_render_cache callback executed.');
-      $this->assertTrue(in_array('test:complex_child', $page['#cache']['tags']), 'Cache tag bubbling performed.');
-      $this->assertTrue(in_array('core/drupal', $page['#attached']['library']), 'Asset bubbling performed.');
-    }
-    catch (\LogicException $e) {
-      $this->fail('Exception triggered.');
-    }
-
-    // Case 3: ::renderRoot() with nested ::renderPlain().
-    $this->pass('Renderer::renderPlain() may be called from anywhere, including from inside of another Renderer::renderRoot() call.');
-    try {
-      $complex_child = $complex_child_template;
-      $page = [
-        'content' => [
-          '#pre_render' => [
-            function ($elements) use ($renderer, $complex_child, $parent_markup) {
-              $elements['#markup'] = $renderer->renderPlain($complex_child);
-              $this->assertEqual('<p>overridden</p>', $elements['#markup'], 'Rendered complex child output as expected, with the #post_render_cache callback executed.');
-              return $elements;
-            }
-          ],
-          '#suffix' => $parent_markup,
-        ]
-      ];
-      $output = $renderer->renderRoot($page);
-      $this->pass('No exception triggered.');
-      $this->assertEqual('<p>overridden</p>' . $parent_markup, $output, 'Rendered output as expected, with the #post_render_cache callback executed.');
-      $this->assertFalse(in_array('test:complex_child', $page['#cache']['tags']), 'Cache tag bubbling not performed.');
-      $this->assertTrue(empty($page['#attached']), 'Asset bubbling not performed.');
-    }
-    catch (\LogicException $e) {
-      $this->fail('Exception triggered.');
-    }
-  }
-
 }
diff --git a/core/tests/Drupal/Tests/Core/Render/RendererTest.php b/core/tests/Drupal/Tests/Core/Render/RendererTest.php
new file mode 100644
index 0000000..c8db79e
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Render/RendererTest.php
@@ -0,0 +1,1496 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Tests\Core\Render\RendererTest.
+ */
+
+namespace Drupal\Tests\Core\Render;
+
+
+use Drupal\Component\Utility\Html;
+use Drupal\Core\Cache\MemoryBackend;
+use Drupal\Core\Cache\NullBackend;
+use Drupal\Core\KeyValueStore\KeyValueMemoryFactory;
+use Drupal\Core\KeyValueStore\MemoryStorage;
+use Drupal\Core\Render\Element;
+use Drupal\Core\Render\Renderer;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\Core\State\State;
+use Drupal\Core\Template\Attribute;
+use Drupal\Core\Url;
+use Drupal\system\Tests\Cache\MemoryBackendUnitTest;
+use Drupal\Tests\UnitTestCase;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Render\Renderer
+ * @group Render
+ */
+class RendererTest extends UnitTestCase {
+
+  /**
+   * The tested renderer.
+   *
+   * @var \Drupal\Core\Render\Renderer
+   */
+  protected $renderer;
+
+  /**
+   * @var \Symfony\Component\HttpFoundation\RequestStack
+   */
+  protected $requestStack;
+
+  /**
+   * @var \Drupal\Core\Cache\CacheFactoryInterface|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $cacheFactory;
+
+  /**
+   * @var \Drupal\Core\Cache\CacheContexts|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $cacheContexts;
+
+  /**
+   * The mocked controller resolver.
+   *
+   * @var \Drupal\Core\Controller\ControllerResolverInterface|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $controllerResolver;
+
+  /**
+   * The mocked theme manager.
+   *
+   * @var \Drupal\Core\Theme\ThemeManagerInterface|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $themeManager;
+
+  /**
+   * The mocked element info.
+   *
+   * @var \Drupal\Core\Render\ElementInfoManagerInterface|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $elementInfo;
+
+  protected $defaultThemeVars = [
+    '#cache' => ['tags' => []],
+    '#attached' => [],
+    '#post_render_cache' => [],
+    '#children' => '',
+  ];
+
+  /**
+   * @var \Drupal\Core\Cache\CacheBackendInterface
+   */
+  protected $memoryCache;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->controllerResolver = $this->getMock('Drupal\Core\Controller\ControllerResolverInterface');
+    $this->themeManager = $this->getMock('Drupal\Core\Theme\ThemeManagerInterface');
+    $this->elementInfo = $this->getMock('Drupal\Core\Render\ElementInfoManagerInterface');
+    $this->requestStack = new RequestStack();
+    $this->cacheFactory = $this->getMock('Drupal\Core\Cache\CacheFactoryInterface');
+    $this->cacheContexts = $this->getMockBuilder('Drupal\Core\Cache\CacheContexts')
+      ->disableOriginalConstructor()
+      ->getMock();
+    $this->renderer = new Renderer($this->controllerResolver, $this->themeManager, $this->elementInfo, $this->requestStack, $this->cacheFactory, $this->cacheContexts);
+
+    $container = new ContainerBuilder();
+    $container->set('renderer', $this->renderer);
+    \Drupal::setContainer($container);
+  }
+
+  /**
+   * @dataProvider providerTestRenderBasic
+   */
+  public function testRenderBasic($build, $expected, callable $setup_code = NULL) {
+
+    if (isset($setup_code)) {
+      $setup_code = $setup_code->bindTo($this);
+      $setup_code();
+    }
+
+    $this->assertSame($expected, $this->renderer->render($build));
+  }
+
+  public function providerTestRenderBasic() {
+    $data = [];
+
+    // Pass a NULL.
+    $data[] = [NULL, ''];
+    // Pass an empty string.
+    $data[] = ['', ''];
+    // Previously printed, see ::renderTwice for a more integration like test.
+    $data[] = [[
+      '#markup' => 'foo',
+      '#printed' => TRUE,
+      ], ''];
+    // Printed in pre_render.
+    $data[] = [[
+      '#markup' => 'foo',
+      '#pre_render' => [[new TestCallables(), 'preRenderPrinted']]
+    ], ''];
+
+    // Test that #theme and #theme_wrappers can co-exist on an element.
+    // #theme and #theme_wrappers basic
+    $build = [
+      '#theme' => 'common_test_foo',
+      '#foo' => 'foo',
+      '#bar' => 'bar',
+      '#theme_wrappers' => ['container'],
+      '#attributes' => ['class' => ['baz']],
+    ];
+    $setup_code_type_link = function() {
+      $this->setupThemeContainer();
+      $this->themeManager->expects($this->at(0))
+        ->method('render')
+        ->with('common_test_foo', $this->anything())
+        ->willReturnCallback(function($theme, $vars) {
+          return $vars['#foo'] . $vars['#bar'];
+        });
+    };
+    $data[] = [$build, '<div class="baz">foobar</div>' . "\n", $setup_code_type_link];
+
+    // Test that #theme_wrappers can disambiguate element attributes shared
+    // with rendering methods that build #children by using the alternate
+    // #theme_wrappers attribute override syntax.
+    // '#theme and #theme_wrappers attribute disambiguation'.
+
+    $build = [
+      '#type' => 'link',
+      '#theme_wrappers' => [
+        'container' => [
+          '#attributes' => ['class' => ['baz']],
+        ],
+      ],
+      '#attributes' => ['id' => 'foo'],
+      '#url' => 'http://drupal.org',
+      '#title' => 'bar',
+    ];
+    $setup_code_type_link = function() {
+      $this->setupThemeContainer();
+      $this->themeManager->expects($this->at(0))
+        ->method('render')
+        ->with('link', $this->anything())
+        ->willReturnCallback(function($theme, $vars) {
+          $attributes = new Attribute(['href' => $vars['#url']] + (isset($vars['#attributes']) ? $vars['#attributes'] : []));
+          return '<a' . (string) $attributes . '>' . $vars['#title'] . '</a>';
+        });
+      $this->elementInfo->expects($this->atLeastOnce())
+        ->method('getInfo')
+        ->with('link')
+        ->willReturn(['#theme' => 'link']);
+    };
+    $data[] = [$build, '<div class="baz"><a href="http://drupal.org" id="foo">bar</a></div>' . "\n", $setup_code_type_link];
+
+    // Test that #theme_wrappers can disambiguate element attributes when the
+    // "base" attribute is not set for #theme.
+    // #theme_wrappers attribute disambiguation with undefined #theme attribute.
+    $build = [
+      '#type' => 'link',
+      '#url' => 'http://drupal.org',
+      '#title' => 'foo',
+      '#theme_wrappers' => [
+        'container' => [
+          '#attributes' => ['class' => ['baz']],
+        ],
+      ],
+    ];
+    $data[] = [$build, '<div class="baz"><a href="http://drupal.org">foo</a></div>' . "\n", $setup_code_type_link];
+
+    // Two 'container' #theme_wrappers, one using the "base" attributes and
+    // one using an override.
+    $build = [
+      '#attributes' => ['class' => ['foo']],
+      '#theme_wrappers' => [
+        'container' => [
+          '#attributes' => ['class' => ['bar']],
+        ],
+        'container',
+      ],
+    ];
+    $setup_code = function() {
+      $this->setupThemeContainer($this->any());
+    };
+    $data[] = [$build, '<div class="foo"><div class="bar"></div>' . "\n" . '</div>' . "\n", $setup_code];
+
+    // Array syntax theme hook suggestion in #theme_wrappers.
+    $build = [
+      '#theme_wrappers' => [['container']],
+      '#attributes' => ['class' => ['foo']],
+    ];
+    $setup_code = function() {
+      $this->setupThemeContainerMultiSuggestion($this->any());
+    };
+    $data[] = [$build, '<div class="foo"></div>' . "\n", $setup_code];
+
+    // Test handling of #markup as a fallback for #theme hooks.
+    // Theme suggestion is not implemented, #markup should be rendered.
+    $build = [
+      '#theme' => ['suggestionnotimplemented'],
+      '#markup' => 'foo',
+    ];
+    $setup_code = function() {
+      $this->themeManager->expects($this->once())
+        ->method('render')
+        ->with(['suggestionnotimplemented'], $this->anything())
+        ->willReturn(FALSE);
+    };
+    $data[] = [$build, 'foo', $setup_code];
+
+    // Theme suggestion is not implemented, child #markup should be rendered.
+    $build = [
+      '#theme' => ['suggestionnotimplemented'],
+      'child' => [
+        '#markup' => 'foo',
+      ],
+    ];
+
+    $setup_code = function() {
+      $this->themeManager->expects($this->once())
+        ->method('render')
+        ->with(['suggestionnotimplemented'], $this->anything())
+        ->willReturn(FALSE);
+    };
+
+    $data[] = [$build, 'foo', $setup_code];
+
+    // Theme suggestion is implemented but returns empty string, #markup
+    // should not be rendered.
+    $build = [
+      '#theme' => ['common_test_empty'],
+      '#markup' => 'foo',
+    ];
+
+    $setup_code = function() {
+      $this->themeManager->expects($this->once())
+        ->method('render')
+        ->with(['common_test_empty'], $this->anything())
+        ->willReturn('');
+    };
+
+    $data[] = [$build, '', $setup_code];
+
+    // Theme suggestion is implemented but returns empty string, children
+    // should not be rendered.
+
+    $build = [
+      '#theme' => ['common_test_empty'],
+      'child' => [
+        '#markup' => 'foo',
+      ],
+    ];
+
+    $setup_code = function() {
+      $this->themeManager->expects($this->once())
+        ->method('render')
+        ->with(['common_test_empty'], $this->anything())
+        ->willReturn('');
+    };
+
+    $data[] = [$build, '', $setup_code];
+
+    // Test handling of #children and child renderable elements.
+    // #theme is implemented so the values of both #children and 'child' will
+    // be ignored - it is the responsibility of the theme hook to render these
+    // if appropriate.
+
+    $build = [
+      '#theme' => 'common_test_foo',
+      '#children' => 'baz',
+      'child' => ['#markup' => 'boo'],
+    ];
+
+    $setup_code = function() {
+      $this->themeManager->expects($this->once())
+        ->method('render')
+        ->with('common_test_foo', $this->anything())
+        ->willReturn('foobar');
+    };
+
+    $data[] = [$build, 'foobar', $setup_code];
+
+    // #theme is implemented but #render_children is TRUE. As in the case
+    // where #theme is not set, empty #children means child elements are
+    // rendered recursively.
+    $build = [
+      '#theme' => 'common_test_foo',
+      '#children' => '',
+      '#render_children' => TRUE,
+      'child' => [
+        '#markup' => 'boo',
+      ],
+    ];
+
+    $setup_code = function() {
+      $this->themeManager->expects($this->never())
+        ->method('render');
+    };
+
+    $data[] = [$build, 'boo', $setup_code];
+
+    // #theme is implemented but #render_children is TRUE. As in the case
+    // where #theme is not set, #children will take precedence over 'child'.
+
+    $build = [
+      '#theme' => 'common_test_foo',
+      '#children' => 'baz',
+      '#render_children' => TRUE,
+      'child' => [
+        '#markup' => 'boo',
+      ],
+    ];
+
+    $setup_code = function() {
+      $this->themeManager->expects($this->never())
+        ->method('render');
+    };
+
+    $data[] = [$build, 'baz', $setup_code];
+
+    // Test handling of #markup as a fallback for #theme hooks.
+    // Simple #markup with no theme.
+
+    // Basic #markup based renderable array.
+    $data[] = [
+      ['#markup' => 'foo']
+    , 'foo'];
+
+    // Test handling of #children and child renderable elements.
+    // #theme is not set, #children is not set and the array has children.
+    $data[] = [
+      [
+        'child' => ['#markup' => 'bar'],
+      ], 'bar'];
+    // #theme is not set, #children is set but empty and the array has
+    // children.
+    $data[] = [
+      [
+        '#children' => '',
+        'child' => ['#markup' => 'bar'],
+      ], 'bar'
+    ];
+    // #theme is not set, #children is not empty and will be assumed to be the
+    // rendered child elements even though the #markup for 'child' differs.
+    $data[] = [
+      [
+        '#children' => 'foo',
+        'child' => ['#markup' => 'bar'],
+      ], 'foo'
+    ];
+
+    return $data;
+  }
+
+  /**
+   * @covers ::render
+   */
+  public function testRenderSorting() {
+    $first = $this->randomMachineName();
+    $second = $this->randomMachineName();
+    // Build an array with '#weight' set for each element.
+    $elements = [
+      'second' => [
+        '#weight' => 10,
+        '#markup' => $second,
+      ],
+      'first' => [
+        '#weight' => 0,
+        '#markup' => $first,
+      ],
+    ];
+    $output = $this->renderer->render($elements);
+
+    // The lowest weight element should appear last in $output.
+    $this->assertTrue(strpos($output, $second) > strpos($output, $first), 'Elements were sorted correctly by weight.');
+
+    // Confirm that the $elements array has '#sorted' set to TRUE.
+    $this->assertTrue($elements['#sorted'], "'#sorted' => TRUE was added to the array");
+
+    // Pass $elements through \Drupal\Core\Render\Element::children() and
+    // ensure it remains sorted in the correct order. drupal_render() will
+    // return an empty string if used on the same array in the same request.
+    $children = Element::children($elements);
+    $this->assertTrue(array_shift($children) == 'first', 'Child found in the correct order.');
+    $this->assertTrue(array_shift($children) == 'second', 'Child found in the correct order.');
+  }
+
+  /**
+   * @covers ::render
+   */
+  public function testRenderSortingWithSetHashSorted() {
+    $first = $this->randomMachineName();
+    $second = $this->randomMachineName();
+    // The same array structure again, but with #sorted set to TRUE.
+    $elements = array(
+      'second' => array(
+        '#weight' => 10,
+        '#markup' => $second,
+      ),
+      'first' => array(
+        '#weight' => 0,
+        '#markup' => $first,
+      ),
+      '#sorted' => TRUE,
+    );
+    $output = $this->renderer->render($elements);
+
+    // The elements should appear in output in the same order as the array.
+    $this->assertTrue(strpos($output, $second) < strpos($output, $first), 'Elements were not sorted.');
+  }
+
+  /**
+   * @dataProvider providerBoolean
+   */
+  public function testRenderWithPresetAccess($access) {
+    $build = [
+      '#markup' => 'test',
+      '#access' => $access,
+    ];
+
+    $this->assertAccess($build, $access);
+  }
+
+  /**
+   * @dataProvider providerBoolean
+   * @covers ::render
+   */
+  public function testRenderWithAccessCallbackCallable($access) {
+    $build = [
+      '#markup' => 'test',
+      '#access_callback' => function() use ($access) {
+        return $access;
+      }
+    ];
+
+    $this->assertAccess($build, $access);
+  }
+
+  /**
+   * @dataProvider providerBoolean
+   *
+   * Ensure that the #access property wins over the callable.
+   *
+   * @covers ::render
+   */
+  public function testRenderWithAccessPropertyAndCallback($access) {
+    $build = [
+      '#markup' => 'test',
+      '#access' => $access,
+      '#access_callback' => function() {
+        return TRUE;
+      }
+    ];
+
+    $this->assertAccess($build, $access);
+  }
+
+  /**
+   * @dataProvider providerBoolean
+   *
+   * @covers ::render
+   */
+  public function testRenderWithAccessControllerResolved($access) {
+    $build = [
+      '#markup' => 'test',
+      '#access_callback' => 'Drupal\Tests\Core\Render\TestAccessClass::' . ($access ? 'accessTrue' : 'accessFalse'),
+    ];
+
+    $this->assertAccess($build, $access);
+  }
+
+  /**
+   * @covers ::render
+   */
+  public function testRenderTwice() {
+    $build = [
+      '#markup' => 'test',
+    ];
+
+    $this->assertEquals('test', $this->renderer->render($build));
+    // @todo This behaviour is odd ...
+    $this->assertEquals('', $this->renderer->render($build));
+  }
+
+
+
+  public function providerBoolean() {
+    return [
+      [FALSE],
+      [TRUE]
+    ];
+  }
+
+  protected function assertAccess($build, $access) {
+    if ($access) {
+      $this->assertSame('test', $this->renderer->render($build));
+    }
+    else {
+      $this->assertSame('', $this->renderer->render($build));
+    }
+  }
+
+  protected function setupThemeContainer($matcher = NULL) {
+    $this->themeManager->expects($matcher ?: $this->at(1))
+      ->method('render')
+      ->with('container', $this->anything())
+      ->willReturnCallback(function($theme, $vars) {
+        return '<div' . (string) (new Attribute($vars['#attributes'])) . '>' . $vars['#children'] . "</div>\n";
+      });
+  }
+
+  protected function setupThemeContainerMultiSuggestion($matcher = NULL) {
+    $this->themeManager->expects($matcher ?: $this->at(1))
+      ->method('render')
+      ->with(['container'], $this->anything())
+      ->willReturnCallback(function($theme, $vars) {
+        return '<div' . (string) (new Attribute($vars['#attributes'])) . '>' . $vars['#children'] . "</div>\n";
+      });
+  }
+
+  /**
+   * @covers ::render
+   */
+  public function testRenderWithoutThemeArguments() {
+    $element = array(
+      '#theme' => 'common_test_foo',
+    );
+
+    $this->themeManager->expects($this->once())
+      ->method('render')
+      ->with('common_test_foo', $this->defaultThemeVars + $element)
+      ->willReturn('foobar');
+
+    // Test that defaults work.
+    $this->assertEquals($this->renderer->render($element), 'foobar', 'Defaults work');
+  }
+
+  /**
+   * @covers ::render
+   */
+  public function testRenderWithThemeArguments() {
+    $element = array(
+      '#theme' => 'common_test_foo',
+      '#foo' => $this->randomMachineName(),
+      '#bar' => $this->randomMachineName(),
+    );
+
+    $this->themeManager->expects($this->once())
+      ->method('render')
+      ->with('common_test_foo', $this->defaultThemeVars + $element)
+      ->willReturnCallback(function ($hook, $vars) {
+        return $vars['#foo'] . $vars['#bar'];
+      });
+
+    // Tests that passing arguments to the theme function works.
+    $this->assertEquals($this->renderer->render($element), $element['#foo'] . $element['#bar'], 'Passing arguments to theme functions works');
+  }
+
+  public function testRenderCache() {
+    // The cache system is turned off for POST requests.
+    $request = Request::create('/', 'GET');
+    $this->requestStack->push($request);
+
+    // Create an empty element.
+    $test_element = [
+      '#cache' => [
+        'cid' => 'render_cache_test',
+        'tags' => ['render_cache_tag'],
+      ],
+      '#markup' => '',
+      'child' => [
+        '#cache' => [
+          'cid' => 'render_cache_test_child',
+          'tags' => ['render_cache_tag_child:1', 'render_cache_tag_child:2'],
+        ],
+        '#markup' => '',
+      ],
+    ];
+
+    // Setup a cache which acts like a cache, a memory backend. Adding a mock
+    // for such a generic test, couples the test too much to the internal
+    // implementation.
+    $cache = new MemoryBackend('render');
+    $this->cacheFactory->expects($this->any())
+      ->method('get')
+      ->with('render')
+      ->willReturn($cache);
+
+    // Render the element and confirm that it goes through the rendering
+    // process (which will set $element['#printed']).
+    $element = $test_element;
+    $this->renderer->render($element);
+    $this->assertTrue(isset($element['#printed']), 'No cache hit');
+
+    // Render the element again and confirm that it is retrieved from the cache
+    // instead (so $element['#printed'] will not be set).
+    $element = $test_element;
+    $this->renderer->render($element);
+    $this->assertFalse(isset($element['#printed']), 'Cache hit');
+
+    // Test that cache tags are correctly collected from the render element,
+    // including the ones from its subchild.
+    $expected_tags = [
+      'render_cache_tag',
+      'render_cache_tag_child:1',
+      'render_cache_tag_child:2',
+      'rendered',
+    ];
+    $this->assertEquals($expected_tags, $element['#cache']['tags'], 'Cache tags were collected from the element and its subchild.');
+  }
+
+  /**
+   * Tests a #post_render_cache callback that adds another #post_render_cache
+   * callback.
+   *
+   * E.g. when rendering a node in a #post_render_cache callback, the rendering
+   * of that node needs a #post_render_cache callback of its own to be executed
+   * (to render the node links).
+   */
+  public function testRenderRecursivePostRenderCache() {
+    $context = ['foo' => $this->randomContextValue()];
+    $element = [];
+    $element['#markup'] = '';
+
+    $this->setupPostRenderCallbacks();
+
+    $element['#post_render_cache']['Drupal\Tests\Core\Render\PostRenderCacheRecursion::callback'] = [
+      $context
+    ];
+
+    $output = $this->renderer->renderRoot($element);
+    $this->assertEquals('<p>overridden</p>', $output, 'The output has been modified by the indirect, recursive #post_render_cache callback.');
+    $this->assertSame($element['#markup'], '<p>overridden</p>', '#markup is overridden by the indirect, recursive #post_render_cache callback.');
+    $expected_js_settings = [
+      'common_test' => $context,
+    ];
+    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified by the indirect, recursive #post_render_cache callback.');
+  }
+
+  /**
+   * Generates a random context value for the post-render cache tests.
+   *
+   * The #context array used by the post-render cache callback will generally
+   * be used to provide metadata like entity IDs, field machine names, paths,
+   * etc. for JavaScript replacement of content or assets. In this test, the
+   * callbacks common_test_post_render_cache() and
+   * common_test_post_render_cache_placeholder() render the context inside test
+   * HTML, so using any random string would sometimes cause random test
+   * failures because the test output would be unparseable. Instead, we provide
+   * random tokens for replacement.
+   *
+   * @see common_test_post_render_cache()
+   * @see common_test_post_render_cache_placeholder()
+   * @see https://drupal.org/node/2151609
+   */
+  protected function randomContextValue() {
+    $tokens = ['llama', 'alpaca', 'camel', 'moose', 'elk'];
+    return $tokens[mt_rand(0, 4)];
+  }
+
+  public function testDrupalRenderChildrenAttached() {
+    // The cache system is turned off for POST requests.
+    $request = Request::create('/', 'GET');
+    $this->requestStack->push($request);
+
+    $cache = new MemoryBackend('render');
+    $this->cacheFactory->expects($this->any())
+      ->method('get')
+      ->willReturn($cache);
+
+    // Mock the cache contexts.
+    $this->cacheContexts->expects($this->any())
+      ->method('convertTokensToKeys')
+      ->willReturnArgument(0);
+
+    $this->elementInfo->expects($this->any())
+      ->method('getInfo')
+      ->willReturn([]);
+
+
+    // Create an element with a child and subchild. Each element loads a
+    // different library using #attached.
+    $element = [
+      '#type' => 'container',
+      '#cache' => [
+        'keys' => ['simpletest', 'drupal_render', 'children_attached'],
+      ],
+      '#attached' => ['library' => ['test/parent']],
+      '#title' => 'Parent',
+    ];
+    $element['child'] = [
+      '#type' => 'container',
+      '#attached' => ['library' => ['test/child']],
+      '#title' => 'Child',
+    ];
+    $element['child']['subchild'] = [
+      '#attached' => ['library' => ['test/subchild']],
+      '#markup' => 'Subchild',
+    ];
+
+    // Render the element and verify the presence of #attached JavaScript.
+    $this->renderer->render($element);
+    $expected_libraries = ['test/parent', 'test/child', 'test/subchild'];
+    $this->assertEquals($element['#attached']['library'], $expected_libraries, 'The element, child and subchild #attached libraries are included.');
+
+    // Load the element from cache and verify the presence of the #attached
+    // JavaScript.
+    $element = ['#cache' => ['keys' => ['simpletest', 'drupal_render', 'children_attached']]];
+    $this->assertTrue(strlen($this->renderer->render($element)) > 0, 'The element was retrieved from cache.');
+    $this->assertEquals($element['#attached']['library'], $expected_libraries, 'The element, child and subchild #attached libraries are included.');
+  }
+
+  protected function drupalRenderPostCacheCacheElement() {
+    $context = ['foo' => $this->randomContextValue()];
+    $test_element = [];
+    $test_element['#markup'] = '';
+    $test_element['#attached']['drupalSettings']['foo'] = 'bar';
+    $test_element['#post_render_cache']['Drupal\Tests\Core\Render\PostRenderCache::callback'] = [
+      $context
+    ];
+
+    return [$test_element, $context];
+  }
+
+  public function testPostRenderCacheWithCacheDisabled() {
+    list ($element, $context) = $this->drupalRenderPostCacheCacheElement();
+    $this->setupPostRenderCallbacks();
+    $this->setupNullCache();
+
+    // #cache disabled.
+    $element['#markup'] = '<p>#cache disabled</p>';
+    $output = $this->renderer->renderRoot($element);
+    $this->assertSame($output, '<p>overridden</p>', 'Output is overridden.');
+    $this->assertSame($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
+    $expected_js_settings = [
+      'foo' => 'bar',
+      'common_test' => $context,
+    ];
+    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.');
+  }
+
+
+  public function testPostRenderCacheWithCacheMiss() {
+    list ($test_element, $context) = $this->drupalRenderPostCacheCacheElement();
+    $element = $test_element;
+    $this->setupPostRenderCallbacks();
+    $this->setupMemoryCache();
+
+    // The cache system is turned off for POST requests.
+    $request = Request::create('/');
+    $this->requestStack->push($request);
+
+    // GET request: #cache enabled, cache miss.
+    $element['#cache'] = ['cid' => 'post_render_cache_test_GET'];
+    $element['#markup'] = '<p>#cache enabled, GET</p>';
+    $output = $this->renderer->renderRoot($element);
+    $this->assertSame($output, '<p>overridden</p>', 'Output is overridden.');
+    $this->assertTrue(isset($element['#printed']), 'No cache hit');
+    $this->assertSame($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
+    $expected_js_settings = [
+      'foo' => 'bar',
+      'common_test' => $context,
+    ];
+    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.');
+
+    // GET request: validate cached data.
+    $cached_element = $this->memoryCache->get('post_render_cache_test_GET')->data;
+    $expected_element = [
+      '#markup' => '<p>#cache enabled, GET</p>',
+      '#attached' => $test_element['#attached'],
+      '#post_render_cache' => $test_element['#post_render_cache'],
+      '#cache' => ['tags' => ['rendered']],
+    ];
+    $this->assertSame($cached_element, $expected_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by #post_render_cache callbacks.');
+
+    // GET request: #cache enabled, cache hit.
+    $element = $test_element;
+    $element['#cache'] = ['cid' => 'post_render_cache_test_GET'];
+    $element['#markup'] = '<p>#cache enabled, GET</p>';
+    $output = $this->renderer->renderRoot($element);
+    $this->assertSame($output, '<p>overridden</p>', 'Output is overridden.');
+    $this->assertFalse(isset($element['#printed']), 'Cache hit');
+    $this->assertSame($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
+    $expected_js_settings = [
+      'foo' => 'bar',
+      'common_test' => $context,
+    ];
+    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.');
+  }
+
+  public function testPostRenderCacheWithPostRequest() {
+    list ($test_element, $context) = $this->drupalRenderPostCacheCacheElement();
+    $this->setupPostRenderCallbacks();
+    $this->setupMemoryCache();
+
+    // Verify behavior when handling a non-GET request, e.g. a POST request:
+    // also in that case, #post_render_cache callbacks must be called.
+    $request = Request::create('/', 'POST');
+    $this->requestStack->push($request);
+
+    // POST request: #cache enabled, cache miss.
+    $element = $test_element;
+    $element['#cache'] = ['cid' => 'post_render_cache_test_POST'];
+    $element['#markup'] = '<p>#cache enabled, POST</p>';
+    $output = $this->renderer->renderRoot($element);
+    $this->assertSame($output, '<p>overridden</p>', 'Output is overridden.');
+    $this->assertTrue(isset($element['#printed']), 'No cache hit');
+    $this->assertSame($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
+    $expected_js_settings = [
+      'foo' => 'bar',
+      'common_test' => $context,
+    ];
+    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the #post_render_cache callback exist.');
+
+    // POST request: Ensure no data was cached.
+    $cached_element = $this->memoryCache->get('post_render_cache_test_POST');
+    $this->assertFalse($cached_element, 'No data is cached because this is a POST request.');
+  }
+
+  protected function setupPostRenderCallbacks() {
+    $post_render_callback_recursive = new PostRenderCacheRecursion($this->renderer);
+    $post_render_callback = new PostRenderCache($this->renderer);
+    return [[$post_render_callback_recursive, 'callback'], [$post_render_callback, 'callback']];
+  }
+
+  protected function setupNullCache() {
+    $this->cacheFactory->expects($this->any())
+      ->method('get')
+      ->willReturn(new NullBackend('render'));
+  }
+
+  protected function setupMemoryCache() {
+    $this->memoryCache = $this->memoryCache ?: new MemoryBackend('render');
+
+    $this->cacheFactory->expects($this->any())
+      ->method('get')
+      ->willReturn($this->memoryCache);
+  }
+
+  protected function setupRenderPlainComplexElements() {
+    $complex_child_markup = '<p>Imagine this is a render array for an entity.</p>';
+    $parent_markup = '<p>Rendered!</p>';
+
+    $complex_child_template = [
+      '#markup' => $complex_child_markup,
+      '#attached' => [
+        'library' => [
+          'core/drupal',
+        ],
+      ],
+      '#cache' => [
+        'tags' => [
+          'test:complex_child',
+        ],
+      ],
+      '#post_render_cache' => [
+        'Drupal\Tests\Core\Render\PostRenderCache::callback' => [
+          ['foo' => $this->getRandomGenerator()->string()],
+        ],
+      ],
+    ];
+
+    return [$complex_child_markup, $parent_markup, $complex_child_template];
+  }
+
+  /**
+   * @expectedException \LogicException
+   */
+  public function testRenderPlainWithNestedRenderRoot() {
+    list($complex_child_markup, $parent_markup, $complex_child_template) = $this->setupRenderPlainComplexElements();
+    $renderer = $this->renderer;
+    $this->setupGetRequest();
+
+    // Case 1: ::renderRoot() with nested ::renderRoot().
+    // 'Renderer::renderRoot() may not be called inside of another Renderer::renderRoot() call, this must trigger an exception.');
+    $complex_child = $complex_child_template;
+    $callable = function () use ($renderer, $complex_child) {
+      $renderer->renderRoot($complex_child);
+    };
+    $this->controllerResolver->expects($this->any())
+      ->method('getControllerFromDefinition')
+      ->willReturnCallback(function($controller) use ($renderer, $callable) {
+        if ($controller == 'Drupal\Tests\Core\Render\PostRenderCache::callback') {
+          return [new PostRenderCache($renderer), 'callback'];
+        }
+        return $callable;
+      });
+
+    $page = [
+      'content' => [
+        '#pre_render' => [
+          $callable
+        ],
+        '#suffix' => $parent_markup,
+      ]
+    ];
+    $renderer->renderRoot($page);
+  }
+
+  public function testRenderPlainWithNestedRender() {
+    list($complex_child_markup, $parent_markup, $complex_child_template) = $this->setupRenderPlainComplexElements();
+    $renderer = $this->renderer;
+    $this->setupGetRequest();
+
+    // Case 2: ::renderRoot() with nested ::render().
+    // Renderer::render() may be called from anywhere, including from inside of another Renderer::renderRoot() call. Bubbling must be performed.');
+    $complex_child = $complex_child_template;
+
+    $callable = function ($elements) use ($renderer, $complex_child, $complex_child_markup, $parent_markup) {
+      $elements['#markup'] = $renderer->render($complex_child);
+      $this->assertEquals($complex_child_markup, $elements['#markup'], 'Rendered complex child output as expected, without the #post_render_cache callback executed.');
+      return $elements;
+    };
+    $this->controllerResolver->expects($this->any())
+      ->method('getControllerFromDefinition')
+      ->willReturnCallback(function($controller) use ($renderer, $callable) {
+        if ($controller == 'Drupal\Tests\Core\Render\PostRenderCache::callback') {
+          return [new PostRenderCache($renderer), 'callback'];
+        }
+        return $callable;
+      });
+
+    $page = [
+      'content' => [
+        '#pre_render' => [
+          $callable
+        ],
+        '#suffix' => $parent_markup,
+      ]
+    ];
+    $output = $renderer->renderRoot($page);
+
+    $this->assertEquals('<p>overridden</p>', $output, 'Rendered output as expected, with the #post_render_cache callback executed.');
+    $this->assertTrue(in_array('test:complex_child', $page['#cache']['tags']), 'Cache tag bubbling performed.');
+    $this->assertTrue(in_array('core/drupal', $page['#attached']['library']), 'Asset bubbling performed.');
+  }
+
+  public function testRenderPlainWithNestedRenderPlain() {
+    list($complex_child_markup, $parent_markup, $complex_child_template) = $this->setupRenderPlainComplexElements();
+    $renderer = $this->renderer;
+    $this->setupGetRequest();
+
+    // Case 3: ::renderRoot() with nested ::renderPlain().
+    // 'Renderer::renderPlain() may be called from anywhere, including from inside of another Renderer::renderRoot() call.');
+    $complex_child = $complex_child_template;
+
+    $callable = function ($elements) use ($renderer, $complex_child, $parent_markup) {
+      $elements['#markup'] = $renderer->renderPlain($complex_child);
+      $this->assertEquals('<p>overridden</p>', $elements['#markup'], 'Rendered complex child output as expected, with the #post_render_cache callback executed.');
+      return $elements;
+    };
+    $this->controllerResolver->expects($this->any())
+      ->method('getControllerFromDefinition')
+      ->willReturnCallback(function($controller) use ($renderer, $callable) {
+        if ($controller == 'Drupal\Tests\Core\Render\PostRenderCache::callback') {
+          return [new PostRenderCache($renderer), 'callback'];
+        }
+        return $callable;
+      });
+
+    $page = [
+      'content' => [
+        '#pre_render' => [
+          $callable
+        ],
+        '#suffix' => $parent_markup,
+      ]
+    ];
+    $output = $renderer->renderRoot($page);
+    $this->assertEquals('<p>overridden</p>' . $parent_markup, $output, 'Rendered output as expected, with the #post_render_cache callback executed.');
+    $this->assertFalse(in_array('test:complex_child', $page['#cache']['tags']), 'Cache tag bubbling not performed.');
+    $this->assertTrue(empty($page['#attached']), 'Asset bubbling not performed.');
+  }
+
+  protected function setupGetRequest() {
+    $request = Request::create('/');
+    $this->requestStack->push($request);
+  }
+
+  /**
+   * Tests bubbling of assets, cache tags and post-render cache callbacks when
+   * they are added by #pre_render callbacks.
+   *
+   * @dataProvider providerTestRenderBubbling
+   */
+  public function testRenderBubbling($test_element) {
+    $this->setupGetRequest();
+    $this->setupMemoryCache();
+
+    $memory_state = new State(new KeyValueMemoryFactory());;
+    \Drupal::getContainer()->set('state', $memory_state);
+
+    $this->memoryCache->set('cached_nested', ['#markup' => 'Cached nested!', '#attached' => [], '#cache' => ['tags' => []], '#post_render_cache' => []]);
+
+    $this->controllerResolver->expects($this->any())
+      ->method('getControllerFromDefinition')
+      ->willReturnCallback(function($callable) {
+        return $callable;
+      });
+
+    $this->themeManager->expects($this->any())
+      ->method('render')
+      ->willReturnCallback(function ($hook, $vars) {
+        return $this->renderer->render($vars['foo']);
+      });
+
+    $output = $this->renderer->renderRoot($test_element);
+
+    $this->assertEquals('Cache tag!Asset!Post-render cache!barquxNested!Cached nested!', trim($output), 'Expected HTML generated.');
+    $this->assertEquals(['child:cache_tag'], $test_element['#cache']['tags'], 'Expected cache tags found.');
+    $expected_attached = [
+      'drupalSettings' => ['foo' => 'bar'],
+    ];
+    $this->assertEquals($expected_attached, $test_element['#attached'], 'Expected assets found.');
+    $this->assertEquals([], $test_element['#post_render_cache'], '#post_render_cache property is empty after rendering');
+
+    // Ensure that #pre_render callbacks are only executed if they don't have
+    // a render cache hit.
+    $this->assertTrue(\Drupal::state()->get('bubbling_nested_pre_render_uncached', FALSE));
+    $this->assertFalse(\Drupal::state()->get('bubbling_nested_pre_render_cached', FALSE));
+  }
+
+  public function providerTestRenderBubbling() {
+    $data = [];
+
+    // Test element without theme.
+    $data[] = [[
+      'foo' => [
+      '#pre_render' => [[get_class($this), 'bubblingPreRender']],
+    ]]];
+
+    // Test element with theme.
+    $data[] = [[
+      '#theme' => 'common_test_render_element',
+      'foo' => [
+        '#pre_render' => [[get_class($this), 'bubblingPreRender']],
+      ]]];
+
+    return $data;
+  }
+
+  /**
+   * #pre_render callback for testDrupalRenderBubbling().
+   */
+  public static function bubblingPreRender($elements) {
+    $callback = get_class() . '::bubblingPostRenderCache';
+    $context = [
+      'foo' => 'bar',
+      'baz' => 'qux',
+    ];
+    $placeholder = \Drupal::service('renderer')->generateCachePlaceholder($callback, $context);
+    $elements += [
+      'child_cache_tag' => [
+        '#cache' => [
+          'tags' => ['child:cache_tag'],
+        ],
+        '#markup' => 'Cache tag!',
+      ],
+      'child_asset' => [
+        '#attached' => [
+          'drupalSettings' => ['foo' => 'bar'],
+        ],
+        '#markup' => 'Asset!',
+      ],
+      'child_post_render_cache' => [
+        '#post_render_cache' => [
+          $callback => [
+            $context,
+          ],
+        ],
+        '#markup' => $placeholder,
+      ],
+      'child_nested_pre_render_uncached' => [
+        '#cache' => ['cid' => 'uncached_nested'],
+        '#pre_render' => [get_class() . '::bubblingNestedPreRenderUncached'],
+      ],
+      'child_nested_pre_render_cached' => [
+        '#cache' => ['cid' => 'cached_nested'],
+        '#pre_render' => [get_class() . '::bubblingNestedPreRenderCached'],
+      ],
+    ];
+    return $elements;
+  }
+
+  /**
+   * #pre_render callback for testDrupalRenderBubbling().
+   */
+  public static function bubblingNestedPreRenderUncached($elements) {
+    \Drupal::state()->set('bubbling_nested_pre_render_uncached', TRUE);
+    $elements['#markup'] = 'Nested!';
+    return $elements;
+  }
+
+  /**
+   * #pre_render callback for testDrupalRenderBubbling().
+   */
+  public static function bubblingNestedPreRenderCached($elements) {
+    \Drupal::state()->set('bubbling_nested_pre_render_cached', TRUE);
+    return $elements;
+  }
+
+  /**
+   * #post_render_cache callback for testDrupalRenderBubbling().
+   */
+  public static function bubblingPostRenderCache(array $element, array $context) {
+    $callback = get_class() . '::bubblingPostRenderCache';
+    $placeholder = \Drupal::service('renderer')->generateCachePlaceholder($callback, $context);
+    $element['#markup'] = str_replace($placeholder, 'Post-render cache!' . $context['foo'] . $context['baz'], $element['#markup']);
+    return $element;
+  }
+
+  /**
+   * Create an element with a child and subchild. Each element has the same
+   * #post_render_cache callback, but with different contexts.
+   */
+  public function testRenderChildrenPostRenderCacheDifferentContexts() {
+    $this->setupGetRequest();
+
+    $this->cacheContexts->expects($this->any())
+      ->method('convertTokensToKeys')
+      ->willReturnArgument(0);
+
+    $this->elementInfo->expects($this->any())
+      ->method('getInfo')
+      ->with('details')
+      ->willReturn(['#theme_wrappers' => ['details']]);
+
+    $this->setupMemoryCache();
+
+    $this->controllerResolver->expects($this->any())
+      ->method('getControllerFromDefinition')
+      ->willReturnArgument(0);
+
+    $this->setupThemeManagerForDetails();
+
+    // Test case 1.
+    $context_1 = ['foo' => $this->randomContextValue()];
+    $context_2 = ['bar' => $this->randomContextValue()];
+    $context_3 = ['baz' => $this->randomContextValue()];
+    $test_element = $this->postRenderCacheWithChildrenTestElement($context_1, $context_2, $context_3);
+    $element = $test_element;
+    $output = $this->renderer->renderRoot($element);
+    $this->assertSame($output, '<p>overridden</p>', 'Output is overridden.');
+    $this->assertTrue(isset($element['#printed']), 'No cache hit');
+    $this->assertSame($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
+    $expected_js_settings = [
+      'foo' => 'bar',
+      'common_test' => $context_1 + $context_2 + $context_3,
+    ];
+    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each #post_render_cache callback exist.');
+
+    // GET request: validate cached data.
+    $cached_element = $this->memoryCache->get('simpletest:drupal_render:children_post_render_cache')->data;
+    $expected_element = [
+      '#attached' => [
+        'drupalSettings' => [
+          'foo' => 'bar',
+        ],
+      ],
+      '#post_render_cache' => [
+        'Drupal\Tests\Core\Render\PostRenderCache::callback' => [
+          $context_1,
+          $context_2,
+          $context_3,
+        ]
+      ],
+      '#cache' => ['tags' => ['rendered']],
+    ];
+
+    $dom = Html::load($cached_element['#markup']);
+    $xpath = new \DOMXPath($dom);
+    $parent = $xpath->query('//details/summary[text()="Parent"]')->length;
+    $child =  $xpath->query('//details/div[@class="details-wrapper"]/details/summary[text()="Child"]')->length;
+    $subchild = $xpath->query('//details/div[@class="details-wrapper"]/details/div[@class="details-wrapper" and text()="Subchild"]')->length;
+    $this->assertTrue($parent && $child && $subchild, 'The correct data is cached: the stored #markup is not affected by #post_render_cache callbacks.');
+
+    // 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 #post_render_cache callbacks.');
+
+    // GET request: #cache enabled, cache hit.
+    $element = $test_element;
+    $output = $this->renderer->renderRoot($element);
+    $this->assertSame($output, '<p>overridden</p>', 'Output is 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 #post_render_cache callback exist.');
+
+    // Test case 2.
+    // Use the exact same element, but now unset #cache.
+    unset($test_element['#cache']);
+    $element = $test_element;
+    $output = $this->renderer->renderRoot($element);
+    $this->assertSame($output, '<p>overridden</p>', 'Output is overridden.');
+    $this->assertSame($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
+    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each #post_render_cache callback exist.');
+  }
+
+  /**
+   * Create an element with a child and subchild. Each element has the same
+   * #post_render_cache callback, but with different contexts. Both the
+   * parent and the child elements have #cache set. The cached parent element
+   * must contain the pristine child element, i.e. unaffected by its
+   * #post_render_cache callbacks. I.e. the #post_render_cache callbacks may
+   * not yet have run, or otherwise the cached parent element would contain
+   * personalized data, thereby breaking the render cache.
+   */
+  public function testRenderChildrenPostRenderCacheComplex() {
+    $this->setupGetRequest();
+    $this->setupMemoryCache();
+
+    $context_1 = ['foo' => $this->randomContextValue()];
+    $context_2 = ['bar' => $this->randomContextValue()];
+    $context_3 = ['baz' => $this->randomContextValue()];
+    $test_element = $this->postRenderCacheWithChildrenTestElement($context_1, $context_2, $context_3);
+
+    $this->cacheContexts->expects($this->any())
+      ->method('convertTokensToKeys')
+      ->willReturnArgument(0);
+
+    $this->elementInfo->expects($this->any())
+      ->method('getInfo')
+      ->with('details')
+      ->willReturn(['#theme_wrappers' => ['details']]);
+
+    $this->setupThemeManagerForDetails();
+
+    $expected_js_settings = [
+      'foo' => 'bar',
+      'common_test' => $context_1 + $context_2 + $context_3,
+    ];
+
+    $element = $test_element;
+    $element['#cache']['keys'] = ['simpletest', 'drupal_render', 'children_post_render_cache', 'nested_cache_parent'];
+    $element['child']['#cache']['keys'] = ['simpletest', 'drupal_render', 'children_post_render_cache', 'nested_cache_child'];
+    $output = $this->renderer->renderRoot($element);
+    $this->assertSame($output, '<p>overridden</p>', 'Output is overridden.');
+    $this->assertTrue(isset($element['#printed']), 'No cache hit');
+    $this->assertSame($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
+    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each #post_render_cache callback exist.');
+
+    // GET request: validate cached data for both the parent and child.
+    $cached_parent_element = $this->memoryCache->get('simpletest:drupal_render:children_post_render_cache:nested_cache_parent')->data;
+    $cached_child_element = $this->memoryCache->get('simpletest:drupal_render:children_post_render_cache:nested_cache_child')->data;
+    $expected_parent_element = [
+      '#attached' => [
+        'drupalSettings' => [
+          'foo' => 'bar',
+        ],
+      ],
+      '#post_render_cache' => [
+        'Drupal\Tests\Core\Render\PostRenderCache::callback' => [
+          $context_1,
+          $context_2,
+          $context_3,
+        ]
+      ],
+      '#cache' => ['tags' => ['rendered']],
+    ];
+
+    $dom = Html::load($cached_parent_element['#markup']);
+    $xpath = new \DOMXPath($dom);
+    $parent = $xpath->query('//details/summary[text()="Parent"]')->length;
+    $child =  $xpath->query('//details/div[@class="details-wrapper"]/details/summary[text()="Child"]')->length;
+    $subchild = $xpath->query('//details/div[@class="details-wrapper"]/details/div [@class="details-wrapper" and text()="Subchild"]')->length;
+    $this->assertTrue($parent && $child && $subchild, 'The correct data is cached for the parent: the stored #markup is not affected by #post_render_cache callbacks.');
+
+    // Remove markup because it's compared above in the xpath.
+    unset($cached_parent_element['#markup']);
+    $this->assertEquals($cached_parent_element, $expected_parent_element, 'The correct data is cached for the parent: the stored #attached properties are not affected by #post_render_cache callbacks.');
+
+    $expected_child_element = [
+      '#attached' => [
+      ],
+      '#post_render_cache' => [
+        'Drupal\Tests\Core\Render\PostRenderCache::callback' => [
+          $context_2,
+          $context_3,
+        ]
+      ],
+      '#cache' => ['tags' => ['rendered']],
+    ];
+
+    $dom = Html::load($cached_child_element['#markup']);
+    $xpath = new \DOMXPath($dom);
+    $child =  $xpath->query('//details/summary[text()="Child"]')->length;
+    $subchild = $xpath->query('//details/div [@class="details-wrapper" and text()="Subchild"]')->length;
+    $this->assertTrue($child && $subchild, 'The correct data is cached for the child: the stored #markup is not affected by #post_render_cache callbacks.');
+
+    // Remove markup because it's compared above in the xpath.
+    unset($cached_child_element['#markup']);
+    $this->assertEquals($cached_child_element, $expected_child_element, 'The correct data is cached for the child: the stored #attached properties are not affected by #post_render_cache callbacks.');
+
+    // GET request: #cache enabled, cache hit, parent element.
+    $element = $test_element;
+    $element['#cache']['keys'] = ['simpletest', 'drupal_render', 'children_post_render_cache', 'nested_cache_parent'];
+    $output = $this->renderer->renderRoot($element);
+    $this->assertSame($output, '<p>overridden</p>', 'Output is 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 #post_render_cache callback exist.');
+
+    // GET request: #cache enabled, cache hit, child element.
+    $element = $test_element;
+    $element['child']['#cache']['keys'] = ['simpletest', 'drupal_render', 'children_post_render_cache', 'nested_cache_child'];
+    $element = $element['child'];
+    $output = $this->renderer->renderRoot($element);
+    $this->assertSame($output, '<p>overridden</p>', 'Output is overridden.');
+    $this->assertFalse(isset($element['#printed']), 'Cache hit');
+    $expected_js_settings = [
+      'common_test' => $context_2 + $context_3,
+    ];
+    $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each #post_render_cache callback exist.');
+  }
+
+  /**
+   * @param $context_1
+   * @param $context_2
+   * @param $context_3
+   * @return array
+   */
+  protected function postRenderCacheWithChildrenTestElement($context_1, $context_2, $context_3) {
+    $test_element = [
+      '#type' => 'details',
+      '#cache' => [
+        'keys' => ['simpletest', 'drupal_render', 'children_post_render_cache'],
+      ],
+      '#post_render_cache' => [
+        __NAMESPACE__ . '\\PostRenderCache::callback' => [$context_1]
+      ],
+      '#title' => 'Parent',
+      '#attached' => [
+        'drupalSettings' => [
+          'foo' => 'bar',
+        ],
+      ],
+    ];
+    $test_element['child'] = [
+      '#type' => 'details',
+      '#post_render_cache' => [
+        __NAMESPACE__ . '\\PostRenderCache::callback' => [$context_2],
+      ],
+      '#title' => 'Child',
+    ];
+    $test_element['child']['subchild'] = [
+      '#post_render_cache' => [
+        __NAMESPACE__ . '\\PostRenderCache::callback' => [$context_3]
+      ],
+      '#markup' => 'Subchild',
+    ];
+    return $test_element;
+  }
+
+  /**
+   * @return \PHPUnit_Framework_MockObject_Builder_InvocationMocker
+   */
+  protected function setupThemeManagerForDetails() {
+    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;
+      });
+  }
+
+}
+
+class TestAccessClass {
+
+  public static function accessTrue() {
+    return TRUE;
+  }
+
+  public static function accessFalse() {
+    return FALSE;
+  }
+
+}
+
+class TestCallables {
+
+  public function preRenderPrinted($elements) {
+    $elements['#printed'] = TRUE;
+    return $elements;
+  }
+
+}
+
+class PostRenderCacheRecursion {
+
+  /**
+   * #post_render_cache callback; bubbles another #post_render_cache callback.
+   *
+   * @param array $element
+   *  A render array with the following keys:
+   *    - #markup
+   *    - #attached
+   * @param array $context
+   *  An array with the following keys:
+   *    - foo: contains a random string.
+   *
+   * @return array $element
+   *   The updated $element.
+   */
+  public static function callback(array $element, array $context) {
+    // Render a child which itself also has a #post_render_cache callback that
+    // must be bubbled.
+    $child = [];
+    $child['#markup'] = 'foo';
+    $child['#post_render_cache']['Drupal\Tests\Core\Render\PostRenderCache::callback'][] = $context;
+
+    // Render the child.
+    $element['#markup'] = \Drupal::service('renderer')->render($child);
+
+    return $element;
+  }
+
+}
+
+class PostRenderCache {
+
+  /**
+   * #post_render_cache callback; modifies #markup, #attached and #context_test.
+   *
+   * @param array $element
+   *  A render array with the following keys:
+   *    - #markup
+   *    - #attached
+   * @param array $context
+   *  An array with the following keys:
+   *    - foo: contains a random string.
+   *
+   * @return array $element
+   *   The updated $element.
+   */
+  public static function callback(array $element, array $context) {
+    // Override #markup.
+    $element['#markup'] = '<p>overridden</p>';
+
+    // Extend #attached.
+    if (!isset($element['#attached']['drupalSettings']['common_test'])) {
+      $element['#attached']['drupalSettings']['common_test'] = [];
+    }
+    $element['#attached']['drupalSettings']['common_test'] += $context;
+
+    // Set new property.
+    $element['#context_test'] = $context;
+
+    return $element;
+  }
+
+}
