 core/core.services.yml                             |   4 +-
 core/lib/Drupal/Core/Render/HtmlResponse.php       |   2 +-
 .../Core/Render/PlaceholderingRenderCache.php      | 241 +++++++++++++
 core/lib/Drupal/Core/Render/RenderCache.php        |  66 +++-
 core/lib/Drupal/Core/Render/Renderer.php           |  17 +-
 .../Tests/Core/Render/RendererBubblingTest.php     | 176 +++++++++-
 .../Tests/Core/Render/RendererPlaceholdersTest.php | 375 +++++++++++++++++++--
 .../Drupal/Tests/Core/Render/RendererTestBase.php  |  58 +++-
 8 files changed, 865 insertions(+), 74 deletions(-)

diff --git a/core/core.services.yml b/core/core.services.yml
index 5ba0116..dfa13d1 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -1485,8 +1485,8 @@ services:
       - { name: mime_type_guesser }
     lazy: true
   render_cache:
-    class: Drupal\Core\Render\RenderCache
-    arguments: ['@request_stack', '@cache_factory', '@cache_contexts_manager']
+    class: Drupal\Core\Render\PlaceholderingRenderCache
+    arguments: ['@request_stack', '@cache_factory', '@cache_contexts_manager', '%renderer.config%']
   renderer:
     class: Drupal\Core\Render\Renderer
     arguments: ['@controller_resolver', '@theme.manager', '@plugin.manager.element_info', '@render_cache', '@request_stack', '%renderer.config%']
diff --git a/core/lib/Drupal/Core/Render/HtmlResponse.php b/core/lib/Drupal/Core/Render/HtmlResponse.php
index c5339d6..7f9ab4d 100644
--- a/core/lib/Drupal/Core/Render/HtmlResponse.php
+++ b/core/lib/Drupal/Core/Render/HtmlResponse.php
@@ -42,6 +42,6 @@ public function setContent($content) {
       $content = $content['#markup'];
     }
 
-    parent::setContent($content);
+    return parent::setContent($content);
   }
 }
