 core/lib/Drupal/Core/Cache/Cache.php               |  20 ++
 core/lib/Drupal/Core/Entity/EntityViewBuilder.php  |   3 -
 .../Field/FieldFormatter/LanguageFormatter.php     |   3 +
 .../Field/FieldFormatter/TimestampFormatter.php    |   9 +-
 core/lib/Drupal/Core/Render/BubbleableMetadata.php |  15 +-
 core/lib/Drupal/Core/Render/Renderer.php           |  87 +++++-
 core/lib/Drupal/Core/Render/RendererInterface.php  |  39 ++-
 .../FieldFormatter/CommentDefaultFormatter.php     |   2 +
 .../FieldFormatter/DateTimeDefaultFormatter.php    |   5 +
 .../FieldFormatter/DateTimePlainFormatter.php      |   9 +-
 core/modules/filter/src/FilterProcessResult.php    |  44 +++
 core/modules/filter/src/Tests/FilterAPITest.php    |   9 +
 .../src/Plugin/Filter/FilterTestCacheContexts.php  |  35 +++
 core/modules/node/src/Tests/NodeCacheTagsTest.php  |  15 +
 .../src/Tests/Entity/EntityCacheTagsTestBase.php   |  96 +++++-
 .../Entity/EntityWithUriCacheTagsTestBase.php      |  10 +-
 .../Field/FieldFormatter/TextDefaultFormatter.php  |   2 +
 .../Field/FieldFormatter/TextTrimmedFormatter.php  |   2 +
 .../Tests/Core/Render/BubbleableMetadataTest.php   |  14 +-
 .../Tests/Core/Render/RendererBubblingTest.php     | 329 ++++++++++++++++++++-
 .../Core/Render/RendererPostRenderCacheTest.php    |  40 ++-
 .../Drupal/Tests/Core/Render/RendererTest.php      |   5 +-
 .../Drupal/Tests/Core/Render/RendererTestBase.php  |  16 +
 23 files changed, 772 insertions(+), 37 deletions(-)

