 core/lib/Drupal/Core/Cache/Cache.php               | 20 ++++++++++
 core/lib/Drupal/Core/Entity/EntityViewBuilder.php  |  3 --
 .../EntityReferenceLabelFormatter.php              | 12 +++++-
 .../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           | 32 +++++++++++++++-
 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 +++++++++++++++++
 .../Plugin/Field/FieldFormatter/LinkFormatter.php  |  6 +++
 .../Plugin/Field/FieldFormatter/PlainFormatter.php |  3 ++
 .../Field/FieldFormatter/TaxonomyFormatterBase.php | 18 ++++++++-
 .../Field/FieldFormatter/TextDefaultFormatter.php  |  2 +
 .../Field/FieldFormatter/TextTrimmedFormatter.php  |  2 +
 .../Tests/Core/Render/BubbleableMetadataTest.php   | 14 +++++--
 20 files changed, 262 insertions(+), 20 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/EntityReferenceLabelFormatter.php b/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceLabelFormatter.php
index dbc8958..0651bbc 100644
--- a/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceLabelFormatter.php
+++ b/core/lib/Drupal/Core/Field/Plugin/Field/FieldFormatter/EntityReferenceLabelFormatter.php
@@ -64,7 +64,16 @@ public function viewElements(FieldItemListInterface $items) {
     $elements = array();
     $output_as_link = $this->getSetting('link');
 
-    foreach ($this->getEntitiesToView($items) as $delta => $entity) {
+    $entities = $this->getEntitiesToView($items);
+
+    // Cache contexts: if one entity is translatable, then they all are.
+    $cache_contexts = [];
+    $first_entity = reset($entities);
+    if (count($first_entity->getTranslationLanguages()) > 1) {
+      $cache_contexts[] = 'language';
+    }
+
+    foreach ($entities as $delta => $entity) {
       $label = $entity->label();
       // If the link is to be displayed and the entity has a uri, display a
       // link.
@@ -100,6 +109,7 @@ public function viewElements(FieldItemListInterface $items) {
       else {
         $elements[$delta] = array('#markup' => String::checkPlain($label));
       }
+      $elements[$delta]['#cache']['contexts'] = $cache_contexts;
       $elements[$delta]['#cache']['tags'] = $entity->getCacheTags();
     }
 
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 c56b421..c01edf4 100644
--- a/core/lib/Drupal/Core/Render/BubbleableMetadata.php
+++ b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
@@ -25,6 +25,13 @@ class BubbleableMetadata {
   protected $tags;
 
   /**
+   * Cache contexts.
+   *
+   * @var string[]
+   */
+  protected $contexts;
+
+  /**
    * Attached assets.
    *
    * @var string[][]
@@ -43,13 +50,16 @@ class BubbleableMetadata {
    *
    * @param string[] $tags
    *   An array of cache tags.
+   * @param string[] $contexts
+   *   An array of cache contexts.
    * @param array $attached
    *   An array of attached assets.
    * @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 $tags = [], array $contexts = [], array $attached = [], array $post_render_cache = []) {
     $this->tags = $tags;
+    $this->contexts = $contexts;
     $this->attached = $attached;
     $this->postRenderCache = $post_render_cache;
   }
@@ -70,6 +80,7 @@ public function __construct(array $tags = [], array $attached = [], array $post_
   public function merge(BubbleableMetadata $other) {
     $result = new BubbleableMetadata();
     $result->tags = Cache::mergeTags($this->tags, $other->tags);
+    $result->contexts = array_unique(array_merge($this->contexts, $other->contexts));
     $result->attached = drupal_merge_attached($this->attached, $other->attached);
     $result->postRenderCache = NestedArray::mergeDeep($this->postRenderCache, $other->postRenderCache);
     return $result;
@@ -83,6 +94,7 @@ public function merge(BubbleableMetadata $other) {
    */
   public function applyTo(array &$build) {
     $build['#cache']['tags'] = $this->tags;
+    $build['#cache']['contexts'] = $this->contexts;
     $build['#attached'] = $this->attached;
     $build['#post_render_cache'] = $this->postRenderCache;
   }
@@ -98,6 +110,7 @@ public function applyTo(array &$build) {
   public static function createFromRenderArray(array $build) {
     $meta = new static();
     $meta->tags = (isset($build['#cache']['tags'])) ? $build['#cache']['tags'] : [];
+    $meta->contexts = (isset($build['#cache']['contexts'])) ? $build['#cache']['contexts'] : [];
     $meta->attached = (isset($build['#attached'])) ? $build['#attached'] : [];
     $meta->postRenderCache = (isset($build['#post_render_cache'])) ? $build['#post_render_cache'] : [];
     return $meta;
diff --git a/core/lib/Drupal/Core/Render/Renderer.php b/core/lib/Drupal/Core/Render/Renderer.php
index e0253b6..148e0f8 100644
--- a/core/lib/Drupal/Core/Render/Renderer.php
+++ b/core/lib/Drupal/Core/Render/Renderer.php
@@ -184,6 +184,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)) {
+          $elements['#cid_pre_bubbling'] = $cid;
+        }
+      }
     }
 
     // If the default values for this element have not been loaded yet, populate
@@ -205,6 +214,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();
@@ -493,6 +503,12 @@ protected function cacheGet(array $elements) {
 
     if (!empty($cid) && $cache = $this->cacheFactory->get($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;
     }
@@ -530,7 +546,20 @@ 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']);
+    $tags = $data['#cache']['tags'];
+    $cache = $this->cacheFactory->get($bin);
+
+    // Two-tier caching: detect different CID post-bubbling, create redirect.
+    // @see ::doRender()
+    // @see ::cacheGet()
+    if (isset($elements['#cid_pre_bubbling']) && $elements['#cid_pre_bubbling'] !== $cid) {
+      $redirect_data = [
+        '#cache_redirect' => TRUE,
+        '#cache' => $elements['#cache'],
+      ];
+      $cache->set($elements['#cid_pre_bubbling'], $redirect_data, $expire, $tags);
+    }
+    $cache->set($cid, $data, $expire, $tags);
   }
 
   /**
@@ -570,6 +599,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 06f4968..0010b86 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..251dce0 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.
+      $output['comments']['#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.
+        $output['comments']['#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..cbed27e 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 array
+   */
+  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 array
+   */
+  public function getCacheContexts() {
+    return $this->cacheContexts;
+  }
+
+  /**
+   * Adds cache contexts associated with the processed text.
+   *
+   * @param array $cache_contexts
+   *   The cache contexts to be added.
+   *
+   * @return $this
+   */
+  public function addCacheContexts(array $cache_contexts) {
+    $this->cacheContexts = array_unique(array_merge($this->cacheTags, $cache_contexts));
+    return $this;
+  }
+
+  /**
+   * Sets cache contexts associated with the processed text.
+   *
+   * @param array $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
@@ -257,6 +300,7 @@ public function setPostRenderCacheCallbacks(array $post_render_cache_callbacks)
   public function getBubbleableMetadata() {
     return new BubbleableMetadata(
       $this->getCacheTags(),
+      $this->getCacheContexts(),
       $this->getAssets(),
       $this->getPostRenderCacheCallbacks()
     );
diff --git a/core/modules/filter/src/Tests/FilterAPITest.php b/core/modules/filter/src/Tests/FilterAPITest.php
index 50210b5..e139da9 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/taxonomy/src/Plugin/Field/FieldFormatter/LinkFormatter.php b/core/modules/taxonomy/src/Plugin/Field/FieldFormatter/LinkFormatter.php
index d94e2bf..847e1fd 100644
--- a/core/modules/taxonomy/src/Plugin/Field/FieldFormatter/LinkFormatter.php
+++ b/core/modules/taxonomy/src/Plugin/Field/FieldFormatter/LinkFormatter.php
@@ -34,6 +34,9 @@ public function viewElements(FieldItemListInterface $items) {
     foreach ($items as $delta => $item) {
       if ($item->hasNewEntity()) {
         $elements[$delta] = array(
+          '#cache' => [
+            'contexts' => $item->_cacheContexts,
+          ],
           '#markup' => String::checkPlain($item->entity->label()),
         );
       }
@@ -41,6 +44,9 @@ public function viewElements(FieldItemListInterface $items) {
         /** @var $term \Drupal\taxonomy\TermInterface */
         $term = $item->entity;
         $elements[$delta] = array(
+          '#cache' => [
+            'contexts' => $item->_cacheContexts,
+          ],
           '#type' => 'link',
           '#title' => $term->getName(),
           '#url' => $term->urlInfo(),
diff --git a/core/modules/taxonomy/src/Plugin/Field/FieldFormatter/PlainFormatter.php b/core/modules/taxonomy/src/Plugin/Field/FieldFormatter/PlainFormatter.php
index 1bf4ab4..0161791 100644
--- a/core/modules/taxonomy/src/Plugin/Field/FieldFormatter/PlainFormatter.php
+++ b/core/modules/taxonomy/src/Plugin/Field/FieldFormatter/PlainFormatter.php
@@ -31,6 +31,9 @@ public function viewElements(FieldItemListInterface $items) {
 
     foreach ($items as $delta => $item) {
       $elements[$delta] = array(
+        '#cache' => [
+          'contexts' => $item->_cacheContexts,
+        ],
         '#markup' => String::checkPlain($item->entity->label()),
       );
     }
diff --git a/core/modules/taxonomy/src/Plugin/Field/FieldFormatter/TaxonomyFormatterBase.php b/core/modules/taxonomy/src/Plugin/Field/FieldFormatter/TaxonomyFormatterBase.php
index 6109674..43273b1 100644
--- a/core/modules/taxonomy/src/Plugin/Field/FieldFormatter/TaxonomyFormatterBase.php
+++ b/core/modules/taxonomy/src/Plugin/Field/FieldFormatter/TaxonomyFormatterBase.php
@@ -7,6 +7,8 @@
 
 namespace Drupal\taxonomy\Plugin\Field\FieldFormatter;
 
+use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheableInterface;
 use Drupal\Core\Field\FormatterBase;
 
 /**
@@ -21,7 +23,8 @@
    * unsets values for invalid terms that do not exist.
    */
   public function prepareView(array $entities_items) {
-    $terms = array();
+    $terms = [];
+    $cache_contexts_per_term = [];
 
     // Collect every possible term attached to any of the fieldable entities.
     /* @var \Drupal\Core\Field\EntityReferenceFieldItemList $items */
@@ -31,12 +34,21 @@ public function prepareView(array $entities_items) {
       $active_langcode = $parent->language()->getId();
       /* @var \Drupal\taxonomy\Entity\Term $term */
       foreach ($items->referencedEntities() as $term) {
+        $cache_contexts = [];
+        if (count($term->getTranslationLanguages()) > 1) {
+          $cache_contexts[] = 'language';
+        }
         if ($term->hasTranslation($active_langcode)) {
           $translated_term = $term->getTranslation($active_langcode);
-          if ($translated_term->access('view')) {
+          $access_result = $translated_term->access('view', NULL, TRUE);
+          if ($access_result instanceof CacheableInterface) {
+            $cache_contexts = Cache::mergeContexts($cache_contexts, $access_result->getCacheContexts());
+          }
+          if ($access_result->isAllowed()) {
             $term = $translated_term;
           }
         }
+        $cache_contexts_per_term[$term->id()] = $cache_contexts;
         if (!$term->isNew()) {
           $terms[$term->id()] = $term;
         }
@@ -53,10 +65,12 @@ public function prepareView(array $entities_items) {
           if (isset($terms[$item->target_id])) {
             // Replace the instance value with the term data.
             $item->entity = $terms[$item->target_id];
+            $item->_cacheContexts = $cache_contexts_per_term[$item->target_id];
           }
           // Terms to be created are not in $terms, but are still legitimate.
           elseif ($item->hasNewEntity()) {
             // Leave the item in place.
+            $item->_cacheContexts = $cache_contexts_per_term[$item->target_id];
           }
           // Otherwise, unset the instance value, since the term does not exist.
           else {
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..23168d3 100644
--- a/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php
+++ b/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php
@@ -35,12 +35,13 @@ public function providerTestApplyTo() {
     $data = [];
 
     $empty_metadata = new BubbleableMetadata();
-    $nonempty_metadata = new BubbleableMetadata(['foo:bar'], ['settings' => ['foo' => 'bar']]);
+    $nonempty_metadata = new BubbleableMetadata(['foo:bar'], [], ['settings' => ['foo' => 'bar']]);
 
     $empty_render_array = [];
     $nonempty_render_array = [
       '#cache' => [
         'tags' => ['llamas:are:awesome:but:kittens:too'],
+        'contexts' => [],
       ],
       '#attached' => [
         'library' => [
@@ -53,7 +54,8 @@ public function providerTestApplyTo() {
 
     $expected_when_empty_metadata = [
       '#cache' => [
-        'tags' => []
+        'tags' => [],
+        'contexts' => [],
       ],
       '#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' => [
+        'tags' => ['foo:bar'],
+        'contexts' => [],
+      ],
       '#attached' => [
         'settings' => [
           'foo' => 'bar',
@@ -92,12 +97,13 @@ public function providerTestCreateFromRenderArray() {
     $data = [];
 
     $empty_metadata = new BubbleableMetadata();
-    $nonempty_metadata = new BubbleableMetadata(['foo:bar'], ['settings' => ['foo' => 'bar']]);
+    $nonempty_metadata = new BubbleableMetadata(['foo:bar'], [], ['settings' => ['foo' => 'bar']]);
 
     $empty_render_array = [];
     $nonempty_render_array = [
       '#cache' => [
         'tags' => ['foo:bar'],
+        'contexts' => [],
       ],
       '#attached' => [
         'settings' => [
