 .../Core/Render/PlaceholderingRenderCache.php      | 55 ++++++++++++++++
 core/lib/Drupal/Core/Render/RenderCache.php        | 40 ++++++++++--
 .../Tests/Core/Render/RendererPlaceholdersTest.php | 75 ++++++++++++++++++----
 .../Drupal/Tests/Core/Render/RendererTestBase.php  | 15 +++++
 4 files changed, 167 insertions(+), 18 deletions(-)

diff --git a/core/lib/Drupal/Core/Render/PlaceholderingRenderCache.php b/core/lib/Drupal/Core/Render/PlaceholderingRenderCache.php
index f867ab1..7a43beb 100644
--- a/core/lib/Drupal/Core/Render/PlaceholderingRenderCache.php
+++ b/core/lib/Drupal/Core/Render/PlaceholderingRenderCache.php
@@ -7,6 +7,7 @@
 
 namespace Drupal\Core\Render;
 
+use Drupal\Core\Cache\Cache;
 use Drupal\Core\Cache\CacheFactoryInterface;
 use Drupal\Core\Cache\Context\CacheContextsManager;
 use Symfony\Component\HttpFoundation\RequestStack;
@@ -77,6 +78,16 @@ class PlaceholderingRenderCache extends RenderCache {
   protected $placeholderResultsCache = [];
 
   /**
+   * The expire override.
+   *
+   * @see ::set()
+   * @see ::maxAgeToOverride()
+   *
+   * @var NULL|int
+   */
+  protected $expireOverride = NULL;
+
+  /**
    * Constructs a new PlaceholderingRenderCache object.
    *
    * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
@@ -116,6 +127,17 @@ public function get(array $elements) {
         return $this->createPlaceholderAndRemember($cached_element, $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;
     }
   }
@@ -126,6 +148,27 @@ public function get(array $elements) {
   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->placeholderGenerator->canCreatePlaceholder($pre_bubbling_elements) && $this->placeholderGenerator->shouldAutomaticallyPlaceholder($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 = NULL;
+    }
+
     if ($this->placeholderGenerator->canCreatePlaceholder($pre_bubbling_elements) && $this->placeholderGenerator->shouldAutomaticallyPlaceholder($elements)) {
       // Overwrite $elements with a placeholder. The Renderer (which called this
       // method) will update the context with the bubbleable metadata of the
@@ -180,4 +223,16 @@ protected function getFromPlaceholderResultsCache(array $elements) {
     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 (isset($this->expireOverride)) {
+      return $this->expireOverride;
+    }
+    return parent::maxAgeToExpire($max_age);
+  }
+
 }
diff --git a/core/lib/Drupal/Core/Render/RenderCache.php b/core/lib/Drupal/Core/Render/RenderCache.php
index b7fb4f6..1b9cf00 100644
--- a/core/lib/Drupal/Core/Render/RenderCache.php
+++ b/core/lib/Drupal/Core/Render/RenderCache.php
@@ -67,10 +67,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() || !isset($elements['#cache']['keys']) || !$this->maxAgeAllowsCaching($elements)) {
       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;
@@ -94,10 +95,24 @@ 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
+   *   Unlike ::set(), this is not passed by reference, which means side-effects
+   *   do not exist anymore here; they're isolated to set().
+   * @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';
@@ -289,6 +304,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'].
@@ -300,11 +331,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/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php b/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php
index e2bc876..b2af262 100644
--- a/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php
+++ b/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php
@@ -53,10 +53,6 @@ protected function setUp() {
    * So, in total 2*8 = 16 permutations. (On one axis: uncacheable vs.
    * uncacheable = 2; on the other axis: A1–7 and B = 8.)
    *
-   * @todo Case A5 is not yet supported by core. So that makes for only 14
-   *   permutations currently, instead of 16. That will be done in
-   *   https://www.drupal.org/node/2559847
-   *
    * @return array
    */
   public function providerPlaceholders() {
@@ -144,8 +140,20 @@ public function providerPlaceholders() {
     ];
     // Note the absence of '#create_placeholder', presence of max-age=0 created
     // by the #lazy_builder callback.
-    // @todo in https://www.drupal.org/node/2559847
-    $base_element_a5 = [];
+    // @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()
@@ -371,13 +379,42 @@ public function providerPlaceholders() {
     // - uncacheable
     // (because the render element with #lazy_builder does not have #cache[keys]
     // and hence the max-age=0 bubbles up further)
-    // @todo in https://www.drupal.org/node/2559847
+    $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
-    // @todo in https://www.drupal.org/node/2559847
+    $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
@@ -570,6 +607,9 @@ protected function assertPlaceholderRenderCache($cid_parts, array $bubbled_cache
       $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 ');
+      }
     }
   }
   /**
@@ -646,16 +686,23 @@ public function testCacheableParent($test_element, $args, array $expected_placeh
     // Edge cases: always where both bubbling of an auto-placeholdering
     // condition happens from within a #lazy_builder that is uncacheable.
     // - uncacheable + A5 (cache max-age)
-    // @todo in https://www.drupal.org/node/2559847
+    $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.
-    if ($edge_case_a6_uncacheable) {
+    elseif ($edge_case_a6_uncacheable) {
       $cached_element = $cached->data;
       $expected_redirect = [
         '#cache_redirect' => TRUE,
@@ -739,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',
diff --git a/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php b/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php
index e903739..af696ba 100644
--- a/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php
+++ b/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php
@@ -279,6 +279,21 @@ 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