diff --git a/core/lib/Drupal/Core/Cache/Cache.php b/core/lib/Drupal/Core/Cache/Cache.php
index 4e0124f..91cc4a8 100644
--- a/core/lib/Drupal/Core/Cache/Cache.php
+++ b/core/lib/Drupal/Core/Cache/Cache.php
@@ -22,6 +22,26 @@ class Cache {
   const PERMANENT = CacheBackendInterface::CACHE_PERMANENT;
 
   /**
+   * Merges arrays of cache contexts and removes duplicates.
+   *
+   * @param string[] …
+   *   Arrays of cache contexts to merge.
+   *
+   * @return string[]
+   *   The merged array of cache contexts.
+   */
+  public static function mergeContexts() {
+    $cache_context_arrays = func_get_args();
+    $cache_contexts = [];
+    foreach ($cache_context_arrays as $contexts) {
+      $cache_contexts = array_merge($cache_contexts, $contexts);
+    }
+    $cache_contexts = array_unique($cache_contexts);
+    sort($cache_contexts);
+    return $cache_contexts;
+  }
+
+  /**
    * Merges arrays of cache tags and removes duplicates.
    *
    * The cache tags array is returned in a format that is valid for
diff --git a/core/lib/Drupal/Core/Entity/EntityViewBuilder.php b/core/lib/Drupal/Core/Entity/EntityViewBuilder.php
index 2f506c2..af5abe0 100644
--- a/core/lib/Drupal/Core/Entity/EntityViewBuilder.php
+++ b/core/lib/Drupal/Core/Entity/EntityViewBuilder.php
@@ -185,9 +185,6 @@ protected function getBuildDefaults(EntityInterface $entity, $view_mode, $langco
         'contexts' => array(
           'theme',
           'user.roles',
-          // @todo Move this out of here and into field formatters that depend
-          //       on the timezone. Blocked on https://drupal.org/node/2099137.
-          'timezone',
         ),
         'bin' => $this->cacheBin,
       );
diff --git a/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/LanguageFormatter.php b/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/LanguageFormatter.php
index d936b89..ba730a6 100644
--- a/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/LanguageFormatter.php
+++ b/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/LanguageFormatter.php
@@ -30,6 +30,9 @@ class LanguageFormatter extends FormatterBase {
   public function viewElements(FieldItemListInterface $items) {
     $elements = array();
 
+    // The 'language' cache context is not necessary, because
+    // \Drupal\Core\Language\LanguageInterface::getName() always returns the
+    // human-readable English name.
     foreach ($items as $delta => $item) {
       $elements[$delta] = array('#markup' => $item->language ? String::checkPlain($item->language->getName()) : '');
     }
diff --git a/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/TimestampFormatter.php b/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/TimestampFormatter.php
index 6861a00..6de42ee 100644
--- a/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/TimestampFormatter.php
+++ b/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/TimestampFormatter.php
@@ -31,7 +31,14 @@ public function viewElements(FieldItemListInterface $items) {
     $elements = array();
 
     foreach ($items as $delta => $item) {
-      $elements[$delta] = array('#markup' => format_date($item->value));
+      $elements[$delta] = [
+        '#cache' => [
+          'contexts' => [
+            'timezone',
+          ],
+        ],
+        '#markup' => format_date($item->value)
+      ];
     }
 
     return $elements;
diff --git a/core/lib/Drupal/Core/Render/BubbleableMetadata.php b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
index 2f62b44..fa2edf0 100644
--- a/core/lib/Drupal/Core/Render/BubbleableMetadata.php
+++ b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
@@ -18,6 +18,13 @@
 class BubbleableMetadata {
 
   /**
+   * Cache contexts.
+   *
+   * @var string[]
+   */
+  protected $contexts;
+
+  /**
    * Cache tags.
    *
    * @var string[]
@@ -41,6 +48,8 @@ class BubbleableMetadata {
   /**
    * Constructs a BubbleableMetadata value object.
    *
+   * @param string[] $contexts
+   *   An array of cache contexts.
    * @param string[] $tags
    *   An array of cache tags.
    * @param array $attached
@@ -48,7 +57,8 @@ class BubbleableMetadata {
    * @param array $post_render_cache
    *   An array of #post_render_cache metadata.
    */
-  public function __construct(array $tags = [], array $attached = [], array $post_render_cache = []) {
+  public function __construct(array $contexts = [], array $tags = [], array $attached = [], array $post_render_cache = []) {
+    $this->contexts = $contexts;
     $this->tags = $tags;
     $this->attached = $attached;
     $this->postRenderCache = $post_render_cache;
@@ -69,6 +79,7 @@ public function __construct(array $tags = [], array $attached = [], array $post_
    */
   public function merge(BubbleableMetadata $other) {
     $result = new BubbleableMetadata();
+    $result->contexts = Cache::mergeContexts($this->contexts, $other->contexts);
     $result->tags = Cache::mergeTags($this->tags, $other->tags);
     $result->attached = Renderer::mergeAttachments($this->attached, $other->attached);
     $result->postRenderCache = NestedArray::mergeDeep($this->postRenderCache, $other->postRenderCache);
@@ -82,6 +93,7 @@ public function merge(BubbleableMetadata $other) {
    *   A render array.
    */
   public function applyTo(array &$build) {
+    $build['#cache']['contexts'] = $this->contexts;
     $build['#cache']['tags'] = $this->tags;
     $build['#attached'] = $this->attached;
     $build['#post_render_cache'] = $this->postRenderCache;
@@ -97,6 +109,7 @@ public function applyTo(array &$build) {
    */
   public static function createFromRenderArray(array $build) {
     $meta = new static();
+    $meta->contexts = (isset($build['#cache']['contexts'])) ? $build['#cache']['contexts'] : [];
     $meta->tags = (isset($build['#cache']['tags'])) ? $build['#cache']['tags'] : [];
     $meta->attached = (isset($build['#attached'])) ? $build['#attached'] : [];
     $meta->postRenderCache = (isset($build['#post_render_cache'])) ? $build['#post_render_cache'] : [];
diff --git a/core/lib/Drupal/Core/Render/Renderer.php b/core/lib/Drupal/Core/Render/Renderer.php
index b2f6072..31d1ebd 100644
--- a/core/lib/Drupal/Core/Render/Renderer.php
+++ b/core/lib/Drupal/Core/Render/Renderer.php
@@ -166,6 +166,7 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
 
     // Try to fetch the prerendered element from cache, run any
     // #post_render_cache callbacks and return the final markup.
+    $pre_bubbling_cid = NULL;
     if (isset($elements['#cache'])) {
       $cached_element = $this->cacheGet($elements);
       if ($cached_element !== FALSE) {
@@ -185,6 +186,15 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
         $this->bubbleStack();
         return $elements['#markup'];
       }
+      else {
+        // Two-tier caching: set pre-bubbling cache ID, if this element is
+        // cacheable..
+        // @see ::cacheGet()
+        // @see ::cacheSet()
+        if ($this->requestStack->getCurrentRequest()->isMethodSafe() && $cid = $this->createCacheID($elements)) {
+          $pre_bubbling_cid = $cid;
+        }
+      }
     }
 
     // If the default values for this element have not been loaded yet, populate
@@ -206,6 +216,7 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
     }
 
     // Defaults for bubbleable rendering metadata.
+    $elements['#cache']['contexts'] = isset($elements['#cache']['contexts']) ? $elements['#cache']['contexts'] : array();
     $elements['#cache']['tags'] = isset($elements['#cache']['tags']) ? $elements['#cache']['tags'] : array();
     $elements['#attached'] = isset($elements['#attached']) ? $elements['#attached'] : array();
     $elements['#post_render_cache'] = isset($elements['#post_render_cache']) ? $elements['#post_render_cache'] : array();
@@ -347,7 +358,7 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
     // Cache the processed element if #cache is set, and the metadata necessary
     // to generate a cache ID is present.
     if (isset($elements['#cache']) && (isset($elements['#cache']['keys']) || isset($elements['#cache']['cid']))) {
-      $this->cacheSet($elements);
+      $this->cacheSet($elements, $pre_bubbling_cid);
     }
 
     // Only when we're in a root (non-recursive) drupal_render() call,
@@ -495,6 +506,12 @@ protected function cacheGet(array $elements) {
 
     if (!empty($cid) && ($cache_bin = $this->cacheFactory->get($bin)) && $cache = $cache_bin->get($cid)) {
       $cached_element = $cache->data;
+      // Two-tier caching: redirect to actual (post-bubbling) cache item.
+      // @see ::doRender()
+      // @see ::cacheSet()
+      if (isset($cached_element['#cache_redirect'])) {
+        return $this->cacheGet($cached_element);
+      }
       // Return the cached element.
       return $cached_element;
     }
@@ -508,13 +525,15 @@ protected function cacheGet(array $elements) {
    *
    * @param array $elements
    *   A renderable array.
+   * @param string|null $pre_bubbling_cid
+   *   The pre-bubbling cache ID.
    *
    * @return bool|null
    *  Returns FALSE if no cache item could be created, NULL otherwise.
    *
    * @see ::getFromCache()
    */
-  protected function cacheSet(array &$elements) {
+  protected function cacheSet(array &$elements, $pre_bubbling_cid) {
     // Form submissions rely on the form being built during the POST request,
     // and render caching of forms prevents this from happening.
     // @todo remove the isMethodSafe() check when
@@ -532,7 +551,67 @@ protected function cacheSet(array &$elements) {
 
     $bin = isset($elements['#cache']['bin']) ? $elements['#cache']['bin'] : 'render';
     $expire = isset($elements['#cache']['expire']) ? $elements['#cache']['expire'] : Cache::PERMANENT;
-    $this->cacheFactory->get($bin)->set($cid, $data, $expire, $data['#cache']['tags']);
+    $cache = $this->cacheFactory->get($bin);
+
+    // Two-tier caching: detect different CID post-bubbling, create redirect,
+    // update redirect if different set of cache contexts.
+    // @see ::doRender()
+    // @see ::cacheGet()
+    if (isset($pre_bubbling_cid) && $pre_bubbling_cid !== $cid) {
+      // The set of cache contexts for this element, including the bubbled ones,
+      // for which we are handling a cache miss.
+      $cache_contexts = $data['#cache']['contexts'];
+
+      // Get the contexts by which this element should be varied according to
+      // the current redirecting cache item, if any.
+      $stored_cache_contexts = [];
+      $stored_cache_tags = [];
+      if ($stored_cache_redirect = $cache->get($pre_bubbling_cid)) {
+        $stored_cache_contexts = $stored_cache_redirect->data['#cache']['contexts'];
+        $stored_cache_tags = $stored_cache_redirect->data['#cache']['tags'];
+      }
+
+      // Calculate the union of the cache contexts for this request and the
+      // stored cache contexts.
+      $merged_cache_contexts = Cache::mergeContexts($stored_cache_contexts, $cache_contexts);
+
+      // Stored cache contexts incomplete: this request causes cache contexts to
+      // be added to the redirecting cache item.
+      if (count(array_diff($merged_cache_contexts, $stored_cache_contexts)) > 0) {
+        $redirect_data = [
+          '#cache_redirect' => TRUE,
+          '#cache' => [
+            // The cache keys of the current element; this remains the same
+            // across requests.
+            'keys' => $elements['#cache']['keys'],
+            // The union of the current element's and stored cache contexts.
+            'contexts' => $merged_cache_contexts,
+            // The union of the current element's and stored cache tags.
+            'tags' => Cache::mergeTags($stored_cache_tags, $data['#cache']['tags']),
+          ],
+        ];
+        $cache->set($pre_bubbling_cid, $redirect_data, $expire, $redirect_data['#cache']['tags']);
+      }
+
+      // Current cache contexts incomplete: this request only uses a subset of
+      // the cache contexts stored in the redirecting cache item. Vary by these
+      // additional (conditional) cache contexts as well, otherwise the
+      // redirecting cache item would be pointing to a cache item that can never
+      // exist.
+      if (count(array_diff($merged_cache_contexts, $cache_contexts)) > 0) {
+        // Recalculate the cache ID.
+        $recalculated_cid_pseudo_element = [
+          '#cache' => [
+            'keys' => $elements['#cache']['keys'],
+            'contexts' => $merged_cache_contexts,
+          ]
+        ];
+        $cid = $this->createCacheID($recalculated_cid_pseudo_element);
+        // Ensure the about-to-be-cached data uses the merged cache contexts.
+        $data['#cache']['contexts'] = $merged_cache_contexts;
+      }
+    }
+    $cache->set($cid, $data, $expire, $data['#cache']['tags']);
   }
 
   /**
@@ -555,6 +634,7 @@ protected function createCacheID(array $elements) {
     elseif (isset($elements['#cache']['keys'])) {
       $cid_parts = $elements['#cache']['keys'];
       if (isset($elements['#cache']['contexts'])) {
+        sort($elements['#cache']['contexts']);
         $contexts = $this->cacheContexts->convertTokensToKeys($elements['#cache']['contexts']);
         $cid_parts = array_merge($cid_parts, $contexts);
       }
@@ -572,6 +652,7 @@ public function getCacheableRenderArray(array $elements) {
       '#attached' => $elements['#attached'],
       '#post_render_cache' => $elements['#post_render_cache'],
       '#cache' => [
+        'contexts' => $elements['#cache']['contexts'],
         'tags' => $elements['#cache']['tags'],
       ],
     ];
diff --git a/core/lib/Drupal/Core/Render/RendererInterface.php b/core/lib/Drupal/Core/Render/RendererInterface.php
index 5207215..c4fbe12 100644
--- a/core/lib/Drupal/Core/Render/RendererInterface.php
+++ b/core/lib/Drupal/Core/Render/RendererInterface.php
@@ -73,12 +73,19 @@ public function renderPlain(&$elements);
    * the parent array.
    *
    * An important aspect of rendering is the bubbling of rendering metadata:
-   * cache tags, attached assets and #post_render_cache metadata all need to be
-   * bubbled up. That information is needed once the rendering to a HTML string
-   * is completed: the resulting HTML for the page must know by which cache tags
-   * it should be invalidated, which (CSS and JavaScript) assets must be loaded,
-   * and which #post_render_cache callbacks should be executed. A stack data
-   * structure is used to perform this bubbling.
+   * cache contexts, cache tags, attached assets and #post_render_cache metadata
+   * all need to be bubbled up. That information is needed once the rendering to
+   * a HTML string is completed: we must know the bubbled cache contexts to be
+   * able to calculate the correct cache ID for render caching, the the
+   * resulting HTML for the page must know by which cache tags it should be
+   * invalidated, which (CSS and JavaScript) assets must be loaded, and which
+   * #post_render_cache callbacks should be executed. A stack data structure is
+   * used to perform this bubbling.
+   *
+   * Another important aspect is render caching. A two-tier caching approach
+   * (i.e. with one cache item redirecting to another one) to make render
+   * caching compatible with the bubbling of cache contexts, because cache
+   * contexts affect the cache ID.
    *
    * The process of rendering an element is recursive unless the element defines
    * an implemented theme hook in #theme. During each call to
@@ -111,6 +118,20 @@ public function renderPlain(&$elements);
    *     metadata from the element retrieved from render cache. Then, this stack
    *     frame is bubbled: the two topmost frames are popped from the stack,
    *     they are merged, and the result is pushed back onto the stack.
+   *     However, also in case of a cache miss we have to do something. Note
+   *     that a Renderer renders top-down, which means that we try to render a
+   *     parent first, and we try to avoid the work of rendering the children by
+   *     using the render cache. Though in this case, we are dealing with a
+   *     cache miss. So a Renderer traverses down the tree, rendering all
+   *     children. In doing so, the render stack is updated with the bubbleable
+   *     metadata of the children. That means that once the children are
+   *     rendered, we can render cache this element. But the cache ID may have
+   *     *changed* at that point, because the children's cache contexts have
+   *     been bubbled!
+   *     It is for that case that we must store the current (pre-bubbling) cache
+   *     ID, so that we can compare it with the new (post-bubbling) cache ID
+   *     when writing to the cache. We store the current cache ID in
+   *     #cid_pre_bubbling.
    *   - If this element has #type defined and the default attributes for this
    *     element have not already been merged in (#defaults_loaded = TRUE) then
    *     the defaults for this type of element, defined in hook_element_info(),
@@ -210,6 +231,12 @@ public function renderPlain(&$elements);
    *   - If this element has #cache defined, the rendered output of this element
    *     is saved to Renderer::render()'s internal cache. This includes the
    *     changes made by #post_render.
+   *     At the same time, if #cid_pre_bubbling is set, it is compared to the
+   *     calculated cache ID. If they are different, then a redirecting cache
+   *     item is created, containing the #cache metadata of the current element,
+   *     and written to cache using the value of #cid_pre_bubbling as the cache
+   *     ID. This ensures the pre-bubbling ("wrong") cache ID redirects to the
+   *     post-bubbling ("right") cache ID.
    *   - If this element has an array of #post_render_cache functions defined,
    *     or any of its children has (which we would know thanks to the stack
    *     having been updated just before the render caching step), they are
diff --git a/core/modules/comment/src/Plugin/Field/FieldFormatter/CommentDefaultFormatter.php b/core/modules/comment/src/Plugin/Field/FieldFormatter/CommentDefaultFormatter.php
index 02c3107..be747b4 100644
--- a/core/modules/comment/src/Plugin/Field/FieldFormatter/CommentDefaultFormatter.php
+++ b/core/modules/comment/src/Plugin/Field/FieldFormatter/CommentDefaultFormatter.php
@@ -157,6 +157,7 @@ public function viewElements(FieldItemListInterface $items) {
       // Unpublished comments are not included in
       // $entity->get($field_name)->comment_count, but unpublished comments
       // should display if the user is an administrator.
+      $elements['#cache']['contexts'][] = 'user.roles';
       if ($this->currentUser->hasPermission('access comments') || $this->currentUser->hasPermission('administer comments')) {
         // This is a listing of Comment entities, so associate its list cache
         // tag for correct invalidation.
@@ -182,6 +183,7 @@ public function viewElements(FieldItemListInterface $items) {
       // display below the entity. Do not show the form for the print view mode.
       if ($status == CommentItemInterface::OPEN && $comment_settings['form_location'] == CommentItemInterface::FORM_BELOW && $this->viewMode != 'print') {
         // Only show the add comment form if the user has permission.
+        $elements['#cache']['contexts'][] = 'user.roles';
         if ($this->currentUser->hasPermission('post comments')) {
           // All users in the "anonymous" role can use the same form: it is fine
           // for this form to be stored in the render cache.
diff --git a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeDefaultFormatter.php b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeDefaultFormatter.php
index f983c8d..a7cb32a 100644
--- a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeDefaultFormatter.php
+++ b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimeDefaultFormatter.php
@@ -127,6 +127,11 @@ public function viewElements(FieldItemListInterface $items) {
 
       // Display the date using theme datetime.
       $elements[$delta] = array(
+        '#cache' => [
+          'contexts' => [
+            'timezone',
+          ],
+        ],
         '#theme' => 'time',
         '#text' => $formatted_date,
         '#html' => FALSE,
diff --git a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimePlainFormatter.php b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimePlainFormatter.php
index 24fc69c..3f68a7f 100644
--- a/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimePlainFormatter.php
+++ b/core/modules/datetime/src/Plugin/Field/FieldFormatter/DateTimePlainFormatter.php
@@ -46,7 +46,14 @@ public function viewElements(FieldItemListInterface $items) {
         }
         $output = $date->format($format);
       }
-      $elements[$delta] = array('#markup' => $output);
+      $elements[$delta] = [
+        '#cache' => [
+          'contexts' => [
+            'timezone',
+          ],
+        ],
+        '#markup' => $output,
+      ];
     }
 
     return $elements;
diff --git a/core/modules/filter/src/FilterProcessResult.php b/core/modules/filter/src/FilterProcessResult.php
index f620868..167478d 100644
--- a/core/modules/filter/src/FilterProcessResult.php
+++ b/core/modules/filter/src/FilterProcessResult.php
@@ -86,6 +86,13 @@ class FilterProcessResult {
   protected $cacheTags;
 
   /**
+   * The associated cache contexts.
+   *
+   * @var string[]
+   */
+  protected $cacheContexts;
+
+  /**
    * The associated #post_render_cache callbacks.
    *
    * @see _drupal_render_process_post_render_cache()
@@ -105,6 +112,7 @@ public function __construct($processed_text) {
 
     $this->assets = array();
     $this->cacheTags = array();
+    $this->cacheContexts = array();
     $this->postRenderCacheCallbacks = array();
   }
 
@@ -175,6 +183,41 @@ public function setCacheTags(array $cache_tags) {
   }
 
   /**
+   * Gets cache contexts associated with the processed text.
+   *
+   * @return string[]
+   */
+  public function getCacheContexts() {
+    return $this->cacheContexts;
+  }
+
+  /**
+   * Adds cache contexts associated with the processed text.
+   *
+   * @param string[] $cache_contexts
+   *   The cache contexts to be added.
+   *
+   * @return $this
+   */
+  public function addCacheContexts(array $cache_contexts) {
+    $this->cacheContexts = Cache::mergeContexts($this->cacheTags, $cache_contexts);
+    return $this;
+  }
+
+  /**
+   * Sets cache contexts associated with the processed text.
+   *
+   * @param string[] $cache_contexts
+   *   The cache contexts to be associated.
+   *
+   * @return $this
+   */
+  public function setCacheContexts(array $cache_contexts) {
+    $this->cacheContexts = $cache_contexts;
+    return $this;
+  }
+
+  /**
    * Gets assets associated with the processed text.
    *
    * @return array
@@ -256,6 +299,7 @@ public function setPostRenderCacheCallbacks(array $post_render_cache_callbacks)
    */
   public function getBubbleableMetadata() {
     return new BubbleableMetadata(
+      $this->getCacheContexts(),
       $this->getCacheTags(),
       $this->getAssets(),
       $this->getPostRenderCacheCallbacks()
diff --git a/core/modules/filter/src/Tests/FilterAPITest.php b/core/modules/filter/src/Tests/FilterAPITest.php
index 50210b5..6ee11e2 100644
--- a/core/modules/filter/src/Tests/FilterAPITest.php
+++ b/core/modules/filter/src/Tests/FilterAPITest.php
@@ -216,6 +216,10 @@ function testProcessedTextElement() {
           'weight' => 0,
           'status' => TRUE,
         ),
+        'filter_test_cache_contexts' => array(
+          'weight' => 0,
+          'status' => TRUE,
+        ),
         'filter_test_post_render_cache' => array(
           'weight' => 1,
           'status' => TRUE,
@@ -253,6 +257,11 @@ function testProcessedTextElement() {
       'foo:baz',
     );
     $this->assertEqual($expected_cache_tags, $build['#cache']['tags'], 'Expected cache tags present.');
+    $expected_cache_contexts = [
+      // The cache context set by the filter_test_cache_contexts filter.
+      'language',
+    ];
+    $this->assertEqual($expected_cache_contexts, $build['#cache']['contexts'], 'Expected cache contexts present.');
     $expected_markup = '<p>Hello, world!</p><p>This is a dynamic llama.</p>';
     $this->assertEqual($expected_markup, $build['#markup'], 'Expected #post_render_cache callback has been applied.');
   }
diff --git a/core/modules/filter/tests/filter_test/src/Plugin/Filter/FilterTestCacheContexts.php b/core/modules/filter/tests/filter_test/src/Plugin/Filter/FilterTestCacheContexts.php
new file mode 100644
index 0000000..6a98956
--- /dev/null
+++ b/core/modules/filter/tests/filter_test/src/Plugin/Filter/FilterTestCacheContexts.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\filter_test\Plugin\Filter\FilterTestCacheContexts.
+ */
+
+namespace Drupal\filter_test\Plugin\Filter;
+
+use Drupal\filter\FilterProcessResult;
+use Drupal\filter\Plugin\FilterBase;
+
+/**
+ * Provides a test filter to associate cache contexts.
+ *
+ * @Filter(
+ *   id = "filter_test_cache_contexts",
+ *   title = @Translation("Testing filter"),
+ *   description = @Translation("Does not change content; associates cache contexts."),
+ *   type = Drupal\filter\Plugin\FilterInterface::TYPE_TRANSFORM_REVERSIBLE
+ * )
+ */
+class FilterTestCacheContexts extends FilterBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function process($text, $langcode) {
+    $result = new FilterProcessResult($text);
+    // The changes made by this filter are language-specific.
+    $result->addCacheContexts(['language']);
+    return $result;
+  }
+
+}
diff --git a/core/modules/node/src/Tests/NodeCacheTagsTest.php b/core/modules/node/src/Tests/NodeCacheTagsTest.php
index cd46f79..333a111 100644
--- a/core/modules/node/src/Tests/NodeCacheTagsTest.php
+++ b/core/modules/node/src/Tests/NodeCacheTagsTest.php
@@ -43,6 +43,21 @@ protected function createEntity() {
   }
 
   /**
+   * Returns the additional (non-standard) cache contexts for the tested entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to be tested, as created by createEntity().
+   *
+   * @return string[]
+   *   An array of the additional cache contexts.
+   *
+   * @see \Drupal\system\Tests\Entity\EntityCacheTagsTestBase::createEntity()
+   */
+  protected function getAdditionalCacheContextsForEntity(EntityInterface $entity) {
+    return ['timezone'];
+  }
+
+  /**
    * {@inheritdoc}
    *
    * Each node must have an author.
diff --git a/core/modules/system/src/Tests/Entity/EntityCacheTagsTestBase.php b/core/modules/system/src/Tests/Entity/EntityCacheTagsTestBase.php
index 3d7c93f..580cada 100644
--- a/core/modules/system/src/Tests/Entity/EntityCacheTagsTestBase.php
+++ b/core/modules/system/src/Tests/Entity/EntityCacheTagsTestBase.php
@@ -128,6 +128,21 @@ protected static function generateStandardizedInfo($entity_type_label, $group) {
   abstract protected function createEntity();
 
   /**
+   * Returns the additional (non-standard) cache contexts for the tested entity.
+   *
+   * @param \Drupal\Core\Entity\EntityInterface $entity
+   *   The entity to be tested, as created by createEntity().
+   *
+   * @return string[]
+   *   An array of the additional cache contexts.
+   *
+   * @see \Drupal\system\Tests\Entity\EntityCacheTagsTestBase::createEntity()
+   */
+  protected function getAdditionalCacheContextsForEntity(EntityInterface $entity) {
+    return [];
+  }
+
+  /**
    * Returns the additional (non-standard) cache tags for the tested entity.
    *
    * @param \Drupal\Core\Entity\EntityInterface $entity
@@ -288,6 +303,9 @@ public function testReferencedEntity() {
     $empty_entity_listing_url = Url::fromRoute('entity.entity_test.collection_empty', ['entity_type_id' => $entity_type]);
     $nonempty_entity_listing_url = Url::fromRoute('entity.entity_test.collection_labels_alphabetically', ['entity_type_id' => $entity_type]);
 
+    // The default cache contexts for rendered entities.
+    $entity_cache_contexts = ['theme', 'user.roles'];
+
     // Cache tags present on every rendered page.
     $page_cache_tags = Cache::mergeTags(
       ['rendered'],
@@ -337,15 +355,18 @@ public function testReferencedEntity() {
     // Verify a cache hit, but also the presence of the correct cache tags.
     $this->verifyPageCache($referencing_entity_url, 'HIT', Cache::mergeTags($referencing_entity_cache_tags, $page_cache_tags));
     // Also verify the existence of an entity render cache entry.
-    $cid = 'entity_view:entity_test:' . $this->referencing_entity->id() . ':full:classy:r.anonymous:' . date_default_timezone_get();
-    $this->verifyRenderCache($cid, $referencing_entity_cache_tags);
+    $cache_keys = ['entity_view', 'entity_test', $this->referencing_entity->id(), 'full'];
+    $cid = $this->createCacheId($cache_keys, $entity_cache_contexts);
+    $redirected_cid = $this->createRedirectedCacheId($cache_keys, $entity_cache_contexts);
+    $this->verifyRenderCache($cid, $referencing_entity_cache_tags, $redirected_cid);
 
     $this->pass("Test non-referencing entity.", 'Debug');
     $this->verifyPageCache($non_referencing_entity_url, 'MISS');
     // Verify a cache hit, but also the presence of the correct cache tags.
     $this->verifyPageCache($non_referencing_entity_url, 'HIT', Cache::mergeTags($non_referencing_entity_cache_tags, $page_cache_tags));
     // Also verify the existence of an entity render cache entry.
-    $cid = 'entity_view:entity_test:' . $this->non_referencing_entity->id() . ':full:classy:r.anonymous:' . date_default_timezone_get();
+    $cache_keys = ['entity_view', 'entity_test', $this->non_referencing_entity->id(), 'full'];
+    $cid = $this->createCacheId($cache_keys, $entity_cache_contexts);
     $this->verifyRenderCache($cid, $non_referencing_entity_cache_tags);
 
 
@@ -579,20 +600,87 @@ public function testReferencedEntity() {
   }
 
   /**
+   * Creates a cache ID from a list of cache keys and a set of cache contexts.
+   *
+   * @param string[] $keys
+   *   A list of cache keys.
+   * @param string[] $contexts
+   *   A set of cache contexts.
+   *
+   * @return string
+   *   The cache ID string.
+   */
+  protected function createCacheId(array $keys, array $contexts) {
+    $cid_parts = $keys;
+
+    sort($contexts);
+    $contexts = \Drupal::service('cache_contexts')->convertTokensToKeys($contexts);
+    $cid_parts = array_merge($cid_parts, $contexts);
+
+    return implode(':', $cid_parts);
+  }
+
+  /**
+   * Creates the redirected cache ID, if any.
+   *
+   * If a subclass overrides ::getAdditionalCacheContextsForEntity(), it can
+   * specify the additional cache contexts by which the given entity must be
+   * varied, because those are the cache contexts that are bubbled from the
+   * field formatters.
+   *
+   * @param string[] $keys
+   *   A list of cache keys used for the regular (pre-bubbling) CID.
+   * @param string[] $contexts
+   *   A set of cache contexts used for the regular (pre-bubbling) CID.
+   *
+   * @return null|string
+   *   The redirected (post-bubbling) CID, if any.
+   */
+  protected function createRedirectedCacheId(array $keys, array $contexts) {
+    $additional_cache_contexts = $this->getAdditionalCacheContextsForEntity($this->referencing_entity);
+    if (count($additional_cache_contexts)) {
+      return $this->createCacheId($keys, Cache::mergeContexts($contexts, $additional_cache_contexts));
+    }
+    else {
+      return NULL;
+    }
+  }
+
+  /**
    * Verify that a given render cache entry exists, with the correct cache tags.
    *
    * @param string $cid
    *   The render cache item ID.
    * @param array $tags
    *   An array of expected cache tags.
+   * @param string|null $redirected_cid
+   *   (optional) The redirected render cache item ID.
    */
-  protected function verifyRenderCache($cid, array $tags) {
+  protected function verifyRenderCache($cid, array $tags, $redirected_cid = NULL) {
     // Also verify the existence of an entity render cache entry.
     $cache_entry = \Drupal::cache('render')->get($cid);
     $this->assertTrue($cache_entry, 'A render cache entry exists.');
     sort($cache_entry->tags);
     sort($tags);
     $this->assertIdentical($cache_entry->tags, $tags);
+    if ($redirected_cid === NULL) {
+      $this->assertTrue(!isset($cache_entry->data['#cache_redirect']), 'Render cache entry is not a redirect.');
+    }
+    else {
+      // Verify that $cid contains a cache redirect.
+      $this->assertTrue(isset($cache_entry->data['#cache_redirect']), 'Render cache entry is a redirect.');
+      // Verify that the cache redirect points to the expected CID.
+      $redirect_cache_metadata = $cache_entry->data['#cache'];
+      $cache_keys = array_merge(
+        $redirect_cache_metadata['keys'],
+        \Drupal::service('cache_contexts')->convertTokensToKeys($redirect_cache_metadata['contexts'])
+      );
+      $actual_redirection_cid = implode(':', $cache_keys);
+      $this->assertIdentical($redirected_cid, $actual_redirection_cid);
+      // Finally, verify that the redirected CID exists and has the same cache
+      // tags.
+      $this->verifyRenderCache($redirected_cid, $tags);
+    }
   }
 
 }
diff --git a/core/modules/system/src/Tests/Entity/EntityWithUriCacheTagsTestBase.php b/core/modules/system/src/Tests/Entity/EntityWithUriCacheTagsTestBase.php
index 0c8cd1d..c72d7d0 100644
--- a/core/modules/system/src/Tests/Entity/EntityWithUriCacheTagsTestBase.php
+++ b/core/modules/system/src/Tests/Entity/EntityWithUriCacheTagsTestBase.php
@@ -30,6 +30,9 @@ public function testEntityUri() {
     // Selects the view mode that will be used.
     $view_mode = $this->selectViewMode($entity_type);
 
+    // The default cache contexts for rendered entities.
+    $entity_cache_contexts = ['theme', 'user.roles'];
+
     // Generate the standardized entity cache tags.
     $cache_tag = $this->entity->getCacheTags();
     $view_cache_tag = \Drupal::entityManager()->getViewBuilder($entity_type)->getCacheTags();
@@ -45,10 +48,11 @@ public function testEntityUri() {
     // Also verify the existence of an entity render cache entry, if this entity
     // type supports render caching.
     if (\Drupal::entityManager()->getDefinition($entity_type)->isRenderCacheable()) {
-      $cid = 'entity_view:' . $entity_type . ':' . $this->entity->id() . ':' . $view_mode . ':classy:r.anonymous:' . date_default_timezone_get();
-      $cache_entry = \Drupal::cache('render')->get($cid);
+      $cache_keys = ['entity_view', $entity_type, $this->entity->id(), $view_mode];
+      $cid = $this->createCacheId($cache_keys, $entity_cache_contexts);
+      $redirected_cid = $this->createRedirectedCacheId($cache_keys, $entity_cache_contexts);
       $expected_cache_tags = Cache::mergeTags($cache_tag, $view_cache_tag, $this->getAdditionalCacheTagsForEntity($this->entity), array($render_cache_tag));
-      $this->verifyRenderCache($cid, $expected_cache_tags);
+      $this->verifyRenderCache($cid, $expected_cache_tags, $redirected_cid);
     }
 
     // Verify that after modifying the entity, there is a cache miss.
diff --git a/core/modules/text/src/Plugin/Field/FieldFormatter/TextDefaultFormatter.php b/core/modules/text/src/Plugin/Field/FieldFormatter/TextDefaultFormatter.php
index efc4c0d..623c808 100644
--- a/core/modules/text/src/Plugin/Field/FieldFormatter/TextDefaultFormatter.php
+++ b/core/modules/text/src/Plugin/Field/FieldFormatter/TextDefaultFormatter.php
@@ -34,6 +34,8 @@ class TextDefaultFormatter extends FormatterBase {
   public function viewElements(FieldItemListInterface $items) {
     $elements = array();
 
+    // The ProcessedText element already handles cache context & tag bubbling.
+    // @see \Drupal\filter\Element\ProcessedText::preRenderText()
     foreach ($items as $delta => $item) {
       $elements[$delta] = array(
         '#type' => 'processed_text',
diff --git a/core/modules/text/src/Plugin/Field/FieldFormatter/TextTrimmedFormatter.php b/core/modules/text/src/Plugin/Field/FieldFormatter/TextTrimmedFormatter.php
index ed1c8c3..105bfd5 100644
--- a/core/modules/text/src/Plugin/Field/FieldFormatter/TextTrimmedFormatter.php
+++ b/core/modules/text/src/Plugin/Field/FieldFormatter/TextTrimmedFormatter.php
@@ -81,6 +81,8 @@ public function viewElements(FieldItemListInterface $items) {
       $element['#text_summary_trim_length'] = $this->getSetting('trim_length');
     };
 
+    // The ProcessedText element already handles cache context & tag bubbling.
+    // @see \Drupal\filter\Element\ProcessedText::preRenderText()
     foreach ($items as $delta => $item) {
       $elements[$delta] = array(
         '#type' => 'processed_text',
diff --git a/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php b/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php
index c3fd7ba..ea336da 100644
--- a/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php
+++ b/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php
@@ -35,11 +35,12 @@ public function providerTestApplyTo() {
     $data = [];
 
     $empty_metadata = new BubbleableMetadata();
-    $nonempty_metadata = new BubbleableMetadata(['foo:bar'], ['settings' => ['foo' => 'bar']]);
+    $nonempty_metadata = new BubbleableMetadata(['qux'], ['foo:bar'], ['settings' => ['foo' => 'bar']]);
 
     $empty_render_array = [];
     $nonempty_render_array = [
       '#cache' => [
+        'contexts' => ['qux'],
         'tags' => ['llamas:are:awesome:but:kittens:too'],
       ],
       '#attached' => [
@@ -53,7 +54,8 @@ public function providerTestApplyTo() {
 
     $expected_when_empty_metadata = [
       '#cache' => [
-        'tags' => []
+        'contexts' => [],
+        'tags' => [],
       ],
       '#attached' => [],
       '#post_render_cache' => [],
@@ -61,7 +63,10 @@ public function providerTestApplyTo() {
     $data[] = [$empty_metadata, $empty_render_array, $expected_when_empty_metadata];
     $data[] = [$empty_metadata, $nonempty_render_array, $expected_when_empty_metadata];
     $expected_when_nonempty_metadata = [
-      '#cache' => ['tags' => ['foo:bar']],
+      '#cache' => [
+        'contexts' => ['qux'],
+        'tags' => ['foo:bar'],
+      ],
       '#attached' => [
         'settings' => [
           'foo' => 'bar',
@@ -92,11 +97,12 @@ public function providerTestCreateFromRenderArray() {
     $data = [];
 
     $empty_metadata = new BubbleableMetadata();
-    $nonempty_metadata = new BubbleableMetadata(['foo:bar'], ['settings' => ['foo' => 'bar']]);
+    $nonempty_metadata = new BubbleableMetadata(['qux'], ['foo:bar'], ['settings' => ['foo' => 'bar']]);
 
     $empty_render_array = [];
     $nonempty_render_array = [
       '#cache' => [
+        'contexts' => ['qux'],
         'tags' => ['foo:bar'],
       ],
       '#attached' => [
diff --git a/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php b/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php
index 34c988c..336a22d 100644
--- a/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php
+++ b/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php
@@ -34,6 +34,9 @@ public function testBubblingWithoutPreRender() {
     $this->elementInfo->expects($this->any())
       ->method('getInfo')
       ->willReturn([]);
+    $this->cacheContexts->expects($this->any())
+      ->method('convertTokensToKeys')
+      ->willReturnArgument(0);
 
     // Create an element with a child and subchild. Each element loads a
     // different library using #attached.
@@ -68,6 +71,321 @@ public function testBubblingWithoutPreRender() {
   }
 
   /**
+   * Tests cache context bubbling in edge cases, because it affects the CID.
+   *
+   * ::testBubblingWithPrerender() already tests the common case.
+   *
+   * @dataProvider providerTestContextBubblingEdgeCases
+   */
+  public function testContextBubblingEdgeCases(array $element, array $expected_top_level_contexts, array $expected_cache_items) {
+    $this->setUpRequest();
+    $this->setupMemoryCache();
+    $this->cacheContexts->expects($this->any())
+      ->method('convertTokensToKeys')
+      ->willReturnArgument(0);
+
+    $this->renderer->render($element);
+
+    $this->assertEquals($expected_top_level_contexts, $element['#cache']['contexts'], 'Expected cache contexts found.');
+    foreach ($expected_cache_items as $cid => $expected_cache_item) {
+      $this->assertRenderCacheItem($cid, $expected_cache_item);
+    }
+  }
+
+  public function providerTestContextBubblingEdgeCases() {
+    $data = [];
+
+    // Bubbled cache contexts cannot override a cache ID set by #cache['cid'].
+    // But the cache context is bubbled nevertheless.
+    $test_element = [
+      '#cache' => [
+        'cid' => 'parent',
+      ],
+      '#markup' => 'parent',
+      'child' => [
+        '#cache' => [
+          'contexts' => ['foo'],
+        ],
+      ],
+    ];
+    $expected_cache_items = [
+      'parent' => [
+        '#attached' => [],
+        '#cache' => [
+          'contexts' => ['foo'],
+          'tags' => ['rendered'],
+        ],
+        '#post_render_cache' => [],
+        '#markup' => 'parent',
+      ],
+    ];
+    $data[] = [$test_element, ['foo'], $expected_cache_items];
+
+    // Cache contexts of inaccessible children aren't bubbled (because those
+    // children are not rendered at all).
+    $test_element = [
+      '#cache' => [
+        'keys' => ['parent'],
+        'contexts' => [],
+      ],
+      '#markup' => 'parent',
+      'child' => [
+        '#access' => FALSE,
+        '#cache' => [
+          'contexts' => ['foo'],
+        ],
+      ],
+    ];
+    $expected_cache_items = [
+      'parent' => [
+        '#attached' => [],
+        '#cache' => [
+          'contexts' => [],
+          'tags' => ['rendered'],
+        ],
+        '#post_render_cache' => [],
+        '#markup' => 'parent',
+      ],
+    ];
+    $data[] = [$test_element, [], $expected_cache_items];
+
+    // Assert cache contexts are sorted when they are used to generate a CID.
+    // (Necessary to ensure that different render arrays where the same keys +
+    // set of contexts are present point to the same cache item. Regardless of
+    // the contexts' order. A sad necessity because PHP doesn't have sets.)
+    $test_element = [
+     '#cache' => [
+        'keys' => ['set_test'],
+        'contexts' => [],
+      ],
+    ];
+    $expected_cache_items = [
+      'set_test:bar:baz:foo' => [
+        '#attached' => [],
+        '#cache' => [
+          'contexts' => [],
+          'tags' => ['rendered'],
+        ],
+        '#post_render_cache' => [],
+        '#markup' => '',
+      ],
+    ];
+    $context_orders = [
+      ['foo', 'bar', 'baz'],
+      ['foo', 'baz', 'bar'],
+      ['bar', 'foo', 'baz'],
+      ['bar', 'baz', 'foo'],
+      ['baz', 'foo', 'bar'],
+      ['baz', 'bar', 'foo'],
+    ];
+    foreach ($context_orders as $context_order) {
+      $test_element['#cache']['contexts'] = $context_order;
+      sort($context_order);
+      $expected_cache_items['set_test:bar:baz:foo']['#cache']['contexts'] = $context_order;
+      $data[] = [$test_element, $context_order, $expected_cache_items];
+    }
+
+    // A parent with a certain set of cache contexts is unaffected by a child
+    // that has a subset of those contexts.
+    $test_element = [
+      '#cache' => [
+        'keys' => ['parent'],
+        'contexts' => ['foo', 'bar', 'baz'],
+      ],
+      '#markup' => 'parent',
+      'child' => [
+        '#cache' => [
+          'contexts' => ['foo', 'baz'],
+        ],
+      ],
+    ];
+    $expected_cache_items = [
+      'parent:bar:baz:foo' => [
+        '#attached' => [],
+        '#cache' => [
+          'contexts' => ['bar', 'baz', 'foo'],
+          'tags' => ['rendered'],
+        ],
+        '#post_render_cache' => [],
+        '#markup' => 'parent',
+      ],
+    ];
+    $data[] = [$test_element, ['bar', 'baz', 'foo'], $expected_cache_items];
+
+    // A parent with a certain set of cache contexts that is a subset of the
+    // cache contexts of a child gets a redirecting cache item for the cache ID
+    // created pre-bubbling (without the child's additional cache contexts). It
+    // points to a cache item with a post-bubbling cache ID (i.e. with the
+    // child's additional cache contexts).
+    // Furthermore, the redirecting cache item also includes the children's
+    // cache tags, since changes in the children may cause those children to get
+    // different cache contexts and therefore cause different cache contexts to
+    // be stored in the redirecting cache item.
+    $test_element = [
+      '#cache' => [
+        'keys' => ['parent'],
+        'contexts' => ['foo'],
+        'tags' => ['yar', 'har'],
+      ],
+      '#markup' => 'parent',
+      'child' => [
+        '#cache' => [
+          'contexts' => ['bar'],
+          'tags' => ['fiddle', 'dee'],
+        ],
+        '#markup' => '',
+      ],
+    ];
+    $expected_cache_items = [
+      'parent:foo' => [
+        '#cache_redirect' => TRUE,
+        '#cache' => [
+          // The keys + contexts this redirects to.
+          'keys' => ['parent'],
+          'contexts' => ['bar', 'foo'],
+          // The 'rendered' cache tag is also present for the redirecting cache
+          // item, to ensure it is considered to be part of the render cache
+          // and thus invalidated along with everything else.
+          'tags' => ['dee', 'fiddle', 'har', 'rendered', 'yar'],
+        ],
+      ],
+      'parent:bar:foo' => [
+        '#attached' => [],
+        '#cache' => [
+          'contexts' => ['bar', 'foo'],
+          'tags' => ['dee', 'fiddle', 'har', 'yar', 'rendered'],
+        ],
+        '#post_render_cache' => [],
+        '#markup' => 'parent',
+      ],
+    ];
+    $data[] = [$test_element, ['bar', 'foo'], $expected_cache_items];
+
+    return $data;
+  }
+
+  /**
+   * Tests the self-healing of the redirect with conditional cache contexts.
+   */
+  public function testConditionalCacheContextBubblingSelfHealing() {
+    global $foo_cache_context;
+    $this->setUpRequest();
+    $this->setupMemoryCache();
+    $this->cacheContexts->expects($this->any())
+      ->method('convertTokensToKeys')
+      ->willReturnCallback(function($context_tokens) use ($foo_cache_context) {
+        global $foo_cache_context;
+        $keys = [];
+        foreach ($context_tokens as $context_id) {
+          if ($context_id === 'foo') {
+            $keys[] = $context_id . '.' . ($foo_cache_context ? '1' : '0');
+          }
+          else {
+            $keys[] = $context_id;
+          }
+        }
+        return $keys;
+      });
+
+    $test_element = [
+      '#cache' => [
+        'keys' => ['parent'],
+        'tags' => ['a'],
+      ],
+      '#markup' => 'parent',
+      'child' => [
+        '#cache' => [
+          'contexts' => ['foo'],
+          'tags' => ['b'],
+        ],
+        'grandchild' => [
+          '#cache' => [
+            'contexts' => ['bar'],
+            'tags' => ['c'],
+          ],
+        ],
+      ],
+    ];
+
+    // Request 1: grandchild is inaccessible => bubbled cache contexts: foo.
+    $element = $test_element;
+    $foo_cache_context = FALSE;
+    $element['child']['grandchild']['#access'] = FALSE;
+    $this->renderer->render($element);
+    $this->assertRenderCacheItem('parent', [
+      '#cache_redirect' => TRUE,
+      '#cache' => [
+        'keys' => ['parent'],
+        'contexts' => ['foo'],
+        'tags' => ['a', 'b', 'rendered'],
+      ],
+    ]);
+    $this->assertRenderCacheItem('parent:foo.0', [
+      '#attached' => [],
+      '#cache' => [
+        'contexts' => ['foo'],
+        'tags' => ['a', 'b', 'rendered'],
+      ],
+      '#post_render_cache' => [],
+      '#markup' => 'parent',
+    ]);
+
+    // Request 2: grandchild is accessible => bubbled cache contexts: foo, bar.
+    $element = $test_element;
+    $foo_cache_context = TRUE;
+    $this->renderer->render($element);
+    $this->assertRenderCacheItem('parent', [
+      '#cache_redirect' => TRUE,
+      '#cache' => [
+        'keys' => ['parent'],
+        'contexts' => ['bar', 'foo'],
+        'tags' => ['a', 'b', 'c', 'rendered'],
+      ],
+    ]);
+    $this->assertRenderCacheItem('parent:bar:foo.1', [
+      '#attached' => [],
+      '#cache' => [
+        'contexts' => ['bar', 'foo'],
+        'tags' => ['a', 'b', 'c', 'rendered'],
+      ],
+      '#post_render_cache' => [],
+      '#markup' => 'parent',
+    ]);
+
+    // Request 3: grandchild is inaccessible again => bubbled cache contexts:
+    // foo; but that's a subset of the already-bubbled cache contexts, so
+    // nothing is actually changed in the redirecting cache item. However, the
+    // cache item we were looking for in request 1 is technically the same one
+    // we're looking for now (it's the exact same request), but with one
+    // additional cache context. This is necessary to avoid "cache ping-pong".
+    // (Requests 1 and 3 are identical, but without the right merging logic to
+    // handle request 2, the redirecting cache item would toggle between only
+    // the 'foo' cache context and only the 'bar' cache context, resulting in
+    // cache miss every time.)
+    $element = $test_element;
+    $foo_cache_context = FALSE;
+    $element['child']['grandchild']['#access'] = FALSE;
+    $this->renderer->render($element);
+    $this->assertRenderCacheItem('parent', [
+      '#cache_redirect' => TRUE,
+      '#cache' => [
+        'keys' => ['parent'],
+        'contexts' => ['bar', 'foo'],
+        'tags' => ['a', 'b', 'c', 'rendered'],
+      ],
+    ]);
+    $this->assertRenderCacheItem('parent:bar:foo.0', [
+      '#attached' => [],
+      '#cache' => [
+        'contexts' => ['bar', 'foo'],
+        'tags' => ['a', 'b', 'rendered'],
+      ],
+      '#post_render_cache' => [],
+      '#markup' => 'parent',
+    ]);
+  }
+
+  /**
    * Tests bubbling of bubbleable metadata added by #pre_render callbacks.
    *
    * @dataProvider providerTestBubblingWithPrerender
@@ -94,13 +412,14 @@ public function testBubblingWithPrerender($test_element) {
     // - … is not cached DOES get called.
     \Drupal::state()->set('bubbling_nested_pre_render_cached', FALSE);
     \Drupal::state()->set('bubbling_nested_pre_render_uncached', FALSE);
-    $this->memoryCache->set('cached_nested', ['#markup' => 'Cached nested!', '#attached' => [], '#cache' => ['tags' => []], '#post_render_cache' => []]);
+    $this->memoryCache->set('cached_nested', ['#markup' => 'Cached nested!', '#attached' => [], '#cache' => ['contexts' => [], 'tags' => []], '#post_render_cache' => []]);
 
     // Simulate the rendering of an entire response (i.e. a root call).
     $output = $this->renderer->renderRoot($test_element);
 
     // First, assert the render array is of the expected form.
-    $this->assertEquals('Cache tag!Asset!Post-render cache!barquxNested!Cached nested!', trim($output), 'Expected HTML generated.');
+    $this->assertEquals('Cache context!Cache tag!Asset!Post-render cache!barquxNested!Cached nested!', trim($output), 'Expected HTML generated.');
+    $this->assertEquals(['child.cache_context'], $test_element['#cache']['contexts'], 'Expected cache contexts found.');
     $this->assertEquals(['child:cache_tag'], $test_element['#cache']['tags'], 'Expected cache tags found.');
     $expected_attached = [
       'drupalSettings' => ['foo' => 'bar'],
@@ -155,6 +474,12 @@ public static function bubblingPreRender($elements) {
     ];
     $placeholder = \Drupal::service('renderer')->generateCachePlaceholder($callback, $context);
     $elements += [
+      'child_cache_context' => [
+        '#cache' => [
+          'contexts' => ['child.cache_context'],
+        ],
+        '#markup' => 'Cache context!',
+      ],
       'child_cache_tag' => [
         '#cache' => [
           'tags' => ['child:cache_tag'],
diff --git a/core/tests/Drupal/Tests/Core/Render/RendererPostRenderCacheTest.php b/core/tests/Drupal/Tests/Core/Render/RendererPostRenderCacheTest.php
index cb7ccc3..46a49be 100644
--- a/core/tests/Drupal/Tests/Core/Render/RendererPostRenderCacheTest.php
+++ b/core/tests/Drupal/Tests/Core/Render/RendererPostRenderCacheTest.php
@@ -89,7 +89,10 @@ public function testPostRenderCacheWithColdCache() {
       '#markup' => '<p>#cache enabled, GET</p>',
       '#attached' => $test_element['#attached'],
       '#post_render_cache' => $test_element['#post_render_cache'],
-      '#cache' => ['tags' => ['rendered']],
+      '#cache' => [
+        'contexts' => [],
+        '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.');
 
@@ -222,7 +225,10 @@ public function testRenderChildrenPostRenderCacheDifferentContexts() {
           $context_3,
         ]
       ],
-      '#cache' => ['tags' => ['rendered']],
+      '#cache' => [
+        'contexts' => [],
+        'tags' => ['rendered'],
+      ],
     ];
 
     $dom = Html::load($cached_element['#markup']);
@@ -314,7 +320,10 @@ public function testRenderChildrenPostRenderCacheComplex() {
           $context_3,
         ]
       ],
-      '#cache' => ['tags' => ['rendered']],
+      '#cache' => [
+        'contexts' => [],
+        'tags' => ['rendered'],
+      ],
     ];
 
     $dom = Html::load($cached_parent_element['#markup']);
@@ -337,7 +346,10 @@ public function testRenderChildrenPostRenderCacheComplex() {
           $context_3,
         ]
       ],
-      '#cache' => ['tags' => ['rendered']],
+      '#cache' => [
+        'contexts' => [],
+        'tags' => ['rendered'],
+      ],
     ];
 
     $dom = Html::load($cached_child_element['#markup']);
@@ -448,7 +460,10 @@ public function testPlaceholder() {
           $context
         ],
       ],
-      '#cache' => ['tags' => ['rendered']],
+      '#cache' => [
+        'contexts' => [],
+        '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.');
 
@@ -543,7 +558,10 @@ public function testChildElementPlaceholder() {
           $context,
         ],
       ],
-      '#cache' => ['tags' => ['rendered']],
+      '#cache' => [
+        'contexts' => [],
+        'tags' => ['rendered'],
+      ],
     ];
     $this->assertSame($cached_element, $expected_element, 'The correct data is cached for the child element: the stored #markup and #attached properties are not affected by #post_render_cache callbacks.');
 
@@ -568,7 +586,10 @@ public function testChildElementPlaceholder() {
           $context,
         ],
       ],
-      '#cache' => ['tags' => ['rendered']],
+      '#cache' => [
+        'contexts' => [],
+        'tags' => ['rendered'],
+      ],
     ];
     $this->assertSame($cached_element, $expected_element, 'The correct data is cached for the parent element: the stored #markup and #attached properties are not affected by #post_render_cache callbacks.');
 
@@ -596,7 +617,10 @@ public function testChildElementPlaceholder() {
           $context,
         ],
       ],
-      '#cache' => ['tags' => ['rendered']],
+      '#cache' => [
+        'contexts' => [],
+        'tags' => ['rendered'],
+      ],
     ];
     $this->assertSame($cached_element, $expected_element, 'The correct data is cached for the child element: the stored #markup and #attached properties are not affected by #post_render_cache callbacks.');
 
diff --git a/core/tests/Drupal/Tests/Core/Render/RendererTest.php b/core/tests/Drupal/Tests/Core/Render/RendererTest.php
index 2f259b6..455834e 100644
--- a/core/tests/Drupal/Tests/Core/Render/RendererTest.php
+++ b/core/tests/Drupal/Tests/Core/Render/RendererTest.php
@@ -17,7 +17,10 @@
 class RendererTest extends RendererTestBase {
 
   protected $defaultThemeVars = [
-    '#cache' => ['tags' => []],
+    '#cache' => [
+      'contexts' => [],
+      'tags' => [],
+    ],
     '#attached' => [],
     '#post_render_cache' => [],
     '#children' => '',
diff --git a/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php b/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php
index f4a051a..cf08554 100644
--- a/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php
+++ b/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php
@@ -139,6 +139,22 @@ protected function setUpRequest($method = 'GET') {
     $this->requestStack->push($request);
   }
 
+  /**
+   * Asserts a render cache item.
+   *
+   * @param string $cid
+   *   The expected cache ID.
+   * @param mixed $data
+   *   The expected data for that cache ID.
+   */
+  protected function assertRenderCacheItem($cid, $data) {
+    $cached = $this->memoryCache->get($cid);
+    $this->assertNotFalse($cached, sprintf('Expected cache item "%s" exists.', $cid));
+    if ($cached !== FALSE) {
+      $this->assertEquals($data, $cached->data, sprintf('Cache item "%s" has the expected data.', $cid));
+    }
+  }
+
 }
 
 