diff --git a/core/lib/Drupal/Core/Render/PlaceholderingRenderCache.php b/core/lib/Drupal/Core/Render/PlaceholderingRenderCache.php
new file mode 100644
index 0000000..461e5c1
--- /dev/null
+++ b/core/lib/Drupal/Core/Render/PlaceholderingRenderCache.php
@@ -0,0 +1,241 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Render\PlaceholderingRenderCache.
+ */
+
+namespace Drupal\Core\Render;
+
+use Drupal\Component\Utility\SafeMarkup;
+use Drupal\Component\Utility\UrlHelper;
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheFactoryInterface;
+use Drupal\Core\Cache\Context\CacheContextsManager;
+use Drupal\Core\Template\Attribute;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * Adds automatic placeholdering to the RenderCache.
+ *
+ * This automatic placeholdering is performed to ensure the containing elements
+ * and overarching response are as cacheable as possible. Elements whose subtree
+ * bubble either max-age=0 or high-cardinality cache contexts (such as 'user'
+ * and 'session') are considered poorly cacheable.
+ *
+ * @see sites/default/default.services.yml
+ *
+ * Automatic placeholdering is performed only on elements whose subtree was
+ * generated using a #lazy_builder callback and whose bubbled cacheability meets
+ * the auto-placeholdering conditions as configured in the renderer.config
+ * container parameter.
+ *
+ * This RenderCache implementation automatically replaces an element with a
+ * placeholder:
+ * - on render cache hit, i.e. ::get()
+ * - on render cache miss, i.e. ::set() (in subsequent requests, it will be a
+ *   cache hit)
+ *
+ * In either case, the render cache is guaranteed to contain the to-be-rendered
+ * placeholder, so replacing (rendering) the placeholder will be very fast.
+ *
+ * Finally, in case the render cache item disappears between the time it is
+ * decided to automatically placeholder the element and the time where the
+ * placeholder is replaced (rendered), that is guaranteed to not be problematic.
+ * Because this only automatically placeholders elements that have a
+ * #lazy_builder callback set, which means that in the worst case, it will need
+ * to be re-rendered.
+ */
+class PlaceholderingRenderCache extends RenderCache {
+
+  /**
+   * The renderer configuration array.
+   *
+   * @var array
+   */
+  protected $rendererConfig;
+
+  /**
+   * The expire override.
+   *
+   * @see ::set()
+   * @see ::maxAgeToOverride()
+   *
+   * @var FALSE|int
+   */
+  protected $expireOverride = FALSE;
+
+  /**
+   * Constructs a new PlaceholderingRenderCache object.
+   *
+   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
+   *   The request stack.
+   * @param \Drupal\Core\Cache\CacheFactoryInterface $cache_factory
+   *   The cache factory.
+   * @param \Drupal\Core\Cache\Context\CacheContextsManager $cache_contexts_manager
+   *   The cache contexts manager.
+   * @param array $renderer_config
+   *   The renderer configuration array.
+   */
+  public function __construct(RequestStack $request_stack, CacheFactoryInterface $cache_factory, CacheContextsManager $cache_contexts_manager, array $renderer_config) {
+    parent::__construct($request_stack, $cache_factory, $cache_contexts_manager);
+    $this->rendererConfig = $renderer_config;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function get(array $elements) {
+    $cached_element = parent::get($elements);
+
+    if ($cached_element === FALSE) {
+      return FALSE;
+    }
+    else {
+      if ($this->canCreatePlaceholder($elements) && $this->shouldAutomaticallyPlaceholder($cached_element)) {
+        return $this->createPlaceholder($elements);
+      }
+
+      // PlaceholderingRenderCache::set() has cached a bubbled max-age = 0, and
+      // placeholders are currently being replaced. Don't return the cached
+      // element, because it *only* contains max-age = 0, it doesn't contain the
+      // rendered content (which it cannot and may not, because after all it was
+      // uncacheable, due to that max-age = 0).
+      // When not replacing placeholders, the above will ensure that this
+      // max-age=0 element is placeholdered.
+      if (isset($elements['#create_placeholder']) && $elements['#create_placeholder'] === FALSE && !$this->maxAgeAllowsCaching($cached_element)) {
+        return FALSE;
+      }
+
+      return $cached_element;
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function set(array &$elements, array $pre_bubbling_elements) {
+    $result = parent::set($elements, $pre_bubbling_elements);
+
+    // If the parent's set() call did not result in a cache write, and that was
+    // the case due to a max-age=0 that bubbled for a #lazy_builder's contents,
+    // then cache the fact that it was a bubbled max-age=0. Don't cache markup
+    // or anything else: *only* cache max-age=0.
+    // The accompanying logic in PlaceholderingRenderCache::get() will ensure
+    // the render cached item is never actually returned; it will only be used
+    // by PlaceholderingRenderCache::get() to automatically placeholder this
+    // element whose subtree is uncacheable, and whose contents are generated by
+    // a #lazy_builder callback.
+    // Note: we can permanently cache the fact that this element's subtree
+    // resulted in a bubbled max-age=0, because cache context variations
+    if ($result === FALSE && $this->canCreatePlaceholder($pre_bubbling_elements) && $this->requestStack->getCurrentRequest()->isMethodSafe() && !$this->maxAgeAllowsCaching($elements) && $this->maxAgeAllowsCaching($pre_bubbling_elements) && $cid = $this->createCacheID($elements)) {
+      // Cache #cache['max-age'] = 0! We just want to cache the cacheability.
+      $data = ['#cache' => $elements['#cache'], '#markup' => '', '#attached' => []];
+
+      //
+      $this->expireOverride = Cache::PERMANENT;
+      parent::doSet($data, $pre_bubbling_elements);
+      $this->expireOverride = FALSE;
+    }
+
+    if ($this->canCreatePlaceholder($pre_bubbling_elements) && $this->shouldAutomaticallyPlaceholder($elements)) {
+      // Overwrite $elements with a placeholder. The Renderer (which called this
+      // method) will update the context with the bubbleable metadata of the
+      // overwritten $elements.
+      $elements = $this->createPlaceholder($pre_bubbling_elements);
+    }
+
+    return $result;
+  }
+
+  /**
+   * Whether the given render array should be automatically placeholdered.
+   *
+   * @param array $element
+   *   The render array whose cacheability to analyze.
+   *
+   * @return bool
+   *   Whether the given render array's cacheability meets the placeholdering
+   *   conditions.
+   */
+  protected function shouldAutomaticallyPlaceholder(array $element) {
+    $conditions = $this->rendererConfig['auto_placeholder_conditions'];
+
+    // Auto-placeholder if max-age is at or below the configured threshold.
+    if (isset($element['#cache']['max-age']) && $element['#cache']['max-age'] !== Cache::PERMANENT && $element['#cache']['max-age'] <= $conditions['max-age']) {
+      return TRUE;
+    }
+
+    // Auto-placeholder if a high-cardinality cache context is set.
+    if (isset($element['#cache']['contexts']) && array_intersect($element['#cache']['contexts'], $conditions['contexts'])) {
+      return TRUE;
+    }
+
+    // Auto-placeholder if a high-invalidation frequency cache tag is set.
+    if (isset($element['#cache']['tags']) && array_intersect($element['#cache']['tags'], $conditions['tags'])) {
+      return TRUE;
+    }
+
+    return FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function maxAgeToExpire($max_age) {
+    // This is a quite hacky override, but otherwise this would have to
+    // reimplement all of \Drupal\Core\Render\RenderCache::doSet().
+    if ($this->expireOverride !== FALSE) {
+      return $this->expireOverride;
+    }
+    return parent::maxAgeToExpire($max_age);
+  }
+
+  /**
+   * Analyzes whether the given render array can be placeholdered.
+   *
+   * @param array $element
+   *   A render array. Its #lazy_builder and #create_placeholder properties are
+   *   analyzed.
+   *
+   * @return bool
+   */
+  protected function canCreatePlaceholder(array $element) {
+    return
+      // If generated by a #lazy_builder callback, placeholdering is possible.
+      isset($element['#lazy_builder'])
+      &&
+      // If #create_placeholder === FALSE, placeholdering is disallowed.
+      (!isset($element['#create_placeholder']) || $element['#create_placeholder'] !== FALSE);
+  }
+
+  // @todo refactor
+  protected function createPlaceholder($element) {
+    $placeholder_render_array = array_intersect_key($element, [
+      // Placeholders are replaced with markup by executing the associated
+      // #lazy_builder callback, which generates a render array, and which the
+      // Renderer will render and replace the placeholder with.
+      '#lazy_builder' => TRUE,
+      // The cacheability metadata for the placeholder. The rendered result of
+      // the placeholder may itself be cached, if [#cache][keys] are specified.
+      '#cache' => TRUE,
+    ]);
+
+    // Generate placeholder markup. Note that the only requirement is that this
+    // is unique markup that isn't easily guessable. The #lazy_builder callback
+    // and its arguments are put in the placeholder markup solely to simplify
+    // debugging.
+    $attributes = new Attribute();
+    $attributes['callback'] = $placeholder_render_array['#lazy_builder'][0];
+    $attributes['arguments'] = UrlHelper::buildQuery($placeholder_render_array['#lazy_builder'][1]);
+    $attributes['token'] = hash('sha1', serialize($placeholder_render_array));
+    $placeholder_markup = SafeMarkup::format('<drupal-render-placeholder@attributes></drupal-render-placeholder>', ['@attributes' => $attributes]);
+
+    // Build the placeholder element to return.
+    $placeholder_element = [];
+    $placeholder_element['#markup'] = $placeholder_markup;
+    $placeholder_element['#attached']['placeholders'][$placeholder_markup] = $placeholder_render_array;
+    return $placeholder_element;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Render/RenderCache.php b/core/lib/Drupal/Core/Render/RenderCache.php
index 880fca3..4f35500 100644
--- a/core/lib/Drupal/Core/Render/RenderCache.php
+++ b/core/lib/Drupal/Core/Render/RenderCache.php
@@ -64,10 +64,11 @@ public function get(array $elements) {
     // and render caching of forms prevents this from happening.
     // @todo remove the isMethodSafe() check when
     //       https://www.drupal.org/node/2367555 lands.
-    if (!$this->requestStack->getCurrentRequest()->isMethodSafe() || !$cid = $this->createCacheID($elements)) {
+    if (!$this->requestStack->getCurrentRequest()->isMethodSafe() || !$this->maxAgeAllowsCaching($elements) || !isset($elements['#cache']['keys'])) {
       return FALSE;
     }
     $bin = isset($elements['#cache']['bin']) ? $elements['#cache']['bin'] : 'render';
+    $cid = $this->createCacheID($elements);
 
     if (!empty($cid) && ($cache_bin = $this->cacheFactory->get($bin)) && $cache = $cache_bin->get($cid)) {
       $cached_element = $cache->data;
@@ -91,10 +92,22 @@ public function set(array &$elements, array $pre_bubbling_elements) {
     // and render caching of forms prevents this from happening.
     // @todo remove the isMethodSafe() check when
     //       https://www.drupal.org/node/2367555 lands.
-    if (!$this->requestStack->getCurrentRequest()->isMethodSafe() || !$cid = $this->createCacheID($elements)) {
+    if (!$this->requestStack->getCurrentRequest()->isMethodSafe() || !isset($elements['#cache']['keys']) || !$this->maxAgeAllowsCaching($elements)) {
       return FALSE;
     }
 
+    return $this->doSet($elements, $pre_bubbling_elements);
+  }
+
+  /**
+   * Helper for ::set().
+   *
+   * @param array $elements
+   * @param array $pre_bubbling_elements
+   */
+  protected function doSet(array &$elements, array $pre_bubbling_elements) {
+    $cid = $this->createCacheID($elements);
+
     $data = $this->getCacheableRenderArray($elements);
 
     $bin = isset($elements['#cache']['bin']) ? $elements['#cache']['bin'] : 'render';
@@ -227,6 +240,17 @@ public function set(array &$elements, array $pre_bubbling_elements) {
       //   redirect, because the last update's max-age would always "win".)
       $redirect_cacheability_updated = CacheableMetadata::createFromRenderArray($data)->merge($redirect_cacheability);
 
+      // Create a redirect cache tag, which is used for two purposes:
+      // 1. invalidate cached supertrees (otherwise redirected render cache
+      //    items may continue to not be automatically placeholdered, because a
+      //    high-cardinality cache context was not yet known originally)
+      // 2. invalidate cache items that become obsolete when the cache contexts
+      //    stored in the cache redirect are discovered to be incomplete.
+      $redirect_cache_tag = 'render_cache_redirect:' . hash('sha1', serialize([$pre_bubbling_elements['#cache']['keys'], $pre_bubbling_elements['#cache']['contexts'], $bin]));
+      $redirect_cacheability_updated->addCacheTags([$redirect_cache_tag]);
+      $elements['#cache']['tags'] = Cache::mergeTags($elements['#cache']['tags'], [$redirect_cache_tag]);
+      $data = $this->getCacheableRenderArray($elements);
+
       // Stored cache contexts incomplete: this request causes cache contexts to
       // be added to the redirecting cache item.
       if (array_diff($redirect_cacheability_updated->getCacheContexts(), $redirect_cacheability->getCacheContexts())) {
@@ -246,6 +270,9 @@ public function set(array &$elements, array $pre_bubbling_elements) {
             'bin' => $bin,
           ],
         ];
+        // First invalidate the cache tag for this redirect, then set the new
+        // version of this redirect.
+        Cache::invalidateTags([$redirect_cache_tag]);
         $cache->set($pre_bubbling_cid, $redirect_data, $this->maxAgeToExpire($redirect_cacheability_updated->getCacheMaxAge()), Cache::mergeTags($redirect_data['#cache']['tags'], ['rendered']));
       }
 
@@ -255,16 +282,12 @@ public function set(array &$elements, array $pre_bubbling_elements) {
       // redirecting cache item would be pointing to a cache item that can never
       // exist.
       if (array_diff($redirect_cacheability_updated->getCacheContexts(), $data['#cache']['contexts'])) {
+        // Ensure the about-to-be-cached data uses the merged cache contexts and
+        // the redirect cache tag.
+        $elements['#cache']['contexts'] = $redirect_cacheability_updated->getCacheContexts();
+        $data = $this->getCacheableRenderArray($elements);
         // Recalculate the cache ID.
-        $recalculated_cid_pseudo_element = [
-          '#cache' => [
-            'keys' => $elements['#cache']['keys'],
-            'contexts' => $redirect_cacheability_updated->getCacheContexts(),
-          ]
-        ];
-        $cid = $this->createCacheID($recalculated_cid_pseudo_element);
-        // Ensure the about-to-be-cached data uses the merged cache contexts.
-        $data['#cache']['contexts'] = $redirect_cacheability_updated->getCacheContexts();
+        $cid = $this->createCacheID($elements);
       }
     }
     $cache->set($cid, $data, $this->maxAgeToExpire($elements['#cache']['max-age']), Cache::mergeTags($data['#cache']['tags'], ['rendered']));
@@ -286,6 +309,22 @@ protected function maxAgeToExpire($max_age) {
   }
 
   /**
+   * If the maximum age is zero, then caching is effectively prohibited.
+   *
+   * @param array $elements
+   *   A render array.
+   *
+   * @return bool
+   *   Whether this render array has a non-zero max-age.
+   */
+  protected function maxAgeAllowsCaching(array $elements) {
+    if (isset($elements['#cache']['max-age']) && $elements['#cache']['max-age'] === 0) {
+      return FALSE;
+    }
+    return TRUE;
+  }
+
+  /**
    * Creates the cache ID for a renderable element.
    *
    * Creates the cache ID string based on #cache['keys'] + #cache['contexts'].
@@ -297,11 +336,6 @@ protected function maxAgeToExpire($max_age) {
    *   The cache ID string, or FALSE if the element may not be cached.
    */
   protected function createCacheID(array &$elements) {
-    // If the maximum age is zero, then caching is effectively prohibited.
-    if (isset($elements['#cache']['max-age']) && $elements['#cache']['max-age'] === 0) {
-      return FALSE;
-    }
-
     if (isset($elements['#cache']['keys'])) {
       $cid_parts = $elements['#cache']['keys'];
       if (!empty($elements['#cache']['contexts'])) {
diff --git a/core/lib/Drupal/Core/Render/Renderer.php b/core/lib/Drupal/Core/Render/Renderer.php
index 52a849c..d5ccd25 100644
--- a/core/lib/Drupal/Core/Render/Renderer.php
+++ b/core/lib/Drupal/Core/Render/Renderer.php
@@ -294,12 +294,15 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
         return $elements['#markup'];
       }
     }
-    // Two-tier caching: track pre-bubbling elements' #cache for later
-    // comparison.
+    // Two-tier caching: track pre-bubbling elements' #cache, #lazy_builder and
+    // #create_placeholder for later comparison.
     // @see \Drupal\Core\Render\RenderCacheInterface::get()
     // @see \Drupal\Core\Render\RenderCacheInterface::set()
-    $pre_bubbling_elements = [];
-    $pre_bubbling_elements['#cache'] = isset($elements['#cache']) ? $elements['#cache'] : [];
+    $pre_bubbling_elements = array_intersect_key($elements, [
+      '#cache' => TRUE,
+      '#lazy_builder' => TRUE,
+      '#create_placeholder' => TRUE,
+    ]);
 
     // If the default values for this element have not been loaded yet, populate
     // them.
@@ -530,6 +533,12 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
         throw new \LogicException('Cache keys may not be changed after initial setup. Use the contexts property instead to bubble additional metadata.');
       }
       $this->renderCache->set($elements, $pre_bubbling_elements);
+      // Update the render context; the render cache implementation may update
+      // the element, and it may have different bubbleable metadata now.
+      // @see \Drupal\Core\Render\PlaceholderingRenderCache::set()
+      $context->pop();
+      $context->push(new BubbleableMetadata());
+      $context->update($elements);
     }
 
     // Only when we're in a root (non-recursive) Renderer::render() call,
diff --git a/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php b/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php
index b9bb4e5..2d49252 100644
--- a/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php
+++ b/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php
@@ -10,6 +10,7 @@
 use Drupal\Core\Cache\MemoryBackend;
 use Drupal\Core\KeyValueStore\KeyValueMemoryFactory;
 use Drupal\Core\Render\Element;
+use Drupal\Core\Render\HtmlResponse;
 use Drupal\Core\Render\Renderer;
 use Drupal\Core\State\State;
 use Drupal\Core\Cache\Cache;
@@ -86,6 +87,13 @@ public function testContextBubblingCustomCacheBin() {
     $this->setUpRequest();
     $this->memoryCache = new MemoryBackend('render');
     $custom_cache = new MemoryBackend($bin);
+    $expected_redirect_cache_tag = 'render_cache_redirect:' . hash('sha1', serialize([['parent'], ['foo'], $bin]));
+
+    // The cache redirect will be updated once, hence its cache tag should be
+    // invalidated once as well.
+    $this->cacheTagsInvalidator->expects($this->once())
+      ->method('invalidateTags')
+      ->with([$expected_redirect_cache_tag]);
 
     $this->cacheFactory->expects($this->atLeastOnce())
       ->method('get')
@@ -123,7 +131,7 @@ public function testContextBubblingCustomCacheBin() {
       '#cache' => [
         'keys' => ['parent'],
         'contexts' => ['bar', 'foo'],
-        'tags' => [],
+        'tags' => [$expected_redirect_cache_tag],
         'bin' => $bin,
         'max-age' => 3600,
       ],
@@ -278,7 +286,7 @@ public function providerTestContextBubblingEdgeCases() {
           // The keys + contexts this redirects to.
           'keys' => ['parent'],
           'contexts' => ['bar', 'foo'],
-          'tags' => ['dee', 'fiddle', 'har', 'yar'],
+          'tags' => ['dee', 'fiddle', 'har', 'render_cache_redirect:02130855cf57da2054a67bdbf19a782b9d6f655f', 'yar'],
           'bin' => 'render',
           'max-age' => Cache::PERMANENT,
         ],
@@ -287,7 +295,7 @@ public function providerTestContextBubblingEdgeCases() {
         '#attached' => [],
         '#cache' => [
           'contexts' => ['bar', 'foo'],
-          'tags' => ['dee', 'fiddle', 'har', 'yar'],
+          'tags' => ['dee', 'fiddle', 'har', 'render_cache_redirect:02130855cf57da2054a67bdbf19a782b9d6f655f', 'yar'],
           'max-age' => Cache::PERMANENT,
         ],
         '#markup' => 'parent',
@@ -300,6 +308,9 @@ public function providerTestContextBubblingEdgeCases() {
 
   /**
    * Tests the self-healing of the redirect with conditional cache contexts.
+   *
+   * The cached response portions of this test prove why cache redirects need
+   * cache tags.
    */
   public function testConditionalCacheContextBubblingSelfHealing() {
     $current_user_role = &$this->currentUserRole;
@@ -307,6 +318,18 @@ public function testConditionalCacheContextBubblingSelfHealing() {
     $this->setUpRequest();
     $this->setupMemoryCache();
 
+    // A mocked response cache, to ensure
+    $response_cache = new MemoryBackend('response');
+
+    // The cache redirect will be updated three times, and hence its cache tag
+    // will also be invalidated three times.
+    $this->cacheTagsInvalidator->expects($this->exactly(3))
+      ->method('invalidateTags')
+      ->with(['render_cache_redirect:46bb17a6d36ce310c5b8c747c3d84a44f29a703f'])
+      ->willReturnCallback(function () use ($response_cache) {
+        $response_cache->invalidateTags(['render_cache_redirect:46bb17a6d36ce310c5b8c747c3d84a44f29a703f']);
+      });
+
     $test_element = [
       '#cache' => [
         'keys' => ['parent'],
@@ -345,17 +368,34 @@ public function testConditionalCacheContextBubblingSelfHealing() {
       ],
     ];
 
+    $store_in_response_cache = function ($element) use (&$current_user_role, &$response_cache) {
+      if ($current_user_role === 'A') {
+        /** @var \Drupal\Core\Render\HtmlResponse $response */
+        $response = (new HtmlResponse())->setContent($element);
+        $response_cache->set('the_cached_response_for_role_A', $response, $response->getCacheableMetadata()->getCacheMaxAge(), $response->getCacheableMetadata()->getCacheTags());
+      }
+    };
+
+    $assert_cached_response = function (array $bubbled_cache_contexts_from_redirect) use (&$response_cache) {
+      $cached = $response_cache->get('the_cached_response_for_role_A');
+      $this->assertNotFalse($cached);
+      $cached_response = $cached->data;
+      $this->assertEquals($cached_response->getContent(), 'parent');
+      $this->assertEquals($cached_response->getCacheableMetadata()->getCacheContexts(), Cache::mergeContexts(['user.roles'], $bubbled_cache_contexts_from_redirect));
+    };
+
     // Request 1: role A, the grandchild isn't accessible => bubbled cache
     // contexts: user.roles.
     $element = $test_element;
     $current_user_role = 'A';
     $this->renderer->renderRoot($element);
+    $store_in_response_cache($element);
     $this->assertRenderCacheItem('parent', [
       '#cache_redirect' => TRUE,
       '#cache' => [
         'keys' => ['parent'],
         'contexts' => ['user.roles'],
-        'tags' => ['a', 'b'],
+        'tags' => ['a', 'b', 'render_cache_redirect:46bb17a6d36ce310c5b8c747c3d84a44f29a703f'],
         'bin' => 'render',
         'max-age' => Cache::PERMANENT,
       ],
@@ -364,23 +404,25 @@ public function testConditionalCacheContextBubblingSelfHealing() {
       '#attached' => [],
       '#cache' => [
         'contexts' => ['user.roles'],
-        'tags' => ['a', 'b'],
+        'tags' => ['a', 'b', 'render_cache_redirect:46bb17a6d36ce310c5b8c747c3d84a44f29a703f'],
         'max-age' => Cache::PERMANENT,
       ],
       '#markup' => 'parent',
     ]);
+    $assert_cached_response([]);
 
     // Request 2: role B, the grandchild is accessible => bubbled cache
     // contexts: foo, user.roles + merged max-age: 1800.
     $element = $test_element;
     $current_user_role = 'B';
     $this->renderer->renderRoot($element);
+    $store_in_response_cache($element);
     $this->assertRenderCacheItem('parent', [
       '#cache_redirect' => TRUE,
       '#cache' => [
         'keys' => ['parent'],
         'contexts' => ['foo', 'user.roles'],
-        'tags' => ['a', 'b', 'c'],
+        'tags' => ['a', 'b', 'c', 'render_cache_redirect:46bb17a6d36ce310c5b8c747c3d84a44f29a703f'],
         'bin' => 'render',
         'max-age' => 1800,
       ],
@@ -389,11 +431,15 @@ public function testConditionalCacheContextBubblingSelfHealing() {
       '#attached' => [],
       '#cache' => [
         'contexts' => ['foo', 'user.roles'],
-        'tags' => ['a', 'b', 'c'],
+        'tags' => ['a', 'b', 'c', 'render_cache_redirect:46bb17a6d36ce310c5b8c747c3d84a44f29a703f'],
         'max-age' => 1800,
       ],
       '#markup' => 'parent',
     ]);
+    // Despite this not being for role A, the cached response is still
+    // invalidated, because the set of cache contexts for the cache redirect has
+    // changed.
+    $this->assertFalse($response_cache->get('the_cached_response_for_role_A'));
 
     // Request 3: role A again, the grandchild is inaccessible again => bubbled
     // cache contexts: user.roles; but that's a subset of the already-bubbled
@@ -408,12 +454,13 @@ public function testConditionalCacheContextBubblingSelfHealing() {
     $element = $test_element;
     $current_user_role = 'A';
     $this->renderer->renderRoot($element);
+    $store_in_response_cache($element);
     $this->assertRenderCacheItem('parent', [
       '#cache_redirect' => TRUE,
       '#cache' => [
         'keys' => ['parent'],
         'contexts' => ['foo', 'user.roles'],
-        'tags' => ['a', 'b', 'c'],
+        'tags' => ['a', 'b', 'c', 'render_cache_redirect:46bb17a6d36ce310c5b8c747c3d84a44f29a703f'],
         'bin' => 'render',
         'max-age' => 1800,
       ],
@@ -422,7 +469,7 @@ public function testConditionalCacheContextBubblingSelfHealing() {
       '#attached' => [],
       '#cache' => [
         'contexts' => ['foo', 'user.roles'],
-        'tags' => ['a', 'b'],
+        'tags' => ['a', 'b', 'render_cache_redirect:46bb17a6d36ce310c5b8c747c3d84a44f29a703f'],
         // Note that the max-age here is unaffected. When role A, the grandchild
         // is never rendered, so neither is its max-age of 1800 present here,
         // despite 1800 being the max-age of the redirecting cache item.
@@ -430,6 +477,7 @@ public function testConditionalCacheContextBubblingSelfHealing() {
       ],
       '#markup' => 'parent',
     ]);
+    $assert_cached_response(['foo']);
 
     // Request 4: role C, both the grandchild and the grandgrandchild are
     // accessible => bubbled cache contexts: foo, bar, user.roles + merged
@@ -437,12 +485,13 @@ public function testConditionalCacheContextBubblingSelfHealing() {
     $element = $test_element;
     $current_user_role = 'C';
     $this->renderer->renderRoot($element);
+    $store_in_response_cache($element);
     $final_parent_cache_item = [
       '#cache_redirect' => TRUE,
       '#cache' => [
         'keys' => ['parent'],
         'contexts' => ['bar', 'foo', 'user.roles'],
-        'tags' => ['a', 'b', 'c', 'd'],
+        'tags' => ['a', 'b', 'c', 'd', 'render_cache_redirect:46bb17a6d36ce310c5b8c747c3d84a44f29a703f'],
         'bin' => 'render',
         'max-age' => 300,
       ],
@@ -452,22 +501,27 @@ public function testConditionalCacheContextBubblingSelfHealing() {
       '#attached' => [],
       '#cache' => [
         'contexts' => ['bar', 'foo', 'user.roles'],
-        'tags' => ['a', 'b', 'c', 'd'],
+        'tags' => ['a', 'b', 'c', 'd', 'render_cache_redirect:46bb17a6d36ce310c5b8c747c3d84a44f29a703f'],
         'max-age' => 300,
       ],
       '#markup' => 'parent',
     ]);
+    // Despite this not being for role A, the cached response is still
+    // invalidated, because the set of cache contexts for the cache redirect has
+    // changed.
+    $this->assertFalse($response_cache->get('the_cached_response_for_role_A'));
 
     // Request 5: role A again, verifying the merging like we did for request 3.
     $element = $test_element;
     $current_user_role = 'A';
     $this->renderer->renderRoot($element);
+    $store_in_response_cache($element);
     $this->assertRenderCacheItem('parent', $final_parent_cache_item);
     $this->assertRenderCacheItem('parent:bar:foo:r.A', [
       '#attached' => [],
       '#cache' => [
         'contexts' => ['bar', 'foo', 'user.roles'],
-        'tags' => ['a', 'b'],
+        'tags' => ['a', 'b', 'render_cache_redirect:46bb17a6d36ce310c5b8c747c3d84a44f29a703f'],
         // Note that the max-age here is unaffected. When role A, the grandchild
         // is never rendered, so neither is its max-age of 1800 present here,
         // nor the grandgrandchild's max-age of 300, despite 300 being the
@@ -476,17 +530,19 @@ public function testConditionalCacheContextBubblingSelfHealing() {
       ],
       '#markup' => 'parent',
     ]);
+    $assert_cached_response(['bar', 'foo']);
 
     // Request 6: role B again, verifying the merging like we did for request 3.
     $element = $test_element;
     $current_user_role = 'B';
     $this->renderer->renderRoot($element);
+    $store_in_response_cache($element);
     $this->assertRenderCacheItem('parent', $final_parent_cache_item);
     $this->assertRenderCacheItem('parent:bar:foo:r.B', [
       '#attached' => [],
       '#cache' => [
         'contexts' => ['bar', 'foo', 'user.roles'],
-        'tags' => ['a', 'b', 'c'],
+        'tags' => ['a', 'b', 'c', 'render_cache_redirect:46bb17a6d36ce310c5b8c747c3d84a44f29a703f'],
         // Note that the max-age here is unaffected. When role B, the
         // grandgrandchild is never rendered, so neither is its max-age of 300
         // present here, despite 300 being the max-age of the redirecting cache
@@ -498,6 +554,100 @@ public function testConditionalCacheContextBubblingSelfHealing() {
   }
 
   /**
+   * Tests the cache tag for a cache redirect.
+   *
+   * This cache tag must be present on:
+   * - the redirecting cache item (source)
+   * - the redirected cache item (destination)
+   * - any render array whose subtree contains the redirect
+   *
+   * This is a unit test, for an integration test, see
+   * testConditionalCacheContextBubblingSelfHealing().
+   */
+  public function testCacheRedirectCacheTag() {
+    $bin = $this->randomMachineName();
+
+    $this->setUpRequest();
+    $custom_cache = new MemoryBackend($bin);
+    $this->cacheFactory->expects($this->atLeastOnce())
+      ->method('get')
+      ->with($bin)
+      ->willReturnCallback(function($requested_bin) use ($bin, $custom_cache) {
+        if ($requested_bin === $bin) {
+          return $custom_cache;
+        }
+        else {
+          throw new \Exception();
+        }
+      });
+
+    $this->elementInfo->expects($this->any())
+      ->method('getInfo')
+      ->willReturn([]);
+
+    // Create a test render tree with a child and subchild. The subchild
+    // specifies an additional cache context that bubbles and causes a cache
+    // redirect.
+    $test_build = [
+      '#type' => 'container',
+      '#cache' => [
+        'keys' => ['parent'],
+        'contexts' => ['a'],
+        'bin' => $bin,
+      ],
+      '#title' => 'Parent',
+    ];
+    $test_build['child'] = [
+      '#type' => 'container',
+      '#title' => 'Child',
+    ];
+    $test_build['child']['subchild'] = [
+      '#cache' => [
+        'contexts' => ['b'],
+      ],
+      '#markup' => 'Varies by B.',
+    ];
+    $expected_cache_redirect_cache_tag = 'render_cache_redirect:' . hash('sha1', serialize([$test_build['#cache']['keys'], $test_build['#cache']['contexts'], $test_build['#cache']['bin']]));
+
+    // Render the tree.
+    $build = $test_build;
+    $output = $this->renderer->renderRoot($build);
+    $this->assertEquals('Varies by B.', $output);
+    // First, assert that the expected cache redirect exists, and it must
+    // include the cache redirect cache tag.
+    $this->assertRenderCacheItem('parent:a', [
+      '#cache_redirect' => TRUE,
+      '#cache' => [
+        'keys' => ['parent'],
+        'contexts' => ['a', 'b'],
+        'tags' => [$expected_cache_redirect_cache_tag],
+        'bin' => $bin,
+        'max-age' => Cache::PERMANENT,
+      ],
+    ], $bin);
+    // Second, assert that the expected redirect destination exists, and it must
+    // include the cache redirect cache tag.
+    $this->assertRenderCacheItem('parent:a:b', [
+      '#cache' => [
+        'contexts' => ['a', 'b'],
+        'tags' => [$expected_cache_redirect_cache_tag],
+        'max-age' => Cache::PERMANENT,
+      ],
+      '#markup' => 'Varies by B.',
+      '#attached' => [],
+    ], $bin);
+    // Third, assert that $build is rendered as expected, and it must include
+    // the cache redirect cache tag.
+    $this->assertSame([
+      'keys' => ['parent'],
+      'contexts' => ['a', 'b'],
+      'bin' => $bin,
+      'tags' => [$expected_cache_redirect_cache_tag],
+      'max-age' => Cache::PERMANENT,
+    ], $build['#cache']);
+  }
+
+  /**
    * Tests bubbling of bubbleable metadata added by #pre_render callbacks.
    *
    * @dataProvider providerTestBubblingWithPrerender
diff --git a/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php b/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php
index 4653000..b1fcc4c 100644
--- a/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php
+++ b/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php
@@ -50,11 +50,8 @@ protected function setUp() {
    *        tags in its subtree, i.e. via bubbling)
    * - B) manually generated placeholder
    *
-   * So, in total 2*5 = 10 permutations.
-   *
-   * @todo Cases A5, A6 and A7 are not yet supported by core. So that makes for
-   *   only 10 permutations currently, instead of 16. That will be done in
-   *   https://www.drupal.org/node/2543334
+   * So, in total 2*8 = 16 permutations. (On one axis: uncacheable vs.
+   * uncacheable = 2; on the other axis: A1–7 and B = 8.)
    *
    * @return array
    */
@@ -142,6 +139,54 @@ public function providerPlaceholders() {
         '#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args],
       ],
     ];
+    // Note the absence of '#create_placeholder', presence of max-age=0 created
+    // by the #lazy_builder callback.
+    // @see \Drupal\Tests\Core\Render\PlaceholdersTest::callbackMaxAgeZero()
+    $base_element_a5 = [
+      '#attached' => [
+        'drupalSettings' => [
+          'foo' => 'bar',
+        ],
+      ],
+      'placeholder' => [
+        '#cache' => [
+          'contexts' => [],
+        ],
+        '#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callbackMaxAgeZero', $args],
+      ],
+    ];
+    // Note the absence of '#create_placeholder', presence of high cardinality
+    // cache context created by the #lazy_builder callback.
+    // @see \Drupal\Tests\Core\Render\PlaceholdersTest::callbackPerUser()
+    $base_element_a6 = [
+      '#attached' => [
+        'drupalSettings' => [
+          'foo' => 'bar',
+        ],
+      ],
+      'placeholder' => [
+        '#cache' => [
+          'contexts' => [],
+        ],
+        '#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callbackPerUser', $args],
+      ],
+    ];
+    // Note the absence of '#create_placeholder', presence of high-invalidation
+    // frequency cache tag created by the #lazy_builder callback.
+    // @see \Drupal\Tests\Core\Render\PlaceholdersTest::callbackTagCurrentTemperature()
+    $base_element_a7 = [
+      '#attached' => [
+        'drupalSettings' => [
+          'foo' => 'bar',
+        ],
+      ],
+      'placeholder' => [
+        '#cache' => [
+          'contexts' => [],
+        ],
+        '#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callbackTagCurrentTemperature', $args],
+      ],
+    ];
     // Note the absence of '#create_placeholder', but the presence of
     // '#attached[placeholders]'.
     $base_element_b = [
@@ -173,6 +218,8 @@ public function providerPlaceholders() {
       $expected_placeholder_render_array,
       FALSE,
       [],
+      [],
+      [],
     ];
 
     // Case two: render array that has a placeholder that is:
@@ -186,6 +233,8 @@ public function providerPlaceholders() {
       $args,
       $expected_placeholder_render_array,
       $keys,
+      [],
+      [],
       [
         '#markup' => '<p>This is a rendered placeholder!</p>',
         '#attached' => [
@@ -212,6 +261,8 @@ public function providerPlaceholders() {
       $expected_placeholder_render_array,
       FALSE,
       [],
+      [],
+      [],
     ];
 
     // Case four: render array that has a placeholder that is:
@@ -225,7 +276,9 @@ public function providerPlaceholders() {
       $args,
       $expected_placeholder_render_array,
       FALSE,
-      []
+      [],
+      [],
+      [],
     ];
 
     // Case five: render array that has a placeholder that is:
@@ -240,6 +293,8 @@ public function providerPlaceholders() {
       $expected_placeholder_render_array,
       FALSE,
       [],
+      [],
+      [],
     ];
 
     // Case six: render array that has a placeholder that is:
@@ -258,6 +313,8 @@ public function providerPlaceholders() {
       $args,
       $expected_placeholder_render_array,
       $cid_parts,
+      [],
+      [],
       [
         '#markup' => '<p>This is a rendered placeholder!</p>',
         '#attached' => [
@@ -285,6 +342,8 @@ public function providerPlaceholders() {
       $expected_placeholder_render_array,
       FALSE,
       [],
+      [],
+      [],
     ];
 
     // Case eight: render array that has a placeholder that is:
@@ -299,6 +358,139 @@ public function providerPlaceholders() {
       $args,
       $expected_placeholder_render_array,
       $keys,
+      [],
+      [],
+      [
+        '#markup' => '<p>This is a rendered placeholder!</p>',
+        '#attached' => [
+          'drupalSettings' => [
+            'dynamic_animal' => $args[0],
+          ],
+        ],
+        '#cache' => [
+          'contexts' => [],
+          'tags' => ['current-temperature'],
+          'max-age' => Cache::PERMANENT,
+        ],
+      ],
+    ];
+
+    // Case nine: render array that DOES NOT have a placeholder that is:
+    // - NOT created, despite max-age=0 that is bubbled
+    // - uncacheable
+    // (because the render element with #lazy_builder does not have #cache[keys]
+    // and hence the max-age=0 bubbles up further)
+    $element_without_cache_keys = $base_element_a5;
+    $expected_placeholder_render_array = $extract_placeholder_render_array($base_element_a5['placeholder']);
+    $cases[] = [
+      $element_without_cache_keys,
+      $args,
+      $expected_placeholder_render_array,
+      FALSE,
+      [],
+      [],
+      [],
+    ];
+
+    // Case ten: render array that has a placeholder that is:
+    // - automatically created, and automatically triggered due to max-age=0
+    //   that is bubbled
+    // - cacheable
+    $element_with_cache_keys = $base_element_a5;
+    $element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
+    $expected_placeholder_render_array['#cache']['keys'] = $keys;
+    $cases[] = [
+      $element_with_cache_keys,
+      $args,
+      $expected_placeholder_render_array,
+      $keys,
+      [],
+      [],
+      [
+        '#markup' => '',
+        '#attached' => [],
+        '#cache' => [
+          'contexts' => [],
+          'tags' => [],
+          'max-age' => 0,
+        ],
+      ],
+    ];
+
+    // Case eleven: render array that DOES NOT have a placeholder that is:
+    // - NOT created, despite high cardinality cache contexts that are bubbled
+    // - uncacheable
+    $element_without_cache_keys = $base_element_a6;
+    $expected_placeholder_render_array = $extract_placeholder_render_array($base_element_a6['placeholder']);
+    $cases[] = [
+      $element_without_cache_keys,
+      $args,
+      $expected_placeholder_render_array,
+      FALSE,
+      ['user'],
+      [],
+      [],
+    ];
+
+    // Case twelve: render array that has a placeholder that is:
+    // - automatically created, and automatically triggered due to high
+    //   cardinality cache contexts that are bubbled
+    // - cacheable
+    $element_with_cache_keys = $base_element_a6;
+    $element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
+    $expected_placeholder_render_array['#cache']['keys'] = $keys;
+    $cases[] = [
+      $element_with_cache_keys,
+      $args,
+      $expected_placeholder_render_array,
+      $keys,
+      ['user'],
+      [],
+      [
+        '#markup' => '<p>This is a rendered placeholder!</p>',
+        '#attached' => [
+          'drupalSettings' => [
+            'dynamic_animal' => $args[0],
+          ],
+        ],
+        '#cache' => [
+          'contexts' => ['user'],
+          'tags' => ['render_cache_redirect:ce7e10fb433d1b051ff2e85e6210d6402701e5c0'],
+          'max-age' => Cache::PERMANENT,
+        ],
+      ],
+    ];
+
+    // Case thirteen: render array that has a placeholder that is:
+    // - automatically created, and automatically triggered due to high
+    //   invalidation frequency cache tags that are bubbled
+    // - uncacheable
+    $element_without_cache_keys = $base_element_a7;
+    $expected_placeholder_render_array = $extract_placeholder_render_array($base_element_a7['placeholder']);
+    $cases[] = [
+      $element_without_cache_keys,
+      $args,
+      $expected_placeholder_render_array,
+      FALSE,
+      [],
+      ['current-temperature'],
+      [],
+    ];
+
+    // Case fourteen: render array that has a placeholder that is:
+    // - automatically created, and automatically triggered due to high
+    //   invalidation frequency cache tags that are bubbled
+    // - cacheable
+    $element_with_cache_keys = $base_element_a7;
+    $element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
+    $expected_placeholder_render_array['#cache']['keys'] = $keys;
+    $cases[] = [
+      $element_with_cache_keys,
+      $args,
+      $expected_placeholder_render_array,
+      $keys,
+      [],
+      [],
       [
         '#markup' => '<p>This is a rendered placeholder!</p>',
         '#attached' => [
@@ -314,7 +506,7 @@ public function providerPlaceholders() {
       ],
     ];
 
-    // Case nine: render array that has a placeholder that is:
+    // Case fifteen: render array that has a placeholder that is:
     // - manually created
     // - uncacheable
     $x = $base_element_b;
@@ -326,9 +518,11 @@ public function providerPlaceholders() {
       $expected_placeholder_render_array,
       FALSE,
       [],
+      [],
+      [],
     ];
 
-    // Case ten: render array that has a placeholder that is:
+    // Case sixteen: render array that has a placeholder that is:
     // - manually created
     // - cacheable
     $x = $base_element_b;
@@ -345,6 +539,8 @@ public function providerPlaceholders() {
       $args,
       $expected_placeholder_render_array,
       $keys,
+      [],
+      [],
       [
         '#markup' => '<p>This is a rendered placeholder!</p>',
         '#attached' => [
@@ -388,12 +584,32 @@ protected function generatePlaceholderElement() {
    * @param array $expected_data
    *   FALSE if no render cache item is expected, a render array with the
    *   expected values if a render cache item is expected.
+   * @param string[] $bubbled_cache_contexts
+   *   Additional cache contexts that were bubbled when the placeholder was
+   *   rendered.
    */
-  protected function assertPlaceholderRenderCache($cid_parts, array $expected_data) {
+  protected function assertPlaceholderRenderCache($cid_parts, array $bubbled_cache_contexts, array $expected_data) {
     if ($cid_parts !== FALSE) {
+      if ($bubbled_cache_contexts) {
+        // Verify render cached placeholder.
+        $cached_element = $this->memoryCache->get(implode(':', $cid_parts))->data;
+        $expected_redirect_element = [
+          '#cache_redirect' => TRUE,
+          '#cache' => $expected_data['#cache'] + [
+            'keys' => $cid_parts,
+            'bin' => 'render',
+          ],
+        ];
+        $this->assertEquals($expected_redirect_element, $cached_element, 'The correct cache redirect exists.');
+      }
+
       // Verify render cached placeholder.
-      $cached_element = $this->memoryCache->get(implode(':', $cid_parts))->data;
+      $cached = $this->memoryCache->get(implode(':', array_merge($cid_parts, $bubbled_cache_contexts)));
+      $cached_element = $cached->data;
       $this->assertEquals($expected_data, $cached_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by the placeholder being replaced.');
+      if ($expected_data['#cache']['max-age'] === 0) {
+        $this->assertEquals(-1, $cached->expire, 'When a bubbled max-age=0 is cached, it is cached permanently for the given set of ');
+      }
     }
   }
   /**
@@ -402,7 +618,7 @@ protected function assertPlaceholderRenderCache($cid_parts, array $expected_data
    *
    * @dataProvider providerPlaceholders
    */
-  public function testUncacheableParent($element, $args, array $expected_placeholder_render_array, $placeholder_cid_parts, array $placeholder_expected_render_cache_array) {
+  public function testUncacheableParent($element, $args, array $expected_placeholder_render_array, $placeholder_cid_parts, array $bubbled_cache_contexts, array $bubbled_cache_tags, array $placeholder_expected_render_cache_array) {
     if ($placeholder_cid_parts) {
       $this->setupMemoryCache();
     }
@@ -422,7 +638,7 @@ public function testUncacheableParent($element, $args, array $expected_placehold
       'dynamic_animal' => $args[0],
     ];
     $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.');
-    $this->assertPlaceholderRenderCache($placeholder_cid_parts, $placeholder_expected_render_cache_array);
+    $this->assertPlaceholderRenderCache($placeholder_cid_parts, $bubbled_cache_contexts, $placeholder_expected_render_cache_array);
   }
 
   /**
@@ -434,14 +650,15 @@ public function testUncacheableParent($element, $args, array $expected_placehold
    *
    * @dataProvider providerPlaceholders
    */
-  public function testCacheableParent($test_element, $args, array $expected_placeholder_render_array, $placeholder_cid_parts, array $placeholder_expected_render_cache_array) {
+  public function testCacheableParent($test_element, $args, array $expected_placeholder_render_array, $placeholder_cid_parts, array $bubbled_cache_contexts, array $bubbled_cache_tags, array $placeholder_expected_render_cache_array) {
     $element = $test_element;
     $this->setupMemoryCache();
 
     $this->setUpRequest('GET');
 
     $token = hash('sha1', serialize($expected_placeholder_render_array));
-    $expected_placeholder_markup = '<drupal-render-placeholder callback="Drupal\Tests\Core\Render\PlaceholdersTest::callback" arguments="0=' . $args[0] . '" token="' . $token . '"></drupal-render-placeholder>';
+    $placeholder_callback = $expected_placeholder_render_array['#lazy_builder'][0];
+    $expected_placeholder_markup = '<drupal-render-placeholder callback="' . $placeholder_callback . '" arguments="0=' . $args[0] . '" token="' . $token . '"></drupal-render-placeholder>';
     $this->assertSame($expected_placeholder_markup, Html::normalize($expected_placeholder_markup), 'Placeholder unaltered by Html::normalize() which is used by FilterHtmlCorrector.');
 
     // GET request: #cache enabled, cache miss.
@@ -456,30 +673,112 @@ public function testCacheableParent($test_element, $args, array $expected_placeh
       'dynamic_animal' => $args[0],
     ];
     $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.');
-    $this->assertPlaceholderRenderCache($placeholder_cid_parts, $placeholder_expected_render_cache_array);
+    $this->assertPlaceholderRenderCache($placeholder_cid_parts, $bubbled_cache_contexts, $placeholder_expected_render_cache_array);
 
     // GET request: validate cached data.
-    $cached_element = $this->memoryCache->get('placeholder_test_GET')->data;
-    $expected_element = [
-      '#markup' => '<p>#cache enabled, GET</p>' . $expected_placeholder_markup,
-      '#attached' => [
-        'drupalSettings' => [
-          'foo' => 'bar',
+    $cached = $this->memoryCache->get('placeholder_test_GET');
+    // There are three edge cases, where the shape of the render cache item for
+    // the parent (with CID 'placeholder_test_GET') is vastly different. These
+    // are the cases where:
+    // - the placeholder is uncacheable (because it has no #cache[keys]), and;
+    // - cacheability metadata that meets auto_placeholder_conditions is bubbled
+    $has_uncacheable_lazy_builder = !isset($test_element['placeholder']['#cache']['keys']) && isset($test_element['placeholder']['#lazy_builder']);
+    // Edge cases: always where both bubbling of an auto-placeholdering
+    // condition happens from within a #lazy_builder that is uncacheable.
+    // - uncacheable + A5 (cache max-age)
+    $edge_case_a5_uncacheable = $has_uncacheable_lazy_builder && $test_element['placeholder']['#lazy_builder'][0] === 'Drupal\Tests\Core\Render\PlaceholdersTest::callbackMaxAgeZero';
+    // - uncacheable + A6 (cache context)
+    $edge_case_a6_uncacheable = $has_uncacheable_lazy_builder && $test_element['placeholder']['#lazy_builder'][0] === 'Drupal\Tests\Core\Render\PlaceholdersTest::callbackPerUser';
+    // - uncacheable + A7 (cache tag)
+    $edge_case_a7_uncacheable = $has_uncacheable_lazy_builder && $test_element['placeholder']['#lazy_builder'][0] === 'Drupal\Tests\Core\Render\PlaceholdersTest::callbackTagCurrentTemperature';
+    // The completely uncacheable edge case: max-age=0 is bubbled from a
+    // #lazy_builder callback for an uncacheable placeholder. The element
+    // containing the uncacheable placeholder has cache keys set, but also does
+    // not cache it due to max-age=0.
+    if ($edge_case_a5_uncacheable) {
+      $this->assertFalse($cached, 'The parent is not render cached in case a max-age=0 is bubbled from an uncacheable child (no #cache[keys]) with a #lazy_builder.');
+    }
+    // The redirect-cacheable edge case: a high-cardinality cache context is
+    // bubbled from a #lazy_builder callback for an uncacheable placeholder. The
+    // element containing the uncacheable placeholder has cache keys set, and
+    // due to the bubbled cache contexts it creates a cache redirect.
+    elseif ($edge_case_a6_uncacheable) {
+      $cached_element = $cached->data;
+      $expected_redirect = [
+        '#cache_redirect' => TRUE,
+        '#cache' => [
+          'keys' => ['placeholder_test_GET'],
+          'contexts' => ['user'],
+          'tags' => ['render_cache_redirect:da9373df59ce294094c3ef12ffb5294c982e5b69'],
+          'max-age' => Cache::PERMANENT,
+          'bin' => 'render',
         ],
-        'placeholders' => [
-          $expected_placeholder_markup => [
-            '#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args],
+      ];
+      $this->assertEquals($expected_redirect, $cached_element);
+      // Follow the redirect.
+      $cached_element = $this->memoryCache->get('placeholder_test_GET:' . implode(':', $bubbled_cache_contexts))->data;
+      $expected_element = [
+        '#markup' => '<p>#cache enabled, GET</p><p>This is a rendered placeholder!</p>',
+        '#attached' => [
+          'drupalSettings' => [
+            'foo' => 'bar',
+            'dynamic_animal' => $args[0],
           ],
         ],
-      ],
-      '#cache' => [
-        'contexts' => [],
-        'tags' => [],
-        'max-age' => Cache::PERMANENT,
-      ],
-    ];
-    $expected_element['#attached']['placeholders'][$expected_placeholder_markup] = $expected_placeholder_render_array;
-    $this->assertEquals($cached_element, $expected_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by placeholder #lazy_builder callbacks.');
+        '#cache' => [
+          'contexts' => $bubbled_cache_contexts,
+          'tags' => ['render_cache_redirect:da9373df59ce294094c3ef12ffb5294c982e5b69'],
+          'max-age' => Cache::PERMANENT,
+        ],
+      ];
+      $this->assertEquals($expected_element, $cached_element, 'The parent is render cached with a redirect in ase a cache context is bubbled from an uncacheable child (no #cache[keys]) with a #lazy_builder.');
+    }
+    // The normally cacheable edge case: a high-invalidation frequency cache tag
+    // is bubbled from a #lazy_builder callback for an uncacheable placeholder.
+    // The element continaing the uncacheable placeholder has cache keys set,
+    // and also has the bubbled cache tags.
+    elseif ($edge_case_a7_uncacheable) {
+      $cached_element = $cached->data;
+      $expected_element = [
+        '#markup' => '<p>#cache enabled, GET</p><p>This is a rendered placeholder!</p>',
+        '#attached' => [
+          'drupalSettings' => [
+            'foo' => 'bar',
+            'dynamic_animal' => $args[0],
+          ],
+        ],
+        '#cache' => [
+          'contexts' => [],
+          'tags' => $bubbled_cache_tags,
+          'max-age' => Cache::PERMANENT,
+        ],
+      ];
+      $this->assertEquals($expected_element, $cached_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by placeholder #lazy_builder callbacks.');
+    }
+    // The regular case.
+    else {
+      $cached_element = $cached->data;
+      $expected_element = [
+        '#markup' => '<p>#cache enabled, GET</p>' . $expected_placeholder_markup,
+        '#attached' => [
+          'drupalSettings' => [
+            'foo' => 'bar',
+          ],
+          'placeholders' => [
+            $expected_placeholder_markup => [
+              '#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args],
+            ],
+          ],
+        ],
+        '#cache' => [
+          'contexts' => [],
+          'tags' => $bubbled_cache_tags,
+          'max-age' => Cache::PERMANENT,
+        ],
+      ];
+      $expected_element['#attached']['placeholders'][$expected_placeholder_markup] = $expected_placeholder_render_array;
+      $this->assertEquals($expected_element, $cached_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by placeholder #lazy_builder callbacks.');
+    }
 
     // GET request: #cache enabled, cache hit.
     $element = $test_element;
@@ -487,7 +786,13 @@ public function testCacheableParent($test_element, $args, array $expected_placeh
     $element['#prefix'] = '<p>#cache enabled, GET</p>';
     $output = $this->renderer->renderRoot($element);
     $this->assertSame('<p>#cache enabled, GET</p><p>This is a rendered placeholder!</p>', (string) $output, 'Output is overridden.');
-    $this->assertFalse(isset($element['#printed']), 'Cache hit');
+    if ($edge_case_a5_uncacheable ) {
+      $this->assertTrue(isset($element['#printed']), 'Cache miss, because this bubbles max-age=0 plus an uncached lazy builder, hence max-age=0 bubbles all the way to the parent.');
+    }
+    // Regular case.
+    else {
+      $this->assertFalse(isset($element['#printed']), 'Cache hit');
+    }
     $this->assertSame('<p>#cache enabled, GET</p><p>This is a rendered placeholder!</p>', (string) $element['#markup'], '#markup is overridden.');
     $expected_js_settings = [
       'foo' => 'bar',
@@ -527,7 +832,7 @@ public function testCacheableParentWithPostRequest($test_element, $args) {
 
     // Even when the child element's placeholder is cacheable, it should not
     // generate a render cache item.
-    $this->assertPlaceholderRenderCache(FALSE, []);
+    $this->assertPlaceholderRenderCache(FALSE, [], []);
   }
 
   /**
diff --git a/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php b/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php
index 4f9a333..f3448df 100644
--- a/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php
+++ b/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php
@@ -12,7 +12,7 @@
 use Drupal\Core\Cache\Context\ContextCacheKeys;
 use Drupal\Core\Cache\MemoryBackend;
 use Drupal\Core\Render\Element;
-use Drupal\Core\Render\RenderCache;
+use Drupal\Core\Render\PlaceholderingRenderCache;
 use Drupal\Core\Render\Renderer;
 use Drupal\Tests\UnitTestCase;
 use Symfony\Component\DependencyInjection\ContainerBuilder;
@@ -34,7 +34,7 @@ class RendererTestBase extends UnitTestCase {
   /**
    * The tested render cache.
    *
-   * @var \Drupal\Core\Render\RenderCache
+   * @var \Drupal\Core\Render\PlaceholderingRenderCache
    */
   protected $renderCache;
 
@@ -54,6 +54,11 @@ class RendererTestBase extends UnitTestCase {
   protected $cacheContexts;
 
   /**
+   * @var \Drupal\Core\Cache\CacheTagsInvalidatorInterface|\PHPUnit_Framework_MockObject_MockObject
+   */
+  protected $cacheTagsInvalidator;
+
+  /**
    * The mocked controller resolver.
    *
    * @var \Drupal\Core\Controller\ControllerResolverInterface|\PHPUnit_Framework_MockObject_MockObject
@@ -142,11 +147,13 @@ protected function setUp() {
         }
         return new ContextCacheKeys($keys, new CacheableMetadata());
       });
-    $this->renderCache = new RenderCache($this->requestStack, $this->cacheFactory, $this->cacheContextsManager);
+    $this->renderCache = new PlaceholderingRenderCache($this->requestStack, $this->cacheFactory, $this->cacheContextsManager, $this->rendererConfig);
     $this->renderer = new Renderer($this->controllerResolver, $this->themeManager, $this->elementInfo, $this->renderCache, $this->requestStack, $this->rendererConfig);
+    $this->cacheTagsInvalidator = $this->getMock('Drupal\Core\Cache\CacheTagsInvalidatorInterface');
 
     $container = new ContainerBuilder();
     $container->set('cache_contexts_manager', $this->cacheContextsManager);
+    $container->set('cache_tags.invalidator', $this->cacheTagsInvalidator);
     $container->set('render_cache', $this->renderCache);
     $container->set('renderer', $this->renderer);
     \Drupal::setContainer($container);
@@ -253,4 +260,49 @@ public static function callback($animal, $use_animal_as_array_key = FALSE) {
     ];
   }
 
+  /**
+   * #lazy_builder callback; attaches setting, generates markup, max-age = 0.
+   *
+   * @param string $animal
+   *  An animal.
+   *
+   * @return array
+   *   A renderable array.
+   */
+  public static function callbackMaxAgeZero($animal) {
+    $build = static::callback($animal);
+    $build['#cache']['max-age'] = 0;
+    return $build;
+  }
+
+  /**
+   * #lazy_builder callback; attaches setting, generates markup, user-specific.
+   *
+   * @param string $animal
+   *  An animal.
+   *
+   * @return array
+   *   A renderable array.
+   */
+  public static function callbackPerUser($animal) {
+    $build = static::callback($animal);
+    $build['#cache']['contexts'][] = 'user';
+    return $build;
+  }
+
+  /**
+   * #lazy_builder callback; attaches setting, generates markup, cache tag.
+   *
+   * @param string $animal
+   *  An animal.
+   *
+   * @return array
+   *   A renderable array.
+   */
+  public static function callbackTagCurrentTemperature($animal) {
+    $build = static::callback($animal);
+    $build['#cache']['tags'][] = 'current-temperature';
+    return $build;
+  }
+
 }
