 core/lib/Drupal/Core/Render/BubbleableMetadata.php | 15 +++++++-
 core/lib/Drupal/Core/Render/Renderer.php           |  2 +
 core/modules/filter/src/FilterProcessResult.php    | 44 ++++++++++++++++++++++
 core/modules/filter/src/Tests/FilterAPITest.php    |  9 +++++
 .../src/Plugin/Filter/FilterTestCacheContexts.php  | 35 +++++++++++++++++
 .../Tests/Core/Render/BubbleableMetadataTest.php   | 14 +++++--
 6 files changed, 114 insertions(+), 5 deletions(-)

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..7d4dcce 100644
--- a/core/lib/Drupal/Core/Render/Renderer.php
+++ b/core/lib/Drupal/Core/Render/Renderer.php
@@ -205,6 +205,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();
@@ -570,6 +571,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/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/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' => [
