From 339643bebb00964b581567f6f5518b8c8682ecf4 Mon Sep 17 00:00:00 2001
From: catch <catch@35733.no-reply.drupal.org>
Date: Wed, 7 Jun 2023 16:56:20 +0100
Subject: [PATCH] Issue #2551419 by kristiaanvandeneynde, catch, dawehner, Wim
 Leers, borisson_, bradjones1, tstoeckler, andypost: Abstract RenderCache into
 a separate service that is capable of cache redirects in a non-render
 array-specific way

---
 core/core.services.yml                        |   8 +-
 core/lib/Drupal/Core/Cache/CacheRedirect.php  |  31 ++
 core/lib/Drupal/Core/Cache/VariationCache.php | 250 +++++++++
 .../Core/Cache/VariationCacheFactory.php      |  48 ++
 .../Cache/VariationCacheFactoryInterface.php  |  21 +
 .../Core/Cache/VariationCacheInterface.php    |  85 +++
 .../Core/Render/PlaceholderGenerator.php      |  26 +-
 .../Core/Render/PlaceholderingRenderCache.php |  10 +-
 core/lib/Drupal/Core/Render/RenderCache.php   | 295 ++--------
 .../tests/src/Kernel/BlockViewBuilderTest.php |  26 +-
 .../Functional/BlockContentCacheTagsTest.php  |   8 +-
 .../dynamic_page_cache.services.yml           |   6 +-
 .../DynamicPageCacheSubscriber.php            | 136 ++---
 .../tests/src/Functional/ResourceTestBase.php |  11 +-
 .../EntityResource/EntityResourceTestBase.php |   6 +-
 .../src/Functional/ShortcutCacheTagsTest.php  |  15 +-
 .../Entity/EntityCacheTagsTestBase.php        |  62 +--
 .../Entity/EntityWithUriCacheTagsTestBase.php |   9 +-
 .../tests/src/Functional/TrackerTest.php      |  12 -
 .../tests/src/Functional/UserBlocksTest.php   |   5 +
 .../Functional/WorkspaceCacheContextTest.php  |  21 +-
 core/phpstan-baseline.neon                    |   5 -
 .../Tests/Core/Cache/VariationCacheTest.php   | 506 ++++++++++++++++++
 .../Core/Render/RendererBubblingTest.php      | 229 +++-----
 .../Core/Render/RendererPlaceholdersTest.php  |  64 +--
 .../Drupal/Tests/Core/Render/RendererTest.php |  10 +-
 .../Tests/Core/Render/RendererTestBase.php    |  33 +-
 27 files changed, 1271 insertions(+), 667 deletions(-)
 create mode 100644 core/lib/Drupal/Core/Cache/CacheRedirect.php
 create mode 100644 core/lib/Drupal/Core/Cache/VariationCache.php
 create mode 100644 core/lib/Drupal/Core/Cache/VariationCacheFactory.php
 create mode 100644 core/lib/Drupal/Core/Cache/VariationCacheFactoryInterface.php
 create mode 100644 core/lib/Drupal/Core/Cache/VariationCacheInterface.php
 create mode 100644 core/tests/Drupal/Tests/Core/Cache/VariationCacheTest.php

diff --git a/core/core.services.yml b/core/core.services.yml
index 0817e41cd5ac..3243691b26a7 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -194,6 +194,10 @@ services:
     calls:
       - [setContainer, ['@service_container']]
   Drupal\Core\Cache\CacheFactoryInterface: '@cache_factory'
+  variation_cache_factory:
+    class: Drupal\Core\Cache\VariationCacheFactory
+    arguments: ['@request_stack', '@cache_factory', '@cache_contexts_manager']
+  Drupal\Core\Cache\VariationCacheFactory: '@variation_cache_factory'
   cache_contexts_manager:
     class: Drupal\Core\Cache\Context\CacheContextsManager
     arguments: ['@service_container', '%cache_contexts%' ]
@@ -1780,11 +1784,11 @@ services:
   #   https://www.drupal.org/node/2367555 lands.
   render_placeholder_generator:
     class: Drupal\Core\Render\PlaceholderGenerator
-    arguments: ['%renderer.config%']
+    arguments: ['@cache_contexts_manager', '%renderer.config%']
   Drupal\Core\Render\PlaceholderGeneratorInterface: '@render_placeholder_generator'
   render_cache:
     class: Drupal\Core\Render\PlaceholderingRenderCache
-    arguments: ['@request_stack', '@cache_factory', '@cache_contexts_manager', '@render_placeholder_generator']
+    arguments: ['@request_stack', '@variation_cache_factory', '@cache_contexts_manager', '@render_placeholder_generator']
   Drupal\Core\Render\RenderCacheInterface: '@render_cache'
   renderer:
     class: Drupal\Core\Render\Renderer
diff --git a/core/lib/Drupal/Core/Cache/CacheRedirect.php b/core/lib/Drupal/Core/Cache/CacheRedirect.php
new file mode 100644
index 000000000000..c39c61bfb899
--- /dev/null
+++ b/core/lib/Drupal/Core/Cache/CacheRedirect.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Drupal\Core\Cache;
+
+/**
+ * Defines a value object to represent a cache redirect.
+ *
+ * @see \Drupal\Core\Cache\VariationCache::get()
+ * @see \Drupal\Core\Cache\VariationCache::set()
+ *
+ * @ingroup cache
+ * @internal
+ */
+class CacheRedirect implements CacheableDependencyInterface {
+
+  use CacheableDependencyTrait;
+
+  /**
+   * Constructs a CacheRedirect object.
+   *
+   * @param \Drupal\Core\Cache\CacheableDependencyInterface $cacheability
+   *   The cacheability to redirect to.
+   *
+   * @see \Drupal\Core\Cache\VariationCache::createCacheIdFast()
+   */
+  public function __construct(CacheableDependencyInterface $cacheability) {
+    // Cache redirects only care about cache contexts.
+    $this->cacheContexts = $cacheability->getCacheContexts();
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Cache/VariationCache.php b/core/lib/Drupal/Core/Cache/VariationCache.php
new file mode 100644
index 000000000000..bec2d3b910e0
--- /dev/null
+++ b/core/lib/Drupal/Core/Cache/VariationCache.php
@@ -0,0 +1,250 @@
+<?php
+
+namespace Drupal\Core\Cache;
+
+use Drupal\Core\Cache\Context\CacheContextsManager;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * Wraps a regular cache backend to make it support cache contexts.
+ *
+ * @ingroup cache
+ */
+class VariationCache implements VariationCacheInterface {
+
+  /**
+   * Constructs a new VariationCache object.
+   *
+   * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack
+   *   The request stack.
+   * @param \Drupal\Core\Cache\CacheBackendInterface $cacheBackend
+   *   The cache backend to wrap.
+   * @param \Drupal\Core\Cache\Context\CacheContextsManager $cacheContextsManager
+   *   The cache contexts manager.
+   */
+  public function __construct(
+    protected RequestStack $requestStack,
+    protected CacheBackendInterface $cacheBackend,
+    protected CacheContextsManager $cacheContextsManager
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function get(array $keys, CacheableDependencyInterface $initial_cacheability) {
+    $chain = $this->getRedirectChain($keys, $initial_cacheability);
+    return array_pop($chain);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function set(array $keys, $data, CacheableDependencyInterface $cacheability, CacheableDependencyInterface $initial_cacheability): void {
+    $initial_contexts = $initial_cacheability->getCacheContexts();
+    $contexts = $cacheability->getCacheContexts();
+
+    if ($missing_contexts = array_diff($initial_contexts, $contexts)) {
+      throw new \LogicException(sprintf('The complete set of cache contexts for a variation cache item must contain all of the initial cache contexts, missing: %s.', implode(', ', $missing_contexts)));
+    }
+
+    // Don't store uncacheable items.
+    if ($cacheability->getCacheMaxAge() === 0) {
+      return;
+    }
+
+    // Track the potential effect of cache context optimization on cache tags.
+    $optimized_cacheability = CacheableMetadata::createFromObject($cacheability);
+    $cid = $this->createCacheId($keys, $optimized_cacheability);
+
+    // Check whether we had any cache redirects leading to the cache ID already.
+    // If there are none, we know that there is no proper redirect path to the
+    // cache ID we're trying to store the data at. This may be because there is
+    // either no full redirect path yet or there is one that is too specific at
+    // a given step of the way. In case of the former, we simply need to store a
+    // redirect. In case of the latter, we need to replace the overly specific
+    // step with a simpler one.
+    $chain = $this->getRedirectChain($keys, $initial_cacheability);
+    if (!array_key_exists($cid, $chain)) {
+      // We can easily find overly specific redirects by comparing their cache
+      // contexts to the ones we have here. If a redirect has more or different
+      // contexts, it needs to be replaced with a simplified version.
+      //
+      // Simplifying overly specific redirects can be done in two ways:
+      //
+      // -------
+      //
+      // Problem: The redirect is a superset of the current cache contexts.
+      // Solution: We replace the redirect with the current contexts.
+      //
+      // Example: Suppose we try to store an object with context A, whereas we
+      // already have a redirect that uses A and B. In this case we simply store
+      // the object at the address designated by context A and next time someone
+      // tries to load the initial AB object, it will restore its redirect path
+      // by adding an AB redirect step after A.
+      //
+      // -------
+      //
+      // Problem: The redirect overlaps, with both options having unique values.
+      // Solution: Find the common contexts and use those for a new redirect.
+      //
+      // Example: Suppose we try to store an object with contexts A and C, but
+      // we already have a redirect that uses A and B. In this case we find A to
+      // be the common cache context and replace the redirect with one only
+      // using A, immediately followed by one for AC so there is a full path to
+      // the data we're trying to set. Next time someone tries to load the
+      // initial AB object, it will restore its redirect path by adding an AB
+      // redirect step after A.
+      foreach ($chain as $chain_cid => $result) {
+        if ($result && $result->data instanceof CacheRedirect) {
+          $result_contexts = $result->data->getCacheContexts();
+          if (array_diff($result_contexts, $contexts)) {
+            // Check whether we have an overlap scenario as we need to manually
+            // create an extra redirect in that case.
+            $common_contexts = array_intersect($result_contexts, $contexts);
+            // != is the most appropriate comparison operator here, since we
+            // only want to know if any keys or values don't match.
+            if ($common_contexts != $contexts) {
+              // Set the redirect to the common contexts at the current address.
+              // In the above example this is essentially overwriting the
+              // redirect to AB with a redirect to A.
+              $common_cacheability = (new CacheableMetadata())->setCacheContexts($common_contexts);
+              $this->cacheBackend->set($chain_cid, new CacheRedirect($common_cacheability));
+
+              // Before breaking the loop, set the current address to the next
+              // one in line so that we can store the full redirect as well. In
+              // the above example, this is the part where we immediately also
+              // store a redirect to AC at the CID that A pointed to.
+              $chain_cid = $this->createCacheIdFast($keys, $common_cacheability);
+            }
+            break;
+          }
+        }
+      }
+
+      // The loop above either broke at an overly specific step or completed
+      // without any problem. In both cases, $chain_cid ended up with the value
+      // that we should store the new redirect at.
+      //
+      // Cache redirects are stored indefinitely and without tags as they never
+      // need to be cleared. If they ever end up leading to a stale cache item
+      // that now uses different contexts then said item will either follow an
+      // existing path of redirects or carve its own over the old one.
+      /** @phpstan-ignore-next-line */
+      $this->cacheBackend->set($chain_cid, new CacheRedirect($cacheability));
+    }
+
+    $this->cacheBackend->set($cid, $data, $this->maxAgeToExpire($cacheability->getCacheMaxAge()), $optimized_cacheability->getCacheTags());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function delete(array $keys, CacheableDependencyInterface $initial_cacheability): void {
+    $chain = $this->getRedirectChain($keys, $initial_cacheability);
+    end($chain);
+    $this->cacheBackend->delete(key($chain));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function invalidate(array $keys, CacheableDependencyInterface $initial_cacheability): void {
+    $chain = $this->getRedirectChain($keys, $initial_cacheability);
+    end($chain);
+    $this->cacheBackend->invalidate(key($chain));
+  }
+
+  /**
+   * Performs a full get, returning every step of the way.
+   *
+   * This will check whether there is a cache redirect and follow it if so. It
+   * will keep following redirects until it gets to a cache miss or the actual
+   * cache object.
+   *
+   * @param string[] $keys
+   *   The cache keys to retrieve the cache entry for.
+   * @param \Drupal\Core\Cache\CacheableDependencyInterface $initial_cacheability
+   *   The cache metadata of the data to store before other systems had a chance
+   *   to adjust it. This is also commonly known as "pre-bubbling" cacheability.
+   *
+   * @return array
+   *   Every cache get that lead to the final result, keyed by the cache ID used
+   *   to query the cache for that result.
+   */
+  protected function getRedirectChain(array $keys, CacheableDependencyInterface $initial_cacheability): array {
+    $cid = $this->createCacheIdFast($keys, $initial_cacheability);
+    $chain[$cid] = $result = $this->cacheBackend->get($cid);
+
+    while ($result && $result->data instanceof CacheRedirect) {
+      $cid = $this->createCacheIdFast($keys, $result->data);
+      $chain[$cid] = $result = $this->cacheBackend->get($cid);
+    }
+
+    return $chain;
+  }
+
+  /**
+   * Maps a max-age value to an "expire" value for the Cache API.
+   *
+   * @param int $max_age
+   *   A max-age value.
+   *
+   * @return int
+   *   A corresponding "expire" value.
+   *
+   * @see \Drupal\Core\Cache\CacheBackendInterface::set()
+   */
+  protected function maxAgeToExpire($max_age) {
+    if ($max_age !== Cache::PERMANENT) {
+      return (int) $this->requestStack->getMainRequest()->server->get('REQUEST_TIME') + $max_age;
+    }
+    return $max_age;
+  }
+
+  /**
+   * Creates a cache ID based on cache keys and cacheable metadata.
+   *
+   * If cache contexts are optimized during the creating of the cache ID, then
+   * the effect of said optimization on the cache contexts will be reflected in
+   * the provided cacheable metadata.
+   *
+   * @param string[] $keys
+   *   The cache keys of the data to store.
+   * @param \Drupal\Core\Cache\CacheableMetadata $cacheable_metadata
+   *   The cacheable metadata of the data to store.
+   *
+   * @return string
+   *   The cache ID.
+   */
+  protected function createCacheId(array $keys, CacheableMetadata &$cacheable_metadata) {
+    if ($contexts = $cacheable_metadata->getCacheContexts()) {
+      $context_cache_keys = $this->cacheContextsManager->convertTokensToKeys($contexts);
+      $keys = array_merge($keys, $context_cache_keys->getKeys());
+      $cacheable_metadata = $cacheable_metadata->merge($context_cache_keys);
+    }
+    return implode(':', $keys);
+  }
+
+  /**
+   * Creates a cache ID based on cache keys and cacheable metadata.
+   *
+   * This is a simpler, faster version of ::createCacheID() to be used when you
+   * do not care about how cache context optimization affects the cache tags.
+   *
+   * @param string[] $keys
+   *   The cache keys of the data to store.
+   * @param \Drupal\Core\Cache\CacheableDependencyInterface $cacheability
+   *   The cache metadata of the data to store.
+   *
+   * @return string
+   *   The cache ID for the redirect.
+   */
+  protected function createCacheIdFast(array $keys, CacheableDependencyInterface $cacheability) {
+    if ($contexts = $cacheability->getCacheContexts()) {
+      $context_cache_keys = $this->cacheContextsManager->convertTokensToKeys($contexts);
+      $keys = array_merge($keys, $context_cache_keys->getKeys());
+    }
+    return implode(':', $keys);
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Cache/VariationCacheFactory.php b/core/lib/Drupal/Core/Cache/VariationCacheFactory.php
new file mode 100644
index 000000000000..d870ed5972f1
--- /dev/null
+++ b/core/lib/Drupal/Core/Cache/VariationCacheFactory.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Drupal\Core\Cache;
+
+use Drupal\Core\Cache\Context\CacheContextsManager;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * Defines the variation cache factory.
+ *
+ * @ingroup cache
+ */
+class VariationCacheFactory implements VariationCacheFactoryInterface {
+
+  /**
+   * Instantiated variation cache bins.
+   *
+   * @var \Drupal\Core\Cache\VariationCacheInterface[]
+   */
+  protected $bins = [];
+
+  /**
+   * Constructs a new VariationCacheFactory object.
+   *
+   * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack
+   *   The request stack.
+   * @param \Drupal\Core\Cache\CacheFactoryInterface $cacheFactory
+   *   The cache factory.
+   * @param \Drupal\Core\Cache\Context\CacheContextsManager $cacheContextsManager
+   *   The cache contexts manager.
+   */
+  public function __construct(
+    protected RequestStack $requestStack,
+    protected CacheFactoryInterface $cacheFactory,
+    protected CacheContextsManager $cacheContextsManager
+  ) {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function get($bin) {
+    if (!isset($this->bins[$bin])) {
+      $this->bins[$bin] = new VariationCache($this->requestStack, $this->cacheFactory->get($bin), $this->cacheContextsManager);
+    }
+    return $this->bins[$bin];
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Cache/VariationCacheFactoryInterface.php b/core/lib/Drupal/Core/Cache/VariationCacheFactoryInterface.php
new file mode 100644
index 000000000000..e9c948813db3
--- /dev/null
+++ b/core/lib/Drupal/Core/Cache/VariationCacheFactoryInterface.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Drupal\Core\Cache;
+
+/**
+ * An interface defining variation cache factory classes.
+ */
+interface VariationCacheFactoryInterface {
+
+  /**
+   * Gets a variation cache backend for a given cache bin.
+   *
+   * @param string $bin
+   *   The cache bin for which a variation cache backend should be returned.
+   *
+   * @return \Drupal\Core\Cache\VariationCacheInterface
+   *   The variation cache backend associated with the specified bin.
+   */
+  public function get($bin);
+
+}
diff --git a/core/lib/Drupal/Core/Cache/VariationCacheInterface.php b/core/lib/Drupal/Core/Cache/VariationCacheInterface.php
new file mode 100644
index 000000000000..0c6f464dbd7b
--- /dev/null
+++ b/core/lib/Drupal/Core/Cache/VariationCacheInterface.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Drupal\Core\Cache;
+
+/**
+ * Defines an interface for variation cache implementations.
+ *
+ * A variation cache wraps any provided cache backend and adds support for cache
+ * contexts to it. The actual caching still happens in the original cache
+ * backend.
+ *
+ * @ingroup cache
+ */
+interface VariationCacheInterface {
+
+  /**
+   * Gets a cache entry based on cache keys.
+   *
+   * @param string[] $keys
+   *   The cache keys to retrieve the cache entry for.
+   * @param \Drupal\Core\Cache\CacheableDependencyInterface $initial_cacheability
+   *   The cache metadata of the data to store before other systems had a chance
+   *   to adjust it. This is also commonly known as "pre-bubbling" cacheability.
+   *
+   * @return object|false
+   *   The cache item or FALSE on failure.
+   *
+   * @see \Drupal\Core\Cache\CacheBackendInterface::get()
+   */
+  public function get(array $keys, CacheableDependencyInterface $initial_cacheability);
+
+  /**
+   * Stores data in the cache.
+   *
+   * @param string[] $keys
+   *   The cache keys of the data to store.
+   * @param mixed $data
+   *   The data to store in the cache.
+   * @param \Drupal\Core\Cache\CacheableDependencyInterface $cacheability
+   *   The cache metadata of the data to store.
+   * @param \Drupal\Core\Cache\CacheableDependencyInterface $initial_cacheability
+   *   The cache metadata of the data to store before other systems had a chance
+   *   to adjust it. This is also commonly known as "pre-bubbling" cacheability.
+   *
+   * @see \Drupal\Core\Cache\CacheBackendInterface::set()
+   *
+   * @throws \LogicException
+   *   Thrown when cacheability is provided that does not contain a cache
+   *   context or does not completely contain the initial cacheability.
+   */
+  public function set(array $keys, $data, CacheableDependencyInterface $cacheability, CacheableDependencyInterface $initial_cacheability): void;
+
+  /**
+   * Deletes an item from the cache.
+   *
+   * To stay consistent with ::get(), this only affects the active variation,
+   * not all possible variations for the associated cache contexts.
+   *
+   * @param string[] $keys
+   *   The cache keys of the data to delete.
+   * @param \Drupal\Core\Cache\CacheableDependencyInterface $initial_cacheability
+   *   The cache metadata of the data to store before other systems had a chance
+   *   to adjust it. This is also commonly known as "pre-bubbling" cacheability.
+   *
+   * @see \Drupal\Core\Cache\CacheBackendInterface::delete()
+   */
+  public function delete(array $keys, CacheableDependencyInterface $initial_cacheability): void;
+
+  /**
+   * Marks a cache item as invalid.
+   *
+   * To stay consistent with ::get(), this only affects the active variation,
+   * not all possible variations for the associated cache contexts.
+   *
+   * @param string[] $keys
+   *   The cache keys of the data to invalidate.
+   * @param \Drupal\Core\Cache\CacheableDependencyInterface $initial_cacheability
+   *   The cache metadata of the data to store before other systems had a chance
+   *   to adjust it. This is also commonly known as "pre-bubbling" cacheability.
+   *
+   * @see \Drupal\Core\Cache\CacheBackendInterface::invalidate()
+   */
+  public function invalidate(array $keys, CacheableDependencyInterface $initial_cacheability): void;
+
+}
diff --git a/core/lib/Drupal/Core/Render/PlaceholderGenerator.php b/core/lib/Drupal/Core/Render/PlaceholderGenerator.php
index c00da6ca5321..f8906c449318 100644
--- a/core/lib/Drupal/Core/Render/PlaceholderGenerator.php
+++ b/core/lib/Drupal/Core/Render/PlaceholderGenerator.php
@@ -6,12 +6,21 @@
 use Drupal\Component\Utility\Html;
 use Drupal\Component\Utility\UrlHelper;
 use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Cache\Context\CacheContextsManager;
 
 /**
  * Turns a render array into a placeholder.
  */
 class PlaceholderGenerator implements PlaceholderGeneratorInterface {
 
+  /**
+   * The cache contexts manager service.
+   *
+   * @var \Drupal\Core\Cache\Context\CacheContextsManager
+   */
+  protected $cacheContextsManager;
+
   /**
    * The renderer configuration array.
    *
@@ -22,10 +31,13 @@ class PlaceholderGenerator implements PlaceholderGeneratorInterface {
   /**
    * Constructs a new Placeholder service.
    *
+   * @param \Drupal\Core\Cache\Context\CacheContextsManager $cache_contexts_manager
+   *   The cache contexts manager service.
    * @param array $renderer_config
    *   The renderer configuration array.
    */
-  public function __construct(array $renderer_config) {
+  public function __construct(CacheContextsManager $cache_contexts_manager, array $renderer_config) {
+    $this->cacheContextsManager = $cache_contexts_manager;
     $this->rendererConfig = $renderer_config;
   }
 
@@ -48,15 +60,21 @@ public function shouldAutomaticallyPlaceholder(array $element) {
     // parameter.
     $conditions = $this->rendererConfig['auto_placeholder_conditions'];
 
-    if (isset($element['#cache']['max-age']) && $element['#cache']['max-age'] !== Cache::PERMANENT && $element['#cache']['max-age'] <= $conditions['max-age']) {
+    $cacheability = CacheableMetadata::createFromRenderArray($element);
+    if ($cacheability->getCacheMaxAge() !== Cache::PERMANENT && $cacheability->getCacheMaxAge() <= $conditions['max-age']) {
       return TRUE;
     }
 
-    if (isset($element['#cache']['contexts']) && array_intersect($element['#cache']['contexts'], $conditions['contexts'])) {
+    // Optimize the contexts and let them affect the cache tags to mimic what
+    // happens to the cacheability in the variation cache (RenderCache backend).
+    $cacheability->addCacheableDependency($this->cacheContextsManager->convertTokensToKeys($cacheability->getCacheContexts()));
+    $cacheability->setCacheContexts($this->cacheContextsManager->optimizeTokens($cacheability->getCacheContexts()));
+
+    if (array_intersect($cacheability->getCacheContexts(), $conditions['contexts'])) {
       return TRUE;
     }
 
-    if (isset($element['#cache']['tags']) && array_intersect($element['#cache']['tags'], $conditions['tags'])) {
+    if (array_intersect($cacheability->getCacheTags(), $conditions['tags'])) {
       return TRUE;
     }
 
diff --git a/core/lib/Drupal/Core/Render/PlaceholderingRenderCache.php b/core/lib/Drupal/Core/Render/PlaceholderingRenderCache.php
index 0422ecf5eb52..2f0ce18ee4a4 100644
--- a/core/lib/Drupal/Core/Render/PlaceholderingRenderCache.php
+++ b/core/lib/Drupal/Core/Render/PlaceholderingRenderCache.php
@@ -76,14 +76,18 @@ class PlaceholderingRenderCache extends RenderCache {
    *
    * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
    *   The request stack.
-   * @param \Drupal\Core\Cache\CacheFactoryInterface $cache_factory
-   *   The cache factory.
+   * @param \Drupal\Core\Cache\VariationCacheFactoryInterface $cache_factory
+   *   The variation cache factory.
    * @param \Drupal\Core\Cache\Context\CacheContextsManager $cache_contexts_manager
    *   The cache contexts manager.
    * @param \Drupal\Core\Render\PlaceholderGeneratorInterface $placeholder_generator
    *   The placeholder generator.
    */
-  public function __construct(RequestStack $request_stack, CacheFactoryInterface $cache_factory, CacheContextsManager $cache_contexts_manager, PlaceholderGeneratorInterface $placeholder_generator) {
+  public function __construct(RequestStack $request_stack, $cache_factory, CacheContextsManager $cache_contexts_manager, PlaceholderGeneratorInterface $placeholder_generator) {
+    if ($cache_factory instanceof CacheFactoryInterface) {
+      @trigger_error('Injecting ' . __CLASS__ . ' with the "cache_factory" service is deprecated in drupal:10.1.0, use "variation_cache_factory" instead.', E_USER_DEPRECATED);
+      $cache_factory = \Drupal::service('variation_cache_factory');
+    }
     parent::__construct($request_stack, $cache_factory, $cache_contexts_manager);
     $this->placeholderGenerator = $placeholder_generator;
   }
diff --git a/core/lib/Drupal/Core/Render/RenderCache.php b/core/lib/Drupal/Core/Render/RenderCache.php
index 674c00e28f2a..90558b4a02d2 100644
--- a/core/lib/Drupal/Core/Render/RenderCache.php
+++ b/core/lib/Drupal/Core/Render/RenderCache.php
@@ -2,19 +2,15 @@
 
 namespace Drupal\Core\Render;
 
-use Drupal\Core\Cache\Cache;
 use Drupal\Core\Cache\CacheableMetadata;
-use Drupal\Core\Cache\Context\CacheContextsManager;
 use Drupal\Core\Cache\CacheFactoryInterface;
+use Drupal\Core\Cache\Context\CacheContextsManager;
 use Symfony\Component\HttpFoundation\RequestStack;
 
 /**
  * Wraps the caching logic for the render caching system.
  *
  * @internal
- *
- * @todo Refactor this out into a generic service capable of cache redirects,
- *   and let RenderCache use that. https://www.drupal.org/node/2551419
  */
 class RenderCache implements RenderCacheInterface {
 
@@ -26,9 +22,9 @@ class RenderCache implements RenderCacheInterface {
   protected $requestStack;
 
   /**
-   * The cache factory.
+   * The variation cache factory.
    *
-   * @var \Drupal\Core\Cache\CacheFactoryInterface
+   * @var \Drupal\Core\Cache\VariationCacheFactoryInterface
    */
   protected $cacheFactory;
 
@@ -44,12 +40,16 @@ class RenderCache implements RenderCacheInterface {
    *
    * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
    *   The request stack.
-   * @param \Drupal\Core\Cache\CacheFactoryInterface $cache_factory
-   *   The cache factory.
+   * @param \Drupal\Core\Cache\VariationCacheFactoryInterface $cache_factory
+   *   The variation cache factory.
    * @param \Drupal\Core\Cache\Context\CacheContextsManager $cache_contexts_manager
    *   The cache contexts manager.
    */
-  public function __construct(RequestStack $request_stack, CacheFactoryInterface $cache_factory, CacheContextsManager $cache_contexts_manager) {
+  public function __construct(RequestStack $request_stack, $cache_factory, CacheContextsManager $cache_contexts_manager) {
+    if ($cache_factory instanceof CacheFactoryInterface) {
+      @trigger_error('Injecting ' . __CLASS__ . ' with the "cache_factory" service is deprecated in drupal:10.1.0, use "variation_cache_factory" instead.', E_USER_DEPRECATED);
+      $cache_factory = \Drupal::service('variation_cache_factory');
+    }
     $this->requestStack = $request_stack;
     $this->cacheFactory = $cache_factory;
     $this->cacheContextsManager = $cache_contexts_manager;
@@ -63,21 +63,13 @@ public function get(array $elements) {
     // and render caching of forms prevents this from happening.
     // @todo remove the isMethodCacheable() check when
     //   https://www.drupal.org/node/2367555 lands.
-    if (!$this->requestStack->getCurrentRequest()->isMethodCacheable() || !$cid = $this->createCacheID($elements)) {
+    if (!$this->requestStack->getCurrentRequest()->isMethodCacheable() || !$this->isElementCacheable($elements)) {
       return FALSE;
     }
-    $bin = $elements['#cache']['bin'] ?? 'render';
 
-    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 \Drupal\Core\Render\RendererInterface::render()
-      // @see ::set()
-      if (isset($cached_element['#cache_redirect'])) {
-        return $this->get($cached_element);
-      }
-      // Return the cached element.
-      return $cached_element;
+    $bin = isset($elements['#cache']['bin']) ? $elements['#cache']['bin'] : 'render';
+    if (($cache_bin = $this->cacheFactory->get($bin)) && $cache = $cache_bin->get($elements['#cache']['keys'], CacheableMetadata::createFromRenderArray($elements))) {
+      return $cache->data;
     }
     return FALSE;
   }
@@ -90,239 +82,19 @@ public function set(array &$elements, array $pre_bubbling_elements) {
     // and render caching of forms prevents this from happening.
     // @todo remove the isMethodCacheable() check when
     //   https://www.drupal.org/node/2367555 lands.
-    if (!$this->requestStack->getCurrentRequest()->isMethodCacheable() || !$cid = $this->createCacheID($elements)) {
+    if (!$this->requestStack->getCurrentRequest()->isMethodCacheable() || !$this->isElementCacheable($elements)) {
       return FALSE;
     }
 
-    $data = $this->getCacheableRenderArray($elements);
-
     $bin = $elements['#cache']['bin'] ?? 'render';
-    $cache = $this->cacheFactory->get($bin);
-
-    // Calculate the pre-bubbling CID.
-    $pre_bubbling_cid = $this->createCacheID($pre_bubbling_elements);
-
-    // Two-tier caching: detect different CID post-bubbling, create redirect,
-    // update redirect if different set of cache contexts.
-    // @see \Drupal\Core\Render\RendererInterface::render()
-    // @see ::get()
-    if ($pre_bubbling_cid && $pre_bubbling_cid !== $cid) {
-      // The cache redirection strategy we're implementing here is pretty
-      // simple in concept. Suppose we have the following render structure:
-      // - A (pre-bubbling, specifies #cache['keys'] = ['foo'])
-      // -- B (specifies #cache['contexts'] = ['b'])
-      //
-      // At the time that we're evaluating whether A's rendering can be
-      // retrieved from cache, we won't know the contexts required by its
-      // children (the children might not even be built yet), so cacheGet()
-      // will only be able to get what is cached for a $cid of 'foo'. But at
-      // the time we're writing to that cache, we do know all the contexts that
-      // were specified by all children, so what we need is a way to
-      // persist that information between the cache write and the next cache
-      // read. So, what we can do is store the following into 'foo':
-      // @code
-      // [
-      //   '#cache_redirect' => TRUE,
-      //   '#cache' => [
-      //     ...
-      //     'contexts' => ['b'],
-      //   ],
-      // ]
-      // @endcode
-      //
-      // This efficiently lets cacheGet() redirect to a $cid that includes all
-      // of the required contexts. The strategy is on-demand: in the case where
-      // there aren't any additional contexts required by children that aren't
-      // already included in the parent's pre-bubbled #cache information, no
-      // cache redirection is needed.
-      //
-      // When implementing this redirection strategy, special care is needed to
-      // resolve potential cache ping-pong problems. For example, consider the
-      // following render structure:
-      // - A (pre-bubbling, specifies #cache['keys'] = ['foo'])
-      // -- B (pre-bubbling, specifies #cache['contexts'] = ['b'])
-      // --- C (pre-bubbling, specifies #cache['contexts'] = ['c'])
-      // --- D (pre-bubbling, specifies #cache['contexts'] = ['d'])
-      //
-      // Additionally, suppose that:
-      // - C only exists for a 'b' context value of 'b1'
-      // - D only exists for a 'b' context value of 'b2'
-      // This is an acceptable variation, since B specifies that its contents
-      // vary on context 'b'.
-      //
-      // A naive implementation of cache redirection would result in the
-      // following:
-      // - When a request is processed where context 'b' = 'b1', what would be
-      //   cached for a $pre_bubbling_cid of 'foo' is:
-      // @code
-      //   [
-      //     '#cache_redirect' => TRUE,
-      //     '#cache' => [
-      //       ...
-      //       'contexts' => ['b', 'c'],
-      //     ],
-      //   ]
-      // @endcode
-      // - When a request is processed where context 'b' = 'b2', we would
-      //   retrieve the above from cache, but when following that redirection,
-      //   get a cache miss, since we're processing a 'b' context value that
-      //   has not yet been cached. Given the cache miss, we would continue
-      //   with rendering the structure, perform the required context bubbling
-      //   and then overwrite the above item with:
-      // @code
-      //   [
-      //     '#cache_redirect' => TRUE,
-      //     '#cache' => [
-      //       ...
-      //       'contexts' => ['b', 'd'],
-      //     ],
-      //   ]
-      // @endcode
-      // - Now, if a request comes in where context 'b' = 'b1' again, the above
-      //   would redirect to a cache key that doesn't exist, since we have not
-      //   yet cached an item that includes 'b'='b1' and something for 'd'. So
-      //   we would process this request as a cache miss, at the end of which,
-      //   we would overwrite the above item back to:
-      // @code
-      //   [
-      //     '#cache_redirect' => TRUE,
-      //     '#cache' => [
-      //       ...
-      //       'contexts' => ['b', 'c'],
-      //     ],
-      //   ]
-      // @endcode
-      // - The above would always result in accurate renderings, but would
-      //   result in poor performance as we keep processing requests as cache
-      //   misses even though the target of the redirection is cached, and
-      //   it's only the redirection element itself that is creating the
-      //   ping-pong problem.
-      //
-      // A way to resolve the ping-pong problem is to eventually reach a cache
-      // state where the redirection element includes all of the contexts used
-      // throughout all requests:
-      // @code
-      // [
-      //   '#cache_redirect' => TRUE,
-      //   '#cache' => [
-      //     ...
-      //     'contexts' => ['b', 'c', 'd'],
-      //   ],
-      // ]
-      // @endcode
-      //
-      // We can't reach that state right away, since we don't know what the
-      // result of future requests will be, but we can incrementally move
-      // towards that state by progressively merging the 'contexts' value
-      // across requests. That's the strategy employed below and tested in
-      // \Drupal\Tests\Core\Render\RendererBubblingTest::testConditionalCacheContextBubblingSelfHealing().
-
-      // Get the cacheability of this element according to the current (stored)
-      // redirecting cache item, if any.
-      $redirect_cacheability = new CacheableMetadata();
-      if ($stored_cache_redirect = $cache->get($pre_bubbling_cid)) {
-        $redirect_cacheability = CacheableMetadata::createFromRenderArray($stored_cache_redirect->data);
-      }
-
-      // Calculate the union of the cacheability for this request and the
-      // current (stored) redirecting cache item. We need:
-      // - the union of cache contexts, because that is how we know which cache
-      //   item to redirect to;
-      // - the union of cache tags, because that is how we know when the cache
-      //   redirect cache item itself is invalidated;
-      // - the union of max ages, because that is how we know when the cache
-      //   redirect cache item itself becomes stale. (Without this, we might end
-      //   up toggling between a permanently and a briefly cacheable cache
-      //   redirect, because the last update's max-age would always "win".)
-      $redirect_cacheability_updated = CacheableMetadata::createFromRenderArray($data)->merge($redirect_cacheability);
-
-      // Stored cache contexts incomplete: this request causes cache contexts to
-      // be added to the redirecting cache item.
-      if (array_diff($redirect_cacheability_updated->getCacheContexts(), $redirect_cacheability->getCacheContexts())) {
-        $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' => $redirect_cacheability_updated->getCacheContexts(),
-            // The union of the current element's and stored cache tags.
-            'tags' => $redirect_cacheability_updated->getCacheTags(),
-            // The union of the current element's and stored cache max-ages.
-            'max-age' => $redirect_cacheability_updated->getCacheMaxAge(),
-            // The same cache bin as the one for the actual render cache items.
-            'bin' => $bin,
-          ],
-        ];
-        $cache->set($pre_bubbling_cid, $redirect_data, $this->maxAgeToExpire($redirect_cacheability_updated->getCacheMaxAge()), Cache::mergeTags($redirect_data['#cache']['tags'], ['rendered']));
-      }
-
-      // 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 (array_diff($redirect_cacheability_updated->getCacheContexts(), $data['#cache']['contexts'])) {
-        // Recalculate the cache ID.
-        $recalculated_cid_pseudo_element = [
-          '#cache' => [
-            'keys' => $elements['#cache']['keys'],
-            'contexts' => $redirect_cacheability_updated->getCacheContexts(),
-          ],
-        ];
-        $cid = $this->createCacheID($recalculated_cid_pseudo_element);
-        // Ensure the about-to-be-cached data uses the merged cache contexts.
-        $data['#cache']['contexts'] = $redirect_cacheability_updated->getCacheContexts();
-      }
-    }
-    $cache->set($cid, $data, $this->maxAgeToExpire($elements['#cache']['max-age']), Cache::mergeTags($data['#cache']['tags'], ['rendered']));
-  }
-
-  /**
-   * Maps a #cache[max-age] value to an "expire" value for the Cache API.
-   *
-   * @param int $max_age
-   *   A #cache[max-age] value.
-   *
-   * @return int
-   *   A corresponding "expire" value.
-   *
-   * @see \Drupal\Core\Cache\CacheBackendInterface::set()
-   */
-  protected function maxAgeToExpire($max_age) {
-    return ($max_age === Cache::PERMANENT) ? Cache::PERMANENT : (int) $this->requestStack->getMainRequest()->server->get('REQUEST_TIME') + $max_age;
-  }
-
-  /**
-   * Creates the cache ID for a renderable element.
-   *
-   * Creates the cache ID string based on #cache['keys'] + #cache['contexts'].
-   *
-   * @param array &$elements
-   *   A renderable array.
-   *
-   * @return string
-   *   The cache ID string, or FALSE if the element may not be cached.
-   */
-  protected function createCacheID(array &$elements) {
-    // If the maximum age is zero, then caching is effectively prohibited.
-    if (isset($elements['#cache']['max-age']) && $elements['#cache']['max-age'] === 0) {
-      return FALSE;
-    }
-
-    if (isset($elements['#cache']['keys'])) {
-      $cid_parts = $elements['#cache']['keys'];
-      if (!empty($elements['#cache']['contexts'])) {
-        $context_cache_keys = $this->cacheContextsManager->convertTokensToKeys($elements['#cache']['contexts']);
-        $cid_parts = array_merge($cid_parts, $context_cache_keys->getKeys());
-        CacheableMetadata::createFromRenderArray($elements)
-          ->merge($context_cache_keys)
-          ->applyTo($elements);
-      }
-      return implode(':', $cid_parts);
-    }
-    return FALSE;
+    $cache_bin = $this->cacheFactory->get($bin);
+    $data = $this->getCacheableRenderArray($elements);
+    $cache_bin->set(
+      $elements['#cache']['keys'],
+      $data,
+      CacheableMetadata::createFromRenderArray($data)->addCacheTags(['rendered']),
+      CacheableMetadata::createFromRenderArray($pre_bubbling_elements)
+    );
   }
 
   /**
@@ -365,4 +137,25 @@ public function getCacheableRenderArray(array $elements) {
     return $data;
   }
 
+  /**
+   * Checks whether a renderable array can be cached.
+   *
+   * This allows us to not even have to instantiate the cache backend if a
+   * renderable array does not have any cache keys or specifies a zero cache
+   * max age.
+   *
+   * @param array $element
+   *   A renderable array.
+   *
+   * @return bool
+   *   Whether the renderable array is cacheable.
+   */
+  protected function isElementCacheable(array $element) {
+    // If the maximum age is zero, then caching is effectively prohibited.
+    if (isset($element['#cache']['max-age']) && $element['#cache']['max-age'] === 0) {
+      return FALSE;
+    }
+    return isset($element['#cache']['keys']);
+  }
+
 }
diff --git a/core/modules/block/tests/src/Kernel/BlockViewBuilderTest.php b/core/modules/block/tests/src/Kernel/BlockViewBuilderTest.php
index 1504077dfd34..6e401e24807f 100644
--- a/core/modules/block/tests/src/Kernel/BlockViewBuilderTest.php
+++ b/core/modules/block/tests/src/Kernel/BlockViewBuilderTest.php
@@ -4,6 +4,7 @@
 
 use Drupal\Component\Utility\Html;
 use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Language\LanguageInterface;
 use Drupal\KernelTests\KernelTestBase;
 use Drupal\block\Entity\Block;
@@ -147,6 +148,10 @@ public function testBlockViewBuilderCache() {
    * @see ::testBlockViewBuilderCache()
    */
   protected function verifyRenderCacheHandling() {
+    /** @var \Drupal\Core\Cache\VariationCacheFactoryInterface $variation_cache_factory */
+    $variation_cache_factory = $this->container->get('variation_cache_factory');
+    $cache_bin = $variation_cache_factory->get('render');
+
     // Force a request via GET so we can test the render cache.
     $request = \Drupal::request();
     $request_method = $request->server->get('REQUEST_METHOD');
@@ -154,13 +159,13 @@ protected function verifyRenderCacheHandling() {
 
     // Test that a cache entry is created.
     $build = $this->getBlockRenderArray();
-    $cid = 'entity_view:block:test_block:' . implode(':', \Drupal::service('cache_contexts_manager')->convertTokensToKeys(['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme', 'user.permissions'])->getKeys());
+    $cache_keys = ['entity_view', 'block', 'test_block'];
     $this->renderer->renderRoot($build);
-    $this->assertNotEmpty($this->container->get('cache.render')->get($cid), 'The block render element has been cached.');
+    $this->assertNotEmpty($cache_bin->get($cache_keys, CacheableMetadata::createFromRenderArray($build)), 'The block render element has been cached.');
 
     // Re-save the block and check that the cache entry has been deleted.
     $this->block->save();
-    $this->assertFalse($this->container->get('cache.render')->get($cid), 'The block render cache entry has been cleared when the block was saved.');
+    $this->assertFalse($cache_bin->get($cache_keys, CacheableMetadata::createFromRenderArray($build)), 'The block render cache entry has been cleared when the block was saved.');
 
     // Rebuild the render array (creating a new cache entry in the process) and
     // delete the block to check the cache entry is deleted.
@@ -170,9 +175,9 @@ protected function verifyRenderCacheHandling() {
     $build['#block'] = $this->block;
 
     $this->renderer->renderRoot($build);
-    $this->assertNotEmpty($this->container->get('cache.render')->get($cid), 'The block render element has been cached.');
+    $this->assertNotEmpty($cache_bin->get($cache_keys, CacheableMetadata::createFromRenderArray($build)), 'The block render element has been cached.');
     $this->block->delete();
-    $this->assertFalse($this->container->get('cache.render')->get($cid), 'The block render cache entry has been cleared when the block was deleted.');
+    $this->assertFalse($cache_bin->get($cache_keys, CacheableMetadata::createFromRenderArray($build)), 'The block render cache entry has been cleared when the block was deleted.');
 
     // Restore the previous request method.
     $request->setMethod($request_method);
@@ -292,6 +297,10 @@ public function testBlockViewBuilderBuildAlter() {
    * @internal
    */
   protected function assertBlockRenderedWithExpectedCacheability(array $expected_keys, array $expected_contexts, array $expected_tags, int $expected_max_age): void {
+    /** @var \Drupal\Core\Cache\VariationCacheFactoryInterface $variation_cache_factory */
+    $variation_cache_factory = $this->container->get('variation_cache_factory');
+    $cache_bin = $variation_cache_factory->get('render');
+
     $required_cache_contexts = ['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme', 'user.permissions'];
 
     // Check that the expected cacheability metadata is present in:
@@ -306,15 +315,14 @@ protected function assertBlockRenderedWithExpectedCacheability(array $expected_k
     $this->renderer->renderRoot($build);
     // - the render cache item.
     $final_cache_contexts = Cache::mergeContexts($expected_contexts, $required_cache_contexts);
-    $cid = implode(':', $expected_keys) . ':' . implode(':', \Drupal::service('cache_contexts_manager')->convertTokensToKeys($final_cache_contexts)->getKeys());
-    $cache_item = $this->container->get('cache.render')->get($cid);
-    $this->assertNotEmpty($cache_item, 'The block render element has been cached with the expected cache ID.');
+    $cache_item = $cache_bin->get($expected_keys, CacheableMetadata::createFromRenderArray($build));
+    $this->assertNotEmpty($cache_item, 'The block render element has been cached with the expected cache keys.');
     $this->assertEqualsCanonicalizing(Cache::mergeTags($expected_tags, ['rendered']), $cache_item->tags);
     $this->assertEqualsCanonicalizing($final_cache_contexts, $cache_item->data['#cache']['contexts']);
     $this->assertEqualsCanonicalizing($expected_tags, $cache_item->data['#cache']['tags']);
     $this->assertSame($expected_max_age, $cache_item->data['#cache']['max-age']);
 
-    $this->container->get('cache.render')->delete($cid);
+    $cache_bin->delete($expected_keys, CacheableMetadata::createFromRenderArray($build));
   }
 
   /**
diff --git a/core/modules/block_content/tests/src/Functional/BlockContentCacheTagsTest.php b/core/modules/block_content/tests/src/Functional/BlockContentCacheTagsTest.php
index fac55fc71075..aa51c9581412 100644
--- a/core/modules/block_content/tests/src/Functional/BlockContentCacheTagsTest.php
+++ b/core/modules/block_content/tests/src/Functional/BlockContentCacheTagsTest.php
@@ -5,8 +5,8 @@
 use Drupal\block_content\Entity\BlockContent;
 use Drupal\block_content\Entity\BlockContentType;
 use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Entity\EntityInterface;
-use Drupal\Core\Language\LanguageInterface;
 use Drupal\Tests\system\Functional\Entity\EntityCacheTagsTestBase;
 use Symfony\Component\HttpFoundation\Request;
 
@@ -91,20 +91,16 @@ public function testBlock() {
     // Expected keys, contexts, and tags for the block.
     // @see \Drupal\block\BlockViewBuilder::viewMultiple()
     $expected_block_cache_keys = ['entity_view', 'block', $block->id()];
-    $expected_block_cache_contexts = ['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme', 'user.permissions'];
     $expected_block_cache_tags = Cache::mergeTags(['block_view', 'rendered'], $block->getCacheTags());
     $expected_block_cache_tags = Cache::mergeTags($expected_block_cache_tags, $block->getPlugin()->getCacheTags());
 
     // Expected contexts and tags for the BlockContent entity.
     // @see \Drupal\Core\Entity\EntityViewBuilder::getBuildDefaults().
-    $expected_entity_cache_contexts = ['theme'];
     $expected_entity_cache_tags = Cache::mergeTags(['block_content_view'], $this->entity->getCacheTags());
     $expected_entity_cache_tags = Cache::mergeTags($expected_entity_cache_tags, $this->getAdditionalCacheTagsForEntity($this->entity));
 
     // Verify that what was render cached matches the above expectations.
-    $cid = $this->createCacheId($expected_block_cache_keys, $expected_block_cache_contexts);
-    $redirected_cid = $this->createCacheId($expected_block_cache_keys, Cache::mergeContexts($expected_block_cache_contexts, $expected_entity_cache_contexts));
-    $this->verifyRenderCache($cid, Cache::mergeTags($expected_block_cache_tags, $expected_entity_cache_tags), ($cid !== $redirected_cid) ? $redirected_cid : NULL);
+    $this->verifyRenderCache($expected_block_cache_keys, Cache::mergeTags($expected_block_cache_tags, $expected_entity_cache_tags), CacheableMetadata::createFromRenderArray($build));
   }
 
 }
diff --git a/core/modules/dynamic_page_cache/dynamic_page_cache.services.yml b/core/modules/dynamic_page_cache/dynamic_page_cache.services.yml
index 2d1aea97c6c1..6f2b53d2c24b 100644
--- a/core/modules/dynamic_page_cache/dynamic_page_cache.services.yml
+++ b/core/modules/dynamic_page_cache/dynamic_page_cache.services.yml
@@ -5,9 +5,13 @@ services:
       - { name: cache.bin }
     factory: ['@cache_factory', 'get']
     arguments: [dynamic_page_cache]
+  variation_cache.dynamic_page_cache:
+    class: Drupal\Core\Cache\VariationCacheInterface
+    factory: variation_cache_factory:get
+    arguments: [dynamic_page_cache]
   dynamic_page_cache_subscriber:
     class: Drupal\dynamic_page_cache\EventSubscriber\DynamicPageCacheSubscriber
-    arguments: ['@dynamic_page_cache_request_policy', '@dynamic_page_cache_response_policy', '@render_cache', '%renderer.config%']
+    arguments: ['@dynamic_page_cache_request_policy', '@dynamic_page_cache_response_policy', '@variation_cache.dynamic_page_cache', '@cache_contexts_manager', '%renderer.config%']
     tags:
       - { name: event_subscriber }
 
diff --git a/core/modules/dynamic_page_cache/src/EventSubscriber/DynamicPageCacheSubscriber.php b/core/modules/dynamic_page_cache/src/EventSubscriber/DynamicPageCacheSubscriber.php
index 2f75ccfaff1b..4d6a9d81a2a5 100644
--- a/core/modules/dynamic_page_cache/src/EventSubscriber/DynamicPageCacheSubscriber.php
+++ b/core/modules/dynamic_page_cache/src/EventSubscriber/DynamicPageCacheSubscriber.php
@@ -5,9 +5,10 @@
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Cache\CacheableResponseInterface;
+use Drupal\Core\Cache\Context\CacheContextsManager;
+use Drupal\Core\Cache\VariationCacheInterface;
 use Drupal\Core\PageCache\RequestPolicyInterface;
 use Drupal\Core\PageCache\ResponsePolicyInterface;
-use Drupal\Core\Render\RenderCacheInterface;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 use Symfony\Component\HttpKernel\Event\ResponseEvent;
 use Symfony\Component\HttpKernel\Event\RequestEvent;
@@ -54,40 +55,41 @@ class DynamicPageCacheSubscriber implements EventSubscriberInterface {
   protected $responsePolicy;
 
   /**
-   * The render cache.
+   * The variation cache.
    *
-   * @var \Drupal\Core\Render\RenderCacheInterface
+   * @var \Drupal\Core\Cache\VariationCacheInterface
    */
-  protected $renderCache;
+  protected $cache;
 
   /**
-   * The renderer configuration array.
+   * The default cache contexts to vary every cache item by.
    *
-   * @var array
+   * @var string[]
    */
-  protected $rendererConfig;
+  protected $cacheContexts = [
+    'route',
+    // Some routes' controllers rely on the request format (they don't have
+    // a separate route for each request format). Additionally, a controller
+    // may be returning a domain object that a KernelEvents::VIEW subscriber
+    // must turn into an actual response, but perhaps a format is being
+    // requested that the subscriber does not support.
+    // @see \Drupal\Core\EventSubscriber\RenderArrayNonHtmlSubscriber::onResponse()
+    'request_format',
+  ];
+
+  /**
+   * The cache contexts manager service.
+   *
+   * @var \Drupal\Core\Cache\Context\CacheContextsManager
+   */
+  protected $cacheContextsManager;
 
   /**
-   * Dynamic Page Cache's redirect render array.
+   * The renderer configuration array.
    *
    * @var array
    */
-  protected $dynamicPageCacheRedirectRenderArray = [
-    '#cache' => [
-      'keys' => ['response'],
-      'contexts' => [
-        'route',
-        // Some routes' controllers rely on the request format (they don't have
-        // a separate route for each request format). Additionally, a controller
-        // may be returning a domain object that a KernelEvents::VIEW subscriber
-        // must turn into an actual response, but perhaps a format is being
-        // requested that the subscriber does not support.
-        // @see \Drupal\Core\EventSubscriber\RenderArrayNonHtmlSubscriber::onResponse()
-        'request_format',
-      ],
-      'bin' => 'dynamic_page_cache',
-    ],
-  ];
+  protected $rendererConfig;
 
   /**
    * Internal cache of request policy results.
@@ -103,15 +105,18 @@ class DynamicPageCacheSubscriber implements EventSubscriberInterface {
    *   A policy rule determining the cacheability of a request.
    * @param \Drupal\Core\PageCache\ResponsePolicyInterface $response_policy
    *   A policy rule determining the cacheability of the response.
-   * @param \Drupal\Core\Render\RenderCacheInterface $render_cache
-   *   The render cache.
+   * @param \Drupal\Core\Cache\VariationCacheInterface $cache
+   *   The variation cache.
+   * @param \Drupal\Core\Cache\Context\CacheContextsManager $cache_contexts_manager
+   *   The cache contexts manager service.
    * @param array $renderer_config
    *   The renderer configuration array.
    */
-  public function __construct(RequestPolicyInterface $request_policy, ResponsePolicyInterface $response_policy, RenderCacheInterface $render_cache, array $renderer_config) {
+  public function __construct(RequestPolicyInterface $request_policy, ResponsePolicyInterface $response_policy, VariationCacheInterface $cache, CacheContextsManager $cache_contexts_manager, array $renderer_config) {
     $this->requestPolicy = $request_policy;
     $this->responsePolicy = $response_policy;
-    $this->renderCache = $render_cache;
+    $this->cache = $cache;
+    $this->cacheContextsManager = $cache_contexts_manager;
     $this->rendererConfig = $renderer_config;
     $this->requestPolicyResults = new \SplObjectStorage();
   }
@@ -134,9 +139,9 @@ public function onRequest(RequestEvent $event) {
     }
 
     // Sets the response for the current route, if cached.
-    $cached = $this->renderCache->get($this->dynamicPageCacheRedirectRenderArray);
+    $cached = $this->cache->get(['response'], (new CacheableMetadata())->setCacheContexts($this->cacheContexts));
     if ($cached) {
-      $response = $this->renderArrayToResponse($cached);
+      $response = $cached->data;
       $response->headers->set(self::HEADER, 'HIT');
       $event->setResponse($response);
     }
@@ -194,10 +199,13 @@ public function onResponse(ResponseEvent $event) {
       return;
     }
 
-    // Embed the response object in a render array so that RenderCache is able
-    // to cache it, handling cache redirection for us.
-    $response_as_render_array = $this->responseToRenderArray($response);
-    $this->renderCache->set($response_as_render_array, $this->dynamicPageCacheRedirectRenderArray);
+    $cacheable_metadata = CacheableMetadata::createFromObject($response->getCacheableMetadata());
+    $this->cache->set(
+      ['response'],
+      $response,
+      $cacheable_metadata->addCacheContexts($this->cacheContexts),
+      (new CacheableMetadata())->setCacheContexts($this->cacheContexts)
+    );
 
     // The response was generated, mark the response as a cache miss. The next
     // time, it will be a cache hit.
@@ -229,13 +237,19 @@ public function onResponse(ResponseEvent $event) {
   protected function shouldCacheResponse(CacheableResponseInterface $response) {
     $conditions = $this->rendererConfig['auto_placeholder_conditions'];
 
-    $cacheability = $response->getCacheableMetadata();
+    // Create a new CacheableMetadata to avoid changing the response itself.
+    $cacheability = CacheableMetadata::createFromObject($response->getCacheableMetadata());
 
     // Response's max-age is at or below the configured threshold.
     if ($cacheability->getCacheMaxAge() !== Cache::PERMANENT && $cacheability->getCacheMaxAge() <= $conditions['max-age']) {
       return FALSE;
     }
 
+    // Optimize the contexts and let them affect the cache tags to mimic what
+    // happens to the cacheability in the variation cache.
+    $cacheability->addCacheableDependency($this->cacheContextsManager->convertTokensToKeys($cacheability->getCacheContexts()));
+    $cacheability->setCacheContexts($this->cacheContextsManager->optimizeTokens($cacheability->getCacheContexts()));
+
     // Response has a high-cardinality cache context.
     if (array_intersect($cacheability->getCacheContexts(), $conditions['contexts'])) {
       return FALSE;
@@ -249,58 +263,6 @@ protected function shouldCacheResponse(CacheableResponseInterface $response) {
     return TRUE;
   }
 
-  /**
-   * Embeds a Response object in a render array so that RenderCache can cache it.
-   *
-   * @param \Drupal\Core\Cache\CacheableResponseInterface $response
-   *   A cacheable response.
-   *
-   * @return array
-   *   A render array that embeds the given cacheable response object, with the
-   *   cacheability metadata of the response object present in the #cache
-   *   property of the render array.
-   *
-   * @see renderArrayToResponse()
-   *
-   * @todo Refactor/remove once https://www.drupal.org/node/2551419 lands.
-   */
-  protected function responseToRenderArray(CacheableResponseInterface $response) {
-    $response_as_render_array = $this->dynamicPageCacheRedirectRenderArray + [
-      // The data we actually care about.
-      '#response' => $response,
-      // Tell RenderCache to cache the #response property: the data we actually
-      // care about.
-      '#cache_properties' => ['#response'],
-      // These exist only to fulfill the requirements of the RenderCache, which
-      // is designed to work with render arrays only. We don't care about these.
-      '#markup' => '',
-      '#attached' => '',
-    ];
-
-    // Merge the response's cacheability metadata, so that RenderCache can take
-    // care of cache redirects for us.
-    CacheableMetadata::createFromObject($response->getCacheableMetadata())
-      ->merge(CacheableMetadata::createFromRenderArray($response_as_render_array))
-      ->applyTo($response_as_render_array);
-
-    return $response_as_render_array;
-  }
-
-  /**
-   * Gets the embedded Response object in a render array.
-   *
-   * @param array $render_array
-   *   A render array with a #response property.
-   *
-   * @return \Drupal\Core\Cache\CacheableResponseInterface
-   *   The cacheable response object.
-   *
-   * @see responseToRenderArray()
-   */
-  protected function renderArrayToResponse(array $render_array) {
-    return $render_array['#response'];
-  }
-
   /**
    * {@inheritdoc}
    */
diff --git a/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
index b4b83c7ebc61..2818fe4f4e55 100644
--- a/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
+++ b/core/modules/jsonapi/tests/src/Functional/ResourceTestBase.php
@@ -10,6 +10,7 @@
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Cache\CacheableResponseInterface;
+use Drupal\Core\Cache\CacheRedirect;
 use Drupal\Core\Config\Entity\ConfigEntityInterface;
 use Drupal\Core\Entity\ContentEntityInterface;
 use Drupal\Core\Entity\ContentEntityNullStorage;
@@ -985,16 +986,12 @@ public function testGetIndividual() {
       ->condition('cid', '%[route]=jsonapi.%', 'LIKE')
       ->execute()
       ->fetchAll();
-    $this->assertLessThanOrEqual(4, count($cache_items));
+    $this->assertLessThanOrEqual(5, count($cache_items));
     $found_cached_200_response = FALSE;
     $other_cached_responses_are_4xx = TRUE;
     foreach ($cache_items as $cache_item) {
-      $cached_data = unserialize($cache_item->data);
-
-      // We might be finding cache redirects when querying like this, so ensure
-      // we only inspect the actual cached response to see if it got flattened.
-      if (!isset($cached_data['#cache_redirect'])) {
-        $cached_response = $cached_data['#response'];
+      $cached_response = unserialize($cache_item->data);
+      if (!$cached_response instanceof CacheRedirect) {
         if ($cached_response->getStatusCode() === 200) {
           $found_cached_200_response = TRUE;
         }
diff --git a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
index 5816b340e156..3ad10537b5ac 100644
--- a/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
+++ b/core/modules/rest/tests/src/Functional/EntityResource/EntityResourceTestBase.php
@@ -8,6 +8,7 @@
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Cache\CacheableResponseInterface;
 use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Cache\CacheRedirect;
 use Drupal\Core\Config\Entity\ConfigEntityInterface;
 use Drupal\Core\Entity\ContentEntityNullStorage;
 use Drupal\Core\Entity\EntityInterface;
@@ -535,9 +536,8 @@ public function testGet() {
       $found_cached_200_response = FALSE;
       $other_cached_responses_are_4xx = TRUE;
       foreach ($cache_items as $cache_item) {
-        $cached_data = unserialize($cache_item->data);
-        if (!isset($cached_data['#cache_redirect'])) {
-          $cached_response = $cached_data['#response'];
+        $cached_response = unserialize($cache_item->data);
+        if (!$cached_response instanceof CacheRedirect) {
           if ($cached_response->getStatusCode() === 200) {
             $found_cached_200_response = TRUE;
           }
diff --git a/core/modules/shortcut/tests/src/Functional/ShortcutCacheTagsTest.php b/core/modules/shortcut/tests/src/Functional/ShortcutCacheTagsTest.php
index bcafe2c4b23d..a6626677ae6d 100644
--- a/core/modules/shortcut/tests/src/Functional/ShortcutCacheTagsTest.php
+++ b/core/modules/shortcut/tests/src/Functional/ShortcutCacheTagsTest.php
@@ -2,7 +2,7 @@
 
 namespace Drupal\Tests\shortcut\Functional;
 
-use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Url;
 use Drupal\shortcut\Entity\Shortcut;
 use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
@@ -67,18 +67,23 @@ protected function createEntity() {
    * Tests that when creating a shortcut, the shortcut set tag is invalidated.
    */
   public function testEntityCreation() {
+    $cache_bin = $this->getRenderCacheBackend();
+
     // Create a cache entry that is tagged with a shortcut set cache tag.
     $cache_tags = ['config:shortcut.set.default'];
-    \Drupal::cache('render')->set('foo', 'bar', CacheBackendInterface::CACHE_PERMANENT, $cache_tags);
+
+    $cacheability = new CacheableMetadata();
+    $cacheability->addCacheTags($cache_tags);
+    $cache_bin->set(['foo'], 'bar', $cacheability, $cacheability);
 
     // Verify a cache hit.
-    $this->verifyRenderCache('foo', $cache_tags);
+    $this->verifyRenderCache(['foo'], $cache_tags, $cacheability);
 
     // Now create a shortcut entity in that shortcut set.
     $this->createEntity();
 
     // Verify a cache miss.
-    $this->assertFalse(\Drupal::cache('render')->get('foo'), 'Creating a new shortcut invalidates the cache tag of the shortcut set.');
+    $this->assertFalse($cache_bin->get(['foo'], $cacheability), 'Creating a new shortcut invalidates the cache tag of the shortcut set.');
   }
 
   /**
@@ -102,9 +107,7 @@ public function testToolbar() {
       'config:shortcut.set.default',
       'config:system.menu.admin',
       'config:system.theme',
-      'config:user.role.authenticated',
       'rendered',
-      'user:' . $this->rootUser->id(),
     ];
     $this->assertCacheTags($expected_cache_tags);
 
diff --git a/core/modules/system/tests/src/Functional/Entity/EntityCacheTagsTestBase.php b/core/modules/system/tests/src/Functional/Entity/EntityCacheTagsTestBase.php
index 73fd3daf0778..3d4a670893a5 100644
--- a/core/modules/system/tests/src/Functional/Entity/EntityCacheTagsTestBase.php
+++ b/core/modules/system/tests/src/Functional/Entity/EntityCacheTagsTestBase.php
@@ -3,6 +3,8 @@
 namespace Drupal\Tests\system\Functional\Entity;
 
 use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheableDependencyInterface;
+use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\EventSubscriber\MainContentViewSubscriber;
 use Drupal\Core\Field\FieldStorageDefinitionInterface;
@@ -372,26 +374,22 @@ public function testReferencedEntity() {
 
     // Also verify the existence of an entity render cache entry.
     $cache_keys = ['entity_view', 'entity_test', $this->referencingEntity->id(), 'full'];
-    $cid = $this->createCacheId($cache_keys, $entity_cache_contexts);
     $access_cache_contexts = $this->getAccessCacheContextsForEntity($this->entity);
     $additional_cache_contexts = $this->getAdditionalCacheContextsForEntity($this->referencingEntity);
-    $redirected_cid = NULL;
     if (count($access_cache_contexts) || count($additional_cache_contexts)) {
       $cache_contexts = Cache::mergeContexts($entity_cache_contexts, $additional_cache_contexts);
       $cache_contexts = Cache::mergeContexts($cache_contexts, $access_cache_contexts);
-      $redirected_cid = $this->createCacheId($cache_keys, $cache_contexts);
       $context_metadata = \Drupal::service('cache_contexts_manager')->convertTokensToKeys($cache_contexts);
       $referencing_entity_cache_tags = Cache::mergeTags($referencing_entity_cache_tags, $context_metadata->getCacheTags());
     }
-    $this->verifyRenderCache($cid, $referencing_entity_cache_tags, $redirected_cid);
+    $this->verifyRenderCache($cache_keys, $referencing_entity_cache_tags, (new CacheableMetadata())->setCacheContexts($entity_cache_contexts));
 
     $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.
     $cache_keys = ['entity_view', 'entity_test', $this->nonReferencingEntity->id(), 'full'];
-    $cid = $this->createCacheId($cache_keys, $entity_cache_contexts);
-    $this->verifyRenderCache($cid, $non_referencing_entity_cache_tags);
+    $this->verifyRenderCache($cache_keys, $non_referencing_entity_cache_tags, (new CacheableMetadata())->setCacheContexts($entity_cache_contexts));
 
     // Prime the page cache for the listing of referencing entities.
     $this->verifyPageCache($listing_url, 'MISS');
@@ -620,8 +618,14 @@ public function testReferencedEntity() {
    *
    * @return string
    *   The cache ID string.
+   *
+   * @deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. There is no
+   *   replacement.
+   *
+   * @see https://www.drupal.org/node/3354596
    */
   protected function createCacheId(array $keys, array $contexts) {
+    @trigger_error(__FUNCTION__ . '() is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. There is no replacement. See: https://www.drupal.org/project/drupal/issues/2551419.', E_USER_DEPRECATED);
     $cid_parts = $keys;
 
     $contexts = \Drupal::service('cache_contexts_manager')->convertTokensToKeys($contexts);
@@ -633,38 +637,36 @@ protected function createCacheId(array $keys, array $contexts) {
   /**
    * Verify that a given render cache entry exists, with the correct cache tags.
    *
-   * @param string $cid
-   *   The render cache item ID.
+   * @param string[] $keys
+   *   The render cache item keys.
    * @param array $tags
    *   An array of expected cache tags.
-   * @param string|null $redirected_cid
-   *   (optional) The redirected render cache item ID.
+   * @param \Drupal\Core\Cache\CacheableDependencyInterface $cacheability
+   *   The initial cacheability the item was rendered with.
    */
-  protected function verifyRenderCache($cid, array $tags, $redirected_cid = NULL) {
+  protected function verifyRenderCache(array $keys, array $tags, CacheableDependencyInterface $cacheability) {
+    $cache_bin = $this->getRenderCacheBackend();
+
     // Also verify the existence of an entity render cache entry.
-    $cache_entry = \Drupal::cache('render')->get($cid);
+    $cache_entry = $cache_bin->get($keys, $cacheability);
     $this->assertInstanceOf(\stdClass::class, $cache_entry);
     sort($cache_entry->tags);
     sort($tags);
     $this->assertSame($cache_entry->tags, $tags);
-    $is_redirecting_cache_item = isset($cache_entry->data['#cache_redirect']);
-    if ($redirected_cid === NULL) {
-      $this->assertFalse($is_redirecting_cache_item, 'Render cache entry is not a redirect.');
-    }
-    else {
-      // Verify that $cid contains a cache redirect.
-      $this->assertTrue($is_redirecting_cache_item, 'Render cache entry is a redirect.');
-      // Verify that the cache redirect points to the expected CID.
-      $redirect_cache_metadata = $cache_entry->data['#cache'];
-      $actual_redirection_cid = $this->createCacheId(
-        $redirect_cache_metadata['keys'],
-        $redirect_cache_metadata['contexts']
-      );
-      $this->assertSame($redirected_cid, $actual_redirection_cid);
-      // Finally, verify that the redirected CID exists and has the same cache
-      // tags.
-      $this->verifyRenderCache($redirected_cid, $tags);
-    }
+  }
+
+  /**
+   * Retrieves the render cache backend as a variation cache.
+   *
+   * This is how Drupal\Core\Render\RenderCache uses the render cache backend.
+   *
+   * @return \Drupal\Core\Cache\VariationCacheInterface
+   *   The render cache backend as a variation cache.
+   */
+  protected function getRenderCacheBackend() {
+    /** @var \Drupal\Core\Cache\VariationCacheFactoryInterface $variation_cache_factory */
+    $variation_cache_factory = \Drupal::service('variation_cache_factory');
+    return $variation_cache_factory->get('render');
   }
 
 }
diff --git a/core/modules/system/tests/src/Functional/Entity/EntityWithUriCacheTagsTestBase.php b/core/modules/system/tests/src/Functional/Entity/EntityWithUriCacheTagsTestBase.php
index 7b497a491ea5..4a04b7a764da 100644
--- a/core/modules/system/tests/src/Functional/Entity/EntityWithUriCacheTagsTestBase.php
+++ b/core/modules/system/tests/src/Functional/Entity/EntityWithUriCacheTagsTestBase.php
@@ -3,6 +3,7 @@
 namespace Drupal\Tests\system\Functional\Entity;
 
 use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Language\LanguageInterface;
 use Drupal\field\Entity\FieldStorageConfig;
 use Drupal\field\Entity\FieldConfig;
@@ -43,16 +44,10 @@ public function testEntityUri() {
     // type supports render caching.
     if (\Drupal::entityTypeManager()->getDefinition($entity_type)->isRenderCacheable()) {
       $cache_keys = ['entity_view', $entity_type, $this->entity->id(), $view_mode];
-      $cid = $this->createCacheId($cache_keys, $entity_cache_contexts);
-      $redirected_cid = NULL;
-      $additional_cache_contexts = $this->getAdditionalCacheContextsForEntity($this->entity);
-      if (count($additional_cache_contexts)) {
-        $redirected_cid = $this->createCacheId($cache_keys, Cache::mergeContexts($entity_cache_contexts, $additional_cache_contexts));
-      }
       $expected_cache_tags = Cache::mergeTags($cache_tag, $view_cache_tag);
       $expected_cache_tags = Cache::mergeTags($expected_cache_tags, $this->getAdditionalCacheTagsForEntity($this->entity));
       $expected_cache_tags = Cache::mergeTags($expected_cache_tags, [$render_cache_tag]);
-      $this->verifyRenderCache($cid, $expected_cache_tags, $redirected_cid);
+      $this->verifyRenderCache($cache_keys, $expected_cache_tags, (new CacheableMetadata())->setCacheContexts($entity_cache_contexts));
     }
 
     // Verify that after modifying the entity, there is a cache miss.
diff --git a/core/modules/tracker/tests/src/Functional/TrackerTest.php b/core/modules/tracker/tests/src/Functional/TrackerTest.php
index 1cb6cd8a7913..8c2f82af4aed 100644
--- a/core/modules/tracker/tests/src/Functional/TrackerTest.php
+++ b/core/modules/tracker/tests/src/Functional/TrackerTest.php
@@ -101,12 +101,6 @@ public function testTrackerAll() {
     // Assert cache tags for the action/tabs blocks, visible node, and node list
     // cache tag.
     $expected_tags = Cache::mergeTags($published->getCacheTags(), $published->getOwner()->getCacheTags());
-    // Because the 'user.permissions' cache context is being optimized away.
-    $role_tags = [];
-    foreach ($this->user->getRoles() as $rid) {
-      $role_tags[] = "config:user.role.$rid";
-    }
-    $expected_tags = Cache::mergeTags($expected_tags, $role_tags);
     $block_tags = [
       'block_view',
       'local_task',
@@ -189,12 +183,6 @@ public function testTrackerUser() {
     $expected_tags = Cache::mergeTags($my_published->getCacheTags(), $my_published->getOwner()->getCacheTags());
     $expected_tags = Cache::mergeTags($expected_tags, $other_published_my_comment->getCacheTags());
     $expected_tags = Cache::mergeTags($expected_tags, $other_published_my_comment->getOwner()->getCacheTags());
-    // Because the 'user.permissions' cache context is being optimized away.
-    $role_tags = [];
-    foreach ($this->user->getRoles() as $rid) {
-      $role_tags[] = "config:user.role.$rid";
-    }
-    $expected_tags = Cache::mergeTags($expected_tags, $role_tags);
     $block_tags = [
       'block_view',
       'local_task',
diff --git a/core/modules/user/tests/src/Functional/UserBlocksTest.php b/core/modules/user/tests/src/Functional/UserBlocksTest.php
index dfc14fb0b196..be0d9cb440e7 100644
--- a/core/modules/user/tests/src/Functional/UserBlocksTest.php
+++ b/core/modules/user/tests/src/Functional/UserBlocksTest.php
@@ -96,6 +96,11 @@ public function testUserLoginBlock() {
 
     // Log out again and repeat with a non-403 page including query arguments.
     $this->drupalLogout();
+    // @todo This test should not check for cache hits. Because it does and the
+    // cache has some clever redirect logic internally, we need to request the
+    // page twice to see the cache HIT in the headers.
+    // @see https://www.drupal.org/project/drupal/issues/2551419 #154
+    $this->drupalGet('filter/tips', ['query' => ['cat' => 'dog']]);
     $this->drupalGet('filter/tips', ['query' => ['foo' => 'bar']]);
     $this->assertSession()->responseHeaderEquals(DynamicPageCacheSubscriber::HEADER, 'HIT');
     $this->submitForm($edit, 'Log in');
diff --git a/core/modules/workspaces/tests/src/Functional/WorkspaceCacheContextTest.php b/core/modules/workspaces/tests/src/Functional/WorkspaceCacheContextTest.php
index 1298093ccc21..bb2df2285f59 100644
--- a/core/modules/workspaces/tests/src/Functional/WorkspaceCacheContextTest.php
+++ b/core/modules/workspaces/tests/src/Functional/WorkspaceCacheContextTest.php
@@ -2,6 +2,7 @@
 
 namespace Drupal\Tests\workspaces\Functional;
 
+use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Tests\BrowserTestBase;
 use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
 use Drupal\workspaces\Entity\Workspace;
@@ -33,6 +34,8 @@ class WorkspaceCacheContextTest extends BrowserTestBase {
   public function testWorkspaceCacheContext() {
     $renderer = \Drupal::service('renderer');
     $cache_contexts_manager = \Drupal::service("cache_contexts_manager");
+    /** @var \Drupal\Core\Cache\VariationCacheFactoryInterface $variation_cache_factory */
+    $variation_cache_factory = $this->container->get('variation_cache_factory');
 
     // Check that the 'workspace' cache context is present when the module is
     // installed.
@@ -54,13 +57,12 @@ public function testWorkspaceCacheContext() {
     $renderer->renderRoot($build);
     $this->assertContains('workspace', $build['#cache']['contexts']);
 
-    $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts_manager->convertTokensToKeys($build['#cache']['contexts'])->getKeys());
-    $this->assertContains('[workspace]=live', $cid_parts);
+    $context_tokens = $cache_contexts_manager->convertTokensToKeys($build['#cache']['contexts'])->getKeys();
+    $this->assertContains('[workspace]=live', $context_tokens);
 
     // Test that a cache entry is created.
-    $cid = implode(':', $cid_parts);
-    $bin = $build['#cache']['bin'];
-    $this->assertInstanceOf(\stdClass::class, $this->container->get('cache.' . $bin)->get($cid));
+    $cache_bin = $variation_cache_factory->get($build['#cache']['bin']);
+    $this->assertInstanceOf(\stdClass::class, $cache_bin->get($build['#cache']['keys'], CacheableMetadata::createFromRenderArray($build)));
 
     // Switch to the 'stage' workspace and check that the correct workspace
     // cache context is used.
@@ -80,13 +82,12 @@ public function testWorkspaceCacheContext() {
     $renderer->renderRoot($build);
     $this->assertContains('workspace', $build['#cache']['contexts']);
 
-    $cid_parts = array_merge($build['#cache']['keys'], $cache_contexts_manager->convertTokensToKeys($build['#cache']['contexts'])->getKeys());
-    $this->assertContains('[workspace]=stage', $cid_parts);
+    $context_tokens = $cache_contexts_manager->convertTokensToKeys($build['#cache']['contexts'])->getKeys();
+    $this->assertContains('[workspace]=stage', $context_tokens);
 
     // Test that a cache entry is created.
-    $cid = implode(':', $cid_parts);
-    $bin = $build['#cache']['bin'];
-    $this->assertInstanceOf(\stdClass::class, $this->container->get('cache.' . $bin)->get($cid));
+    $cache_bin = $variation_cache_factory->get($build['#cache']['bin']);
+    $this->assertInstanceOf(\stdClass::class, $cache_bin->get($build['#cache']['keys'], CacheableMetadata::createFromRenderArray($build)));
   }
 
 }
diff --git a/core/phpstan-baseline.neon b/core/phpstan-baseline.neon
index 9ea58324f48c..e11a82854ed0 100644
--- a/core/phpstan-baseline.neon
+++ b/core/phpstan-baseline.neon
@@ -635,11 +635,6 @@ parameters:
 			count: 1
 			path: lib/Drupal/Core/Render/MainContent/HtmlRenderer.php
 
-		-
-			message: "#^Variable \\$cid in empty\\(\\) always exists and is not falsy\\.$#"
-			count: 1
-			path: lib/Drupal/Core/Render/RenderCache.php
-
 		-
 			message: "#^Variable \\$context in isset\\(\\) always exists and is not nullable\\.$#"
 			count: 1
diff --git a/core/tests/Drupal/Tests/Core/Cache/VariationCacheTest.php b/core/tests/Drupal/Tests/Core/Cache/VariationCacheTest.php
new file mode 100644
index 000000000000..60f50db41554
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Cache/VariationCacheTest.php
@@ -0,0 +1,506 @@
+<?php
+
+namespace Drupal\Tests\Core\Cache;
+
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Cache\CacheRedirect;
+use Drupal\Core\Cache\Context\CacheContextsManager;
+use Drupal\Core\Cache\Context\ContextCacheKeys;
+use Drupal\Core\Cache\MemoryBackend;
+use Drupal\Core\Cache\VariationCache;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * @coversDefaultClass \Drupal\Core\Cache\VariationCache
+ * @group Cache
+ */
+class VariationCacheTest extends UnitTestCase {
+
+  /**
+   * The prophesized request stack.
+   *
+   * @var \Symfony\Component\HttpFoundation\RequestStack|\Prophecy\Prophecy\ProphecyInterface
+   */
+  protected $requestStack;
+
+  /**
+   * The backend used by the variation cache.
+   *
+   * @var \Drupal\Core\Cache\MemoryBackend
+   */
+  protected $memoryBackend;
+
+  /**
+   * The prophesized cache contexts manager.
+   *
+   * @var \Drupal\Core\Cache\Context\CacheContextsManager|\Prophecy\Prophecy\ProphecyInterface
+   */
+  protected $cacheContextsManager;
+
+  /**
+   * The variation cache instance.
+   *
+   * @var \Drupal\Core\Cache\VariationCacheInterface
+   */
+  protected $variationCache;
+
+  /**
+   * The cache keys this test will store things under.
+   *
+   * @var string[]
+   */
+  protected $cacheKeys = ['your', 'housing', 'situation'];
+
+  /**
+   * The cache ID for the cache keys, without taking contexts into account.
+   *
+   * @var string
+   */
+  protected $cacheIdBase = 'your:housing:situation';
+
+  /**
+   * The simulated current user's housing type.
+   *
+   * For use in tests with cache contexts.
+   *
+   * @var string
+   */
+  protected $housingType;
+
+  /**
+   * The cacheability for something that only varies per housing type.
+   *
+   * @var \Drupal\Core\Cache\CacheableMetadata
+   */
+  protected $housingTypeCacheability;
+
+  /**
+   * The simulated current user's garden type.
+   *
+   * For use in tests with cache contexts.
+   *
+   * @var string
+   */
+  protected $gardenType;
+
+  /**
+   * The cacheability for something that varies per housing and garden type.
+   *
+   * @var \Drupal\Core\Cache\CacheableMetadata
+   */
+  protected $gardenTypeCacheability;
+
+  /**
+   * The simulated current user's house's orientation.
+   *
+   * For use in tests with cache contexts.
+   *
+   * @var string
+   */
+  protected $houseOrientation;
+
+  /**
+   * The cacheability for varying per housing, garden and orientation.
+   *
+   * @var \Drupal\Core\Cache\CacheableMetadata
+   */
+  protected $houseOrientationCacheability;
+
+  /**
+   * The simulated current user's solar panel type.
+   *
+   * For use in tests with cache contexts.
+   *
+   * @var string
+   */
+  protected $solarType;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->requestStack = $this->prophesize(RequestStack::class);
+    $this->memoryBackend = new MemoryBackend();
+    $this->cacheContextsManager = $this->prophesize(CacheContextsManager::class);
+
+    $housing_type = &$this->housingType;
+    $garden_type = &$this->gardenType;
+    $house_orientation = &$this->houseOrientation;
+    $solar_type = &$this->solarType;
+    $this->cacheContextsManager->convertTokensToKeys(Argument::any())
+      ->will(function ($args) use (&$housing_type, &$garden_type, &$house_orientation, &$solar_type) {
+        $keys = [];
+        foreach ($args[0] as $context_id) {
+          switch ($context_id) {
+            case 'house.type':
+              $keys[] = "ht.$housing_type";
+              break;
+
+            case 'garden.type':
+              $keys[] = "gt.$garden_type";
+              break;
+
+            case 'house.orientation':
+              $keys[] = "ho.$house_orientation";
+              break;
+
+            case 'solar.type':
+              $keys[] = "st.$solar_type";
+              break;
+
+            default:
+              $keys[] = $context_id;
+          }
+        }
+        return new ContextCacheKeys($keys);
+      });
+
+    $this->variationCache = new VariationCache(
+      $this->requestStack->reveal(),
+      $this->memoryBackend,
+      $this->cacheContextsManager->reveal()
+    );
+
+    $this->housingTypeCacheability = (new CacheableMetadata())
+      ->setCacheTags(['foo'])
+      ->setCacheContexts(['house.type']);
+    $this->gardenTypeCacheability = (new CacheableMetadata())
+      ->setCacheTags(['bar'])
+      ->setCacheContexts(['house.type', 'garden.type']);
+    $this->houseOrientationCacheability = (new CacheableMetadata())
+      ->setCacheTags(['baz'])
+      ->setCacheContexts(['house.type', 'garden.type', 'house.orientation']);
+  }
+
+  /**
+   * Tests a cache item that has no variations.
+   *
+   * @covers ::get
+   * @covers ::set
+   */
+  public function testNoVariations() {
+    $data = 'You have a nice house!';
+    $cacheability = (new CacheableMetadata())->setCacheTags(['bar', 'foo']);
+    $initial_cacheability = (new CacheableMetadata())->setCacheTags(['foo']);
+    $this->setVariationCacheItem($data, $cacheability, $initial_cacheability);
+    $this->assertVariationCacheItem($data, $cacheability, $initial_cacheability);
+  }
+
+  /**
+   * Tests a cache item that only ever varies by one context.
+   *
+   * @covers ::get
+   * @covers ::set
+   */
+  public function testSingleVariation() {
+    $cacheability = $this->housingTypeCacheability;
+
+    $house_data = [
+      'apartment' => 'You have a nice apartment',
+      'house' => 'You have a nice house',
+    ];
+
+    foreach ($house_data as $housing_type => $data) {
+      $this->housingType = $housing_type;
+      $this->assertVariationCacheMiss($cacheability);
+      $this->setVariationCacheItem($data, $cacheability, $cacheability);
+      $this->assertVariationCacheItem($data, $cacheability, $cacheability);
+      $this->assertCacheBackendItem("$this->cacheIdBase:ht.$housing_type", $data, $cacheability);
+    }
+  }
+
+  /**
+   * Tests a cache item that has nested variations.
+   *
+   * @covers ::get
+   * @covers ::set
+   */
+  public function testNestedVariations() {
+    // We are running this scenario in the best possible outcome: The redirects
+    // are stored in expanding order, meaning the simplest one is stored first
+    // and the nested ones are stored in subsequent ::set() calls. This means no
+    // self-healing takes place where overly specific redirects are overwritten
+    // with simpler ones.
+    $possible_outcomes = [
+      'apartment' => 'You have a nice apartment!',
+      'house|no-garden' => 'You have a nice house!',
+      'house|garden|east' => 'You have a nice house with an east-facing garden!',
+      'house|garden|south' => 'You have a nice house with a south-facing garden!',
+      'house|garden|west' => 'You have a nice house with a west-facing garden!',
+      'house|garden|north' => 'You have a nice house with a north-facing garden!',
+    ];
+
+    foreach ($possible_outcomes as $cache_context_values => $data) {
+      [$this->housingType, $this->gardenType, $this->houseOrientation] = explode('|', $cache_context_values . '||');
+
+      $cacheability = $this->housingTypeCacheability;
+      if (!empty($this->houseOrientation)) {
+        $cacheability = $this->houseOrientationCacheability;
+      }
+      elseif (!empty($this->gardenType)) {
+        $cacheability = $this->gardenTypeCacheability;
+      }
+
+      $this->assertVariationCacheMiss($this->housingTypeCacheability);
+      $this->setVariationCacheItem($data, $cacheability, $this->housingTypeCacheability);
+      $this->assertVariationCacheItem($data, $cacheability, $this->housingTypeCacheability);
+
+      $cache_id_parts = ["ht.$this->housingType"];
+      if (!empty($this->gardenType)) {
+        $this->assertCacheBackendItem($this->getSortedCacheId($cache_id_parts), new CacheRedirect($this->gardenTypeCacheability));
+        $cache_id_parts[] = "gt.$this->gardenType";
+      }
+      if (!empty($this->houseOrientation)) {
+        $this->assertCacheBackendItem($this->getSortedCacheId($cache_id_parts), new CacheRedirect($this->houseOrientationCacheability));
+        $cache_id_parts[] = "ho.$this->houseOrientation";
+      }
+
+      $this->assertCacheBackendItem($this->getSortedCacheId($cache_id_parts), $data, $cacheability);
+    }
+  }
+
+  /**
+   * Tests a cache item that has nested variations that trigger self-healing.
+   *
+   * @covers ::get
+   * @covers ::set
+   *
+   * @depends testNestedVariations
+   */
+  public function testNestedVariationsSelfHealing() {
+    // This is the worst possible scenario: A very specific item was stored
+    // first, followed by a less specific one. This means an overly specific
+    // cache redirect was stored that needs to be dumbed down. After this
+    // process, the first ::get() for the more specific item will fail as we
+    // have effectively destroyed the path to said item. Setting an item of the
+    // same specificity will restore the path for all items of said specificity.
+    $cache_id_parts = ['ht.house'];
+    $possible_outcomes = [
+      'house|garden|east' => 'You have a nice house with an east-facing garden!',
+      'house|garden|south' => 'You have a nice house with a south-facing garden!',
+      'house|garden|west' => 'You have a nice house with a west-facing garden!',
+      'house|garden|north' => 'You have a nice house with a north-facing garden!',
+    ];
+
+    foreach ($possible_outcomes as $cache_context_values => $data) {
+      [$this->housingType, $this->gardenType, $this->houseOrientation] = explode('|', $cache_context_values . '||');
+      $this->setVariationCacheItem($data, $this->houseOrientationCacheability, $this->housingTypeCacheability);
+    }
+
+    // Verify that the overly specific redirect is stored at the first possible
+    // redirect location, i.e.: The base cache ID.
+    $this->assertCacheBackendItem($this->getSortedCacheId($cache_id_parts), new CacheRedirect($this->houseOrientationCacheability));
+
+    // Store a simpler variation and verify that the first cache redirect is now
+    // the one redirecting to the simplest known outcome.
+    [$this->housingType, $this->gardenType, $this->houseOrientation] = ['house', 'no-garden', NULL];
+    $this->setVariationCacheItem('You have a nice house', $this->gardenTypeCacheability, $this->housingTypeCacheability);
+    $this->assertCacheBackendItem($this->getSortedCacheId($cache_id_parts), new CacheRedirect($this->gardenTypeCacheability));
+
+    // Verify that the previously set outcomes are all inaccessible now.
+    foreach ($possible_outcomes as $cache_context_values => $data) {
+      [$this->housingType, $this->gardenType, $this->houseOrientation] = explode('|', $cache_context_values . '||');
+      $this->assertVariationCacheMiss($this->housingTypeCacheability);
+    }
+
+    // Set at least one more specific item in the cache again.
+    $this->setVariationCacheItem($data, $this->houseOrientationCacheability, $this->housingTypeCacheability);
+
+    // Verify that the previously set outcomes are all accessible again.
+    foreach ($possible_outcomes as $cache_context_values => $data) {
+      [$this->housingType, $this->gardenType, $this->houseOrientation] = explode('|', $cache_context_values . '||');
+      $this->assertVariationCacheItem($data, $this->houseOrientationCacheability, $this->housingTypeCacheability);
+    }
+
+    // Verify that the more specific cache redirect is now stored one step after
+    // the less specific one.
+    $cache_id_parts[] = 'gt.garden';
+    $this->assertCacheBackendItem($this->getSortedCacheId($cache_id_parts), new CacheRedirect($this->houseOrientationCacheability));
+  }
+
+  /**
+   * Tests self-healing for a cache item that has split variations.
+   *
+   * @covers ::get
+   * @covers ::set
+   */
+  public function testSplitVariationsSelfHealing() {
+    // This is an edge case. Something varies by AB where some values of B
+    // trigger the whole to vary by either C, D or nothing extra. But due to an
+    // unfortunate series of requests, only ABC and ABD variations were cached.
+    //
+    // In this case, the cache should be smart enough to generate a redirect for
+    // AB, followed by redirects for ABC and ABD.
+    //
+    // For the sake of this test, we'll vary by housing and orientation, but:
+    // - Only vary by garden type for south-facing houses.
+    // - Only vary by solar panel type for north-facing houses.
+    $this->housingType = 'house';
+    $this->gardenType = 'garden';
+    $this->solarType = 'solar';
+
+    $initial_cacheability = (new CacheableMetadata())
+      ->setCacheTags(['foo'])
+      ->setCacheContexts(['house.type']);
+
+    $south_cacheability = (new CacheableMetadata())
+      ->setCacheTags(['foo'])
+      ->setCacheContexts(['house.type', 'house.orientation', 'garden.type']);
+
+    $north_cacheability = (new CacheableMetadata())
+      ->setCacheTags(['foo'])
+      ->setCacheContexts(['house.type', 'house.orientation', 'solar.type']);
+
+    $common_cacheability = (new CacheableMetadata())
+      ->setCacheContexts(['house.type', 'house.orientation']);
+
+    // Calculate the cache IDs once beforehand for readability.
+    $cache_id = $this->getSortedCacheId(['ht.house']);
+    $cache_id_north = $this->getSortedCacheId(['ht.house', 'ho.north']);
+    $cache_id_south = $this->getSortedCacheId(['ht.house', 'ho.south']);
+
+    // Set the first scenario.
+    $this->houseOrientation = 'south';
+    $this->setVariationCacheItem('You have a south-facing house with a garden!', $south_cacheability, $initial_cacheability);
+
+    // Verify that the overly specific redirect is stored at the first possible
+    // redirect location, i.e.: The base cache ID.
+    $this->assertCacheBackendItem($cache_id, new CacheRedirect($south_cacheability));
+
+    // Store a split variation, and verify that the common contexts are now used
+    // for the first cache redirect and the actual contexts for the next step of
+    // the redirect chain.
+    $this->houseOrientation = 'north';
+    $this->setVariationCacheItem('You have a north-facing house with solar panels!', $north_cacheability, $initial_cacheability);
+    $this->assertCacheBackendItem($cache_id, new CacheRedirect($common_cacheability));
+    $this->assertCacheBackendItem($cache_id_north, new CacheRedirect($north_cacheability));
+
+    // Verify that the initially set scenario is inaccessible now.
+    $this->houseOrientation = 'south';
+    $this->assertVariationCacheMiss($initial_cacheability);
+
+    // Reset the initial scenario and verify that its redirects are accessible.
+    $this->setVariationCacheItem('You have a south-facing house with a garden!', $south_cacheability, $initial_cacheability);
+    $this->assertCacheBackendItem($cache_id, new CacheRedirect($common_cacheability));
+    $this->assertCacheBackendItem($cache_id_south, new CacheRedirect($south_cacheability));
+
+    // Double-check that the split scenario redirects are left untouched.
+    $this->houseOrientation = 'north';
+    $this->assertCacheBackendItem($cache_id, new CacheRedirect($common_cacheability));
+    $this->assertCacheBackendItem($cache_id_north, new CacheRedirect($north_cacheability));
+  }
+
+  /**
+   * Tests exception for a cache item that has incompatible variations.
+   *
+   * @covers ::get
+   * @covers ::set
+   */
+  public function testIncompatibleVariationsException() {
+    // This should never happen. When someone first stores something in the
+    // cache using context A and then tries to store something using context B,
+    // something is wrong. There should always be at least one shared context at
+    // the top level or else the cache cannot do its job.
+    $this->expectException(\LogicException::class);
+    $this->expectExceptionMessage("The complete set of cache contexts for a variation cache item must contain all of the initial cache contexts, missing: garden.type.");
+
+    $this->housingType = 'house';
+    $house_cacheability = (new CacheableMetadata())
+      ->setCacheContexts(['house.type']);
+
+    $this->gardenType = 'garden';
+    $garden_cacheability = (new CacheableMetadata())
+      ->setCacheContexts(['garden.type']);
+
+    $this->setVariationCacheItem('You have a nice garden!', $garden_cacheability, $garden_cacheability);
+    $this->setVariationCacheItem('You have a nice house!', $house_cacheability, $garden_cacheability);
+  }
+
+  /**
+   * Creates the sorted cache ID from cache ID parts.
+   *
+   * When core optimizes cache contexts it returns the keys alphabetically. To
+   * make testing easier, we replicate said sorting here.
+   *
+   * @param string[] $cache_id_parts
+   *   The parts to add to the base cache ID, will be sorted.
+   *
+   * @return string
+   *   The correct cache ID.
+   */
+  protected function getSortedCacheId($cache_id_parts) {
+    sort($cache_id_parts);
+    array_unshift($cache_id_parts, $this->cacheIdBase);
+    return implode(':', $cache_id_parts);
+  }
+
+  /**
+   * Stores an item in the variation cache.
+   *
+   * @param mixed $data
+   *   The data that should be stored.
+   * @param \Drupal\Core\Cache\CacheableMetadata $cacheability
+   *   The cacheability that should be used.
+   * @param \Drupal\Core\Cache\CacheableMetadata $initial_cacheability
+   *   The initial cacheability that should be used.
+   */
+  protected function setVariationCacheItem($data, CacheableMetadata $cacheability, CacheableMetadata $initial_cacheability) {
+    $this->variationCache->set($this->cacheKeys, $data, $cacheability, $initial_cacheability);
+  }
+
+  /**
+   * Asserts that an item was properly stored in the variation cache.
+   *
+   * @param mixed $data
+   *   The data that should have been stored.
+   * @param \Drupal\Core\Cache\CacheableMetadata $cacheability
+   *   The cacheability that should have been used.
+   * @param \Drupal\Core\Cache\CacheableMetadata $initial_cacheability
+   *   The initial cacheability that should be used.
+   */
+  protected function assertVariationCacheItem($data, CacheableMetadata $cacheability, CacheableMetadata $initial_cacheability) {
+    $cache_item = $this->variationCache->get($this->cacheKeys, $initial_cacheability);
+    $this->assertNotFalse($cache_item, 'Variable data was stored and retrieved successfully.');
+    $this->assertEquals($data, $cache_item->data, 'Variable cache item contains the right data.');
+    $this->assertSame($cacheability->getCacheTags(), $cache_item->tags, 'Variable cache item uses the right cache tags.');
+  }
+
+  /**
+   * Asserts that an item could not be retrieved from the variation cache.
+   *
+   * @param \Drupal\Core\Cache\CacheableMetadata $initial_cacheability
+   *   The initial cacheability that should be used.
+   */
+  protected function assertVariationCacheMiss(CacheableMetadata $initial_cacheability) {
+    $this->assertFalse($this->variationCache->get($this->cacheKeys, $initial_cacheability), 'Nothing could be retrieved for the active cache contexts.');
+  }
+
+  /**
+   * Asserts that an item was properly stored in the cache backend.
+   *
+   * @param string $cid
+   *   The cache ID that should have been used.
+   * @param mixed $data
+   *   The data that should have been stored.
+   * @param \Drupal\Core\Cache\CacheableMetadata|null $cacheability
+   *   (optional) The cacheability that should have been used. Does not apply
+   *   when checking for cache redirects.
+   */
+  protected function assertCacheBackendItem(string $cid, $data, CacheableMetadata $cacheability = NULL) {
+    $cache_backend_item = $this->memoryBackend->get($cid);
+    $this->assertNotFalse($cache_backend_item, 'The data was stored and retrieved successfully.');
+    $this->assertEquals($data, $cache_backend_item->data, 'Cache item contains the right data.');
+
+    if ($data instanceof CacheRedirect) {
+      $this->assertSame([], $cache_backend_item->tags, 'A cache redirect does not use cache tags.');
+      $this->assertSame(-1, $cache_backend_item->expire, 'A cache redirect is stored indefinitely.');
+    }
+    else {
+      $this->assertSame($cacheability->getCacheTags(), $cache_backend_item->tags, 'Cache item uses the right cache tags.');
+    }
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php b/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php
index e9041d9deaa4..88517acc6e77 100644
--- a/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php
+++ b/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php
@@ -7,7 +7,9 @@
 
 namespace Drupal\Tests\Core\Render;
 
+use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Cache\MemoryBackend;
+use Drupal\Core\Cache\VariationCache;
 use Drupal\Core\KeyValueStore\KeyValueMemoryFactory;
 use Drupal\Core\Security\TrustedCallbackInterface;
 use Drupal\Core\State\State;
@@ -81,8 +83,8 @@ public function testContextBubblingCustomCacheBin() {
     $bin = $this->randomMachineName();
 
     $this->setUpRequest();
-    $this->memoryCache = new MemoryBackend();
-    $custom_cache = new MemoryBackend();
+    $this->memoryCache = new VariationCache($this->requestStack, new MemoryBackend(), $this->cacheContextsManager);
+    $custom_cache = new VariationCache($this->requestStack, new MemoryBackend(), $this->cacheContextsManager);
 
     $this->cacheFactory->expects($this->atLeastOnce())
       ->method('get')
@@ -115,15 +117,14 @@ public function testContextBubblingCustomCacheBin() {
     ];
     $this->renderer->renderRoot($build);
 
-    $this->assertRenderCacheItem('parent:foo', [
-      '#cache_redirect' => TRUE,
+    $this->assertRenderCacheItem(['parent'], [
+      '#attached' => [],
       '#cache' => [
-        'keys' => ['parent'],
-        'contexts' => ['foo', 'bar'],
+        'contexts' => ['bar', 'foo'],
         'tags' => [],
-        'bin' => $bin,
         'max-age' => 3600,
       ],
+      '#markup' => 'parent',
     ], $bin);
   }
 
@@ -134,7 +135,7 @@ public function testContextBubblingCustomCacheBin() {
    *
    * @dataProvider providerTestContextBubblingEdgeCases
    */
-  public function testContextBubblingEdgeCases(array $element, array $expected_top_level_contexts, array $expected_cache_items) {
+  public function testContextBubblingEdgeCases(array $element, array $expected_top_level_contexts, $expected_cache_item) {
     $this->setUpRequest();
     $this->setupMemoryCache();
     $this->cacheContextsManager->expects($this->any())
@@ -144,9 +145,7 @@ public function testContextBubblingEdgeCases(array $element, array $expected_top
     $this->renderer->renderRoot($element);
 
     $this->assertEqualsCanonicalizing($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);
-    }
+    $this->assertRenderCacheItem($element['#cache']['keys'], $expected_cache_item);
   }
 
   public function providerTestContextBubblingEdgeCases() {
@@ -167,18 +166,16 @@ public function providerTestContextBubblingEdgeCases() {
         ],
       ],
     ];
-    $expected_cache_items = [
-      'parent' => [
-        '#attached' => [],
-        '#cache' => [
-          'contexts' => [],
-          'tags' => [],
-          'max-age' => Cache::PERMANENT,
-        ],
-        '#markup' => 'parent',
+    $expected_cache_item = [
+      '#attached' => [],
+      '#cache' => [
+        'contexts' => [],
+        'tags' => [],
+        'max-age' => Cache::PERMANENT,
       ],
+      '#markup' => 'parent',
     ];
-    $data[] = [$test_element, [], $expected_cache_items];
+    $data[] = [$test_element, [], $expected_cache_item];
 
     // Assert cache contexts are sorted when they are used to generate a CID.
     // (Necessary to ensure that different render arrays where the same keys +
@@ -190,16 +187,14 @@ public function providerTestContextBubblingEdgeCases() {
         'contexts' => [],
       ],
     ];
-    $expected_cache_items = [
-      'set_test:bar:baz:foo' => [
-        '#attached' => [],
-        '#cache' => [
-          'contexts' => [],
-          'tags' => [],
-          'max-age' => Cache::PERMANENT,
-        ],
-        '#markup' => '',
+    $expected_cache_item = [
+      '#attached' => [],
+      '#cache' => [
+        'contexts' => [],
+        'tags' => [],
+        'max-age' => Cache::PERMANENT,
       ],
+      '#markup' => '',
     ];
     $context_orders = [
       ['foo', 'bar', 'baz'],
@@ -211,8 +206,8 @@ public function providerTestContextBubblingEdgeCases() {
     ];
     foreach ($context_orders as $context_order) {
       $test_element['#cache']['contexts'] = $context_order;
-      $expected_cache_items['set_test:bar:baz:foo']['#cache']['contexts'] = $context_order;
-      $data[] = [$test_element, $context_order, $expected_cache_items];
+      $expected_cache_item['#cache']['contexts'] = $context_order;
+      $data[] = [$test_element, $context_order, $expected_cache_item];
     }
 
     // A parent with a certain set of cache contexts is unaffected by a child
@@ -230,18 +225,16 @@ public function providerTestContextBubblingEdgeCases() {
         ],
       ],
     ];
-    $expected_cache_items = [
-      'parent:bar:baz:foo' => [
-        '#attached' => [],
-        '#cache' => [
-          'contexts' => ['foo', 'bar', 'baz'],
-          'tags' => [],
-          'max-age' => 3600,
-        ],
-        '#markup' => 'parent',
+    $expected_cache_item = [
+      '#attached' => [],
+      '#cache' => [
+        'contexts' => ['foo', 'bar', 'baz'],
+        'tags' => [],
+        'max-age' => 3600,
       ],
+      '#markup' => 'parent',
     ];
-    $data[] = [$test_element, ['bar', 'baz', 'foo'], $expected_cache_items];
+    $data[] = [$test_element, ['bar', 'baz', 'foo'], $expected_cache_item];
 
     // 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
@@ -267,29 +260,16 @@ public function providerTestContextBubblingEdgeCases() {
         '#markup' => '',
       ],
     ];
-    $expected_cache_items = [
-      'parent:foo' => [
-        '#cache_redirect' => TRUE,
-        '#cache' => [
-          // The keys + contexts this redirects to.
-          'keys' => ['parent'],
-          'contexts' => ['foo', 'bar'],
-          'tags' => ['yar', 'har', 'fiddle', 'dee'],
-          'bin' => 'render',
-          'max-age' => Cache::PERMANENT,
-        ],
-      ],
-      'parent:bar:foo' => [
-        '#attached' => [],
-        '#cache' => [
-          'contexts' => ['foo', 'bar'],
-          'tags' => ['yar', 'har', 'fiddle', 'dee'],
-          'max-age' => Cache::PERMANENT,
-        ],
-        '#markup' => 'parent',
+    $expected_cache_item = [
+      '#attached' => [],
+      '#cache' => [
+        'contexts' => ['foo', 'bar'],
+        'tags' => ['yar', 'har', 'fiddle', 'dee'],
+        'max-age' => Cache::PERMANENT,
       ],
+      '#markup' => 'parent',
     ];
-    $data[] = [$test_element, ['bar', 'foo'], $expected_cache_items];
+    $data[] = [$test_element, ['bar', 'foo'], $expected_cache_item];
 
     // Ensure that bubbleable metadata has been collected from children and set
     // correctly to the main level of the render array. That ensures that correct
@@ -314,24 +294,25 @@ public function providerTestContextBubblingEdgeCases() {
         ],
       ],
     ];
-    $expected_cache_items = [
-      'parent:foo' => [
-        '#attached' => ['library' => ['foo/bar']],
-        '#cache' => [
-          'contexts' => ['foo'],
-          'tags' => ['yar', 'har', 'fiddle', 'dee'],
-          'max-age' => Cache::PERMANENT,
-        ],
-        '#markup' => 'parent',
+    $expected_cache_item = [
+      '#attached' => ['library' => ['foo/bar']],
+      '#cache' => [
+        'contexts' => ['foo'],
+        'tags' => ['yar', 'har', 'fiddle', 'dee'],
+        'max-age' => Cache::PERMANENT,
       ],
+      '#markup' => 'parent',
     ];
-    $data[] = [$test_element, ['foo'], $expected_cache_items];
+    $data[] = [$test_element, ['foo'], $expected_cache_item];
 
     return $data;
   }
 
   /**
    * Tests the self-healing of the redirect with conditional cache contexts.
+   *
+   * @todo Revisit now that we have self-healing tests for VariationCache. This
+   * is essentially a clone of the other bubbling tests now.
    */
   public function testConditionalCacheContextBubblingSelfHealing() {
     $current_user_role = &$this->currentUserRole;
@@ -382,17 +363,7 @@ public function testConditionalCacheContextBubblingSelfHealing() {
     $element = $test_element;
     $current_user_role = 'A';
     $this->renderer->renderRoot($element);
-    $this->assertRenderCacheItem('parent', [
-      '#cache_redirect' => TRUE,
-      '#cache' => [
-        'keys' => ['parent'],
-        'contexts' => ['user.roles'],
-        'tags' => ['a', 'b'],
-        'bin' => 'render',
-        'max-age' => Cache::PERMANENT,
-      ],
-    ]);
-    $this->assertRenderCacheItem('parent:r.A', [
+    $this->assertRenderCacheItem(['parent'], [
       '#attached' => [],
       '#cache' => [
         'contexts' => ['user.roles'],
@@ -407,17 +378,7 @@ public function testConditionalCacheContextBubblingSelfHealing() {
     $element = $test_element;
     $current_user_role = 'B';
     $this->renderer->renderRoot($element);
-    $this->assertRenderCacheItem('parent', [
-      '#cache_redirect' => TRUE,
-      '#cache' => [
-        'keys' => ['parent'],
-        'contexts' => ['user.roles', 'foo'],
-        'tags' => ['a', 'b', 'c'],
-        'bin' => 'render',
-        'max-age' => 1800,
-      ],
-    ]);
-    $this->assertRenderCacheItem('parent:foo:r.B', [
+    $this->assertRenderCacheItem(['parent'], [
       '#attached' => [],
       '#cache' => [
         'contexts' => ['user.roles', 'foo'],
@@ -427,60 +388,25 @@ public function testConditionalCacheContextBubblingSelfHealing() {
       '#markup' => 'parent',
     ]);
 
-    // Request 3: role A again, the grandchild is inaccessible again => bubbled
-    // cache contexts: user.roles; 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 'user.roles' cache context and both the 'foo'
-    // and 'user.roles' cache contexts, resulting in a cache miss every time.)
-    $element = $test_element;
+    // Verify that request 1 is still cached and accessible.
     $current_user_role = 'A';
-    $this->renderer->renderRoot($element);
-    $this->assertRenderCacheItem('parent', [
-      '#cache_redirect' => TRUE,
-      '#cache' => [
-        'keys' => ['parent'],
-        'contexts' => ['user.roles', 'foo'],
-        'tags' => ['a', 'b', 'c'],
-        'bin' => 'render',
-        'max-age' => 1800,
-      ],
-    ]);
-    $this->assertRenderCacheItem('parent:foo:r.A', [
+    $this->assertRenderCacheItem(['parent'], [
       '#attached' => [],
       '#cache' => [
-        'contexts' => ['user.roles', 'foo'],
+        'contexts' => ['user.roles'],
         'tags' => ['a', 'b'],
-        // Note that the max-age here is unaffected. When role A, the grandchild
-        // is never rendered, so neither is its max-age of 1800 present here,
-        // despite 1800 being the max-age of the redirecting cache item.
         'max-age' => Cache::PERMANENT,
       ],
       '#markup' => 'parent',
     ]);
 
-    // Request 4: role C, both the grandchild and the grandgrandchild are
+    // Request 3: role C, both the grandchild and the grandgrandchild are
     // accessible => bubbled cache contexts: foo, bar, user.roles + merged
     // max-age: 300.
     $element = $test_element;
     $current_user_role = 'C';
     $this->renderer->renderRoot($element);
-    $final_parent_cache_item = [
-      '#cache_redirect' => TRUE,
-      '#cache' => [
-        'keys' => ['parent'],
-        'contexts' => ['user.roles', 'foo', 'bar'],
-        'tags' => ['a', 'b', 'c', 'd'],
-        'bin' => 'render',
-        'max-age' => 300,
-      ],
-    ];
-    $this->assertRenderCacheItem('parent', $final_parent_cache_item);
-    $this->assertRenderCacheItem('parent:bar:foo:r.C', [
+    $this->assertRenderCacheItem(['parent'], [
       '#attached' => [],
       '#cache' => [
         'contexts' => ['user.roles', 'foo', 'bar'],
@@ -490,39 +416,24 @@ public function testConditionalCacheContextBubblingSelfHealing() {
       '#markup' => 'parent',
     ]);
 
-    // Request 5: role A again, verifying the merging like we did for request 3.
-    $element = $test_element;
+    // Verify that request 2 and 3 are still cached and accessible.
     $current_user_role = 'A';
-    $this->renderer->renderRoot($element);
-    $this->assertRenderCacheItem('parent', $final_parent_cache_item);
-    $this->assertRenderCacheItem('parent:bar:foo:r.A', [
+    $this->assertRenderCacheItem(['parent'], [
       '#attached' => [],
       '#cache' => [
-        'contexts' => ['user.roles', 'foo', 'bar'],
+        'contexts' => ['user.roles'],
         'tags' => ['a', 'b'],
-        // Note that the max-age here is unaffected. When role A, the grandchild
-        // is never rendered, so neither is its max-age of 1800 present here,
-        // nor the grandgrandchild's max-age of 300, despite 300 being the
-        // max-age of the redirecting cache item.
         'max-age' => Cache::PERMANENT,
       ],
       '#markup' => 'parent',
     ]);
 
-    // Request 6: role B again, verifying the merging like we did for request 3.
-    $element = $test_element;
     $current_user_role = 'B';
-    $this->renderer->renderRoot($element);
-    $this->assertRenderCacheItem('parent', $final_parent_cache_item);
-    $this->assertRenderCacheItem('parent:bar:foo:r.B', [
+    $this->assertRenderCacheItem(['parent'], [
       '#attached' => [],
       '#cache' => [
-        'contexts' => ['user.roles', 'foo', 'bar'],
+        'contexts' => ['user.roles', 'foo'],
         'tags' => ['a', 'b', 'c'],
-        // Note that the max-age here is unaffected. When role B, the
-        // grandgrandchild is never rendered, so neither is its max-age of 300
-        // present here, despite 300 being the max-age of the redirecting cache
-        // item.
         'max-age' => 1800,
       ],
       '#markup' => 'parent',
@@ -559,7 +470,13 @@ 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' => ['contexts' => [], 'tags' => []]]);
+    $cacheability = new CacheableMetadata();
+    $this->memoryCache->set(
+      ['cached_nested'],
+      ['#markup' => 'Cached nested!', '#attached' => [], '#cache' => ['contexts' => [], 'tags' => []]],
+      $cacheability,
+      $cacheability
+    );
 
     // Simulate the rendering of an entire response (i.e. a root call).
     $output = $this->renderer->renderRoot($test_element);
diff --git a/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php b/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php
index 79fa3f51447f..6b531e85f526 100644
--- a/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php
+++ b/core/tests/Drupal/Tests/Core/Render/RendererPlaceholdersTest.php
@@ -10,6 +10,7 @@
 use Drupal\Component\Utility\Crypt;
 use Drupal\Component\Utility\Html;
 use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Render\Markup;
 use Drupal\Core\Security\TrustedCallbackInterface;
 use Drupal\Core\Render\RenderContext;
@@ -299,15 +300,11 @@ public function providerPlaceholders() {
     $element_with_cache_keys = $base_element_a3;
     $element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
     $expected_placeholder_render_array['#cache']['keys'] = $keys;
-    // The CID parts here consist of the cache keys plus the 'user' cache
-    // context, which in this unit test is simply the given cache context token,
-    // see \Drupal\Tests\Core\Render\RendererTestBase::setUp().
-    $cid_parts = array_merge($keys, ['user']);
     $cases[] = [
       $element_with_cache_keys,
       $args,
       $expected_placeholder_render_array,
-      $cid_parts,
+      $keys,
       [],
       [],
       [
@@ -547,33 +544,16 @@ protected function generatePlaceholderElement() {
   }
 
   /**
-   * @param false|array $cid_parts
-   *   The cid parts.
-   * @param string[] $bubbled_cache_contexts
-   *   Additional cache contexts that were bubbled when the placeholder was
-   *   rendered.
+   * @param false|array $cache_keys
+   *   The cache keys.
    * @param array $expected_data
    *   A render array with the expected values.
    *
    * @internal
    */
-  protected function assertPlaceholderRenderCache($cid_parts, array $bubbled_cache_contexts, array $expected_data): void {
-    if ($cid_parts !== FALSE) {
-      if ($bubbled_cache_contexts) {
-        // Verify render cached placeholder.
-        $cached_element = $this->memoryCache->get(implode(':', $cid_parts))->data;
-        $expected_redirect_element = [
-          '#cache_redirect' => TRUE,
-          '#cache' => $expected_data['#cache'] + [
-            'keys' => $cid_parts,
-            'bin' => 'render',
-          ],
-        ];
-        $this->assertEquals($expected_redirect_element, $cached_element, 'The correct cache redirect exists.');
-      }
-
-      // Verify render cached placeholder.
-      $cached = $this->memoryCache->get(implode(':', array_merge($cid_parts, $bubbled_cache_contexts)));
+  protected function assertPlaceholderRenderCache($cache_keys, array $expected_data) {
+    if ($cache_keys !== FALSE) {
+      $cached = $this->memoryCache->get($cache_keys, CacheableMetadata::createFromRenderArray($expected_data));
       $cached_element = $cached->data;
       $this->assertEquals($expected_data, $cached_element, 'The correct data is cached: the stored #markup and #attached properties are not affected by the placeholder being replaced.');
     }
@@ -585,8 +565,8 @@ protected function assertPlaceholderRenderCache($cid_parts, array $bubbled_cache
    *
    * @dataProvider providerPlaceholders
    */
-  public function testUncacheableParent($element, $args, array $expected_placeholder_render_array, $placeholder_cid_parts, array $bubbled_cache_contexts, array $bubbled_cache_tags, array $placeholder_expected_render_cache_array) {
-    if ($placeholder_cid_parts) {
+  public function testUncacheableParent($element, $args, array $expected_placeholder_render_array, $placeholder_cache_keys, array $bubbled_cache_contexts, array $bubbled_cache_tags, array $placeholder_expected_render_cache_array) {
+    if ($placeholder_cache_keys) {
       $this->setupMemoryCache();
     }
     else {
@@ -605,7 +585,7 @@ public function testUncacheableParent($element, $args, array $expected_placehold
       'dynamic_animal' => $args[0],
     ];
     $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.');
-    $this->assertPlaceholderRenderCache($placeholder_cid_parts, $bubbled_cache_contexts, $placeholder_expected_render_cache_array);
+    $this->assertPlaceholderRenderCache($placeholder_cache_keys, $placeholder_expected_render_cache_array);
   }
 
   /**
@@ -613,11 +593,10 @@ public function testUncacheableParent($element, $args, array $expected_placehold
    * @covers ::doRender
    * @covers \Drupal\Core\Render\RenderCache::get
    * @covers \Drupal\Core\Render\RenderCache::set
-   * @covers \Drupal\Core\Render\RenderCache::createCacheID
    *
    * @dataProvider providerPlaceholders
    */
-  public function testCacheableParent($test_element, $args, array $expected_placeholder_render_array, $placeholder_cid_parts, array $bubbled_cache_contexts, array $bubbled_cache_tags, array $placeholder_expected_render_cache_array) {
+  public function testCacheableParent($test_element, $args, array $expected_placeholder_render_array, $placeholder_cache_keys, array $bubbled_cache_contexts, array $bubbled_cache_tags, array $placeholder_expected_render_cache_array) {
     $element = $test_element;
     $this->setupMemoryCache();
 
@@ -640,10 +619,10 @@ public function testCacheableParent($test_element, $args, array $expected_placeh
       'dynamic_animal' => $args[0],
     ];
     $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.');
-    $this->assertPlaceholderRenderCache($placeholder_cid_parts, $bubbled_cache_contexts, $placeholder_expected_render_cache_array);
+    $this->assertPlaceholderRenderCache($placeholder_cache_keys, $placeholder_expected_render_cache_array);
 
     // GET request: validate cached data.
-    $cached = $this->memoryCache->get('placeholder_test_GET');
+    $cached = $this->memoryCache->get(['placeholder_test_GET'], CacheableMetadata::createFromRenderArray($test_element));
     // There are three edge cases, where the shape of the render cache item for
     // the parent (with CID 'placeholder_test_GET') is vastly different. These
     // are the cases where:
@@ -664,19 +643,6 @@ public function testCacheableParent($test_element, $args, array $expected_placeh
     // due to the bubbled cache contexts it creates a cache redirect.
     if ($edge_case_a6_uncacheable) {
       $cached_element = $cached->data;
-      $expected_redirect = [
-        '#cache_redirect' => TRUE,
-        '#cache' => [
-          'keys' => ['placeholder_test_GET'],
-          'contexts' => ['user'],
-          'tags' => [],
-          'max-age' => Cache::PERMANENT,
-          'bin' => 'render',
-        ],
-      ];
-      $this->assertEquals($expected_redirect, $cached_element);
-      // Follow the redirect.
-      $cached_element = $this->memoryCache->get('placeholder_test_GET:' . implode(':', $bubbled_cache_contexts))->data;
       $expected_element = [
         '#markup' => '<p>#cache enabled, GET</p><p>This is a rendered placeholder!</p>',
         '#attached' => [
@@ -786,7 +752,7 @@ public function testCacheableParentWithPostRequest($test_element, $args) {
 
     // Even when the child element's placeholder is cacheable, it should not
     // generate a render cache item.
-    $this->assertPlaceholderRenderCache(FALSE, [], []);
+    $this->assertPlaceholderRenderCache(FALSE, []);
   }
 
   /**
@@ -1025,7 +991,7 @@ public function testRenderChildrenPlaceholdersDifferentArguments() {
     $this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the ones added by each placeholder #lazy_builder callback exist.');
 
     // GET request: validate cached data.
-    $cached_element = $this->memoryCache->get('test:renderer:children_placeholders')->data;
+    $cached_element = $this->memoryCache->get(['test', 'renderer', 'children_placeholders'], CacheableMetadata::createFromRenderArray($element))->data;
     $expected_element = [
       '#attached' => [
         'drupalSettings' => [
diff --git a/core/tests/Drupal/Tests/Core/Render/RendererTest.php b/core/tests/Drupal/Tests/Core/Render/RendererTest.php
index 7a2a7eedfbb5..c94caa1a2caf 100644
--- a/core/tests/Drupal/Tests/Core/Render/RendererTest.php
+++ b/core/tests/Drupal/Tests/Core/Render/RendererTest.php
@@ -11,6 +11,7 @@
 use Drupal\Core\Access\AccessResult;
 use Drupal\Core\Access\AccessResultInterface;
 use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Security\TrustedCallbackInterface;
 use Drupal\Core\Render\Element;
 use Drupal\Core\Render\Markup;
@@ -795,7 +796,6 @@ public function testRenderWithThemeArguments() {
    * @covers ::doRender
    * @covers \Drupal\Core\Render\RenderCache::get
    * @covers \Drupal\Core\Render\RenderCache::set
-   * @covers \Drupal\Core\Render\RenderCache::createCacheID
    */
   public function testRenderCache() {
     $this->setUpRequest();
@@ -839,7 +839,7 @@ public function testRenderCache() {
     $this->assertEquals($expected_tags, $element['#cache']['tags'], 'Cache tags were collected from the element and its subchild.');
 
     // The cache item also has a 'rendered' cache tag.
-    $cache_item = $this->cacheFactory->get('render')->get('render_cache_test:en:stark');
+    $cache_item = $this->cacheFactory->get('render')->get(['render_cache_test'], CacheableMetadata::createFromRenderArray($element));
     $this->assertSame(Cache::mergeTags($expected_tags, ['rendered']), $cache_item->tags);
   }
 
@@ -848,7 +848,6 @@ public function testRenderCache() {
    * @covers ::doRender
    * @covers \Drupal\Core\Render\RenderCache::get
    * @covers \Drupal\Core\Render\RenderCache::set
-   * @covers \Drupal\Core\Render\RenderCache::createCacheID
    *
    * @dataProvider providerTestRenderCacheMaxAge
    */
@@ -865,7 +864,7 @@ public function testRenderCacheMaxAge($max_age, $is_render_cached, $render_cache
     ];
     $this->renderer->renderRoot($element);
 
-    $cache_item = $this->cacheFactory->get('render')->get('render_cache_test:en:stark');
+    $cache_item = $this->cacheFactory->get('render')->get(['render_cache_test'], CacheableMetadata::createFromRenderArray($element));
     if (!$is_render_cached) {
       $this->assertFalse($cache_item);
     }
@@ -893,7 +892,6 @@ public function providerTestRenderCacheMaxAge() {
    * @covers ::doRender
    * @covers \Drupal\Core\Render\RenderCache::get
    * @covers \Drupal\Core\Render\RenderCache::set
-   * @covers \Drupal\Core\Render\RenderCache::createCacheID
    * @covers \Drupal\Core\Render\RenderCache::getCacheableRenderArray
    *
    * @dataProvider providerTestRenderCacheProperties
@@ -918,7 +916,7 @@ public function testRenderCacheProperties(array $expected_results) {
     $this->renderer->renderRoot($element);
 
     $cache = $this->cacheFactory->get('render');
-    $data = $cache->get('render_cache_test:en:stark')->data;
+    $data = $cache->get(['render_cache_test'], CacheableMetadata::createFromRenderArray($element))->data;
 
     // Check that parent markup is ignored when caching children's markup.
     $this->assertEquals($data['#markup'] === '', (bool) Element::children($data));
diff --git a/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php b/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php
index 4419f04ceb40..631fccc56110 100644
--- a/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php
+++ b/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php
@@ -8,12 +8,14 @@
 namespace Drupal\Tests\Core\Render;
 
 use Drupal\Core\Cache\Cache;
+use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Cache\Context\ContextCacheKeys;
 use Drupal\Core\Cache\MemoryBackend;
-use Drupal\Core\Security\TrustedCallbackInterface;
+use Drupal\Core\Cache\VariationCache;
 use Drupal\Core\Render\PlaceholderGenerator;
 use Drupal\Core\Render\PlaceholderingRenderCache;
 use Drupal\Core\Render\Renderer;
+use Drupal\Core\Security\TrustedCallbackInterface;
 use Drupal\Tests\UnitTestCase;
 use Symfony\Component\DependencyInjection\ContainerBuilder;
 use Symfony\Component\HttpFoundation\Request;
@@ -51,7 +53,7 @@ abstract class RendererTestBase extends UnitTestCase {
   protected $requestStack;
 
   /**
-   * @var \Drupal\Core\Cache\CacheFactoryInterface|\PHPUnit\Framework\MockObject\MockObject
+   * @var \Drupal\Core\Cache\VariationCacheFactoryInterface|\PHPUnit\Framework\MockObject\MockObject
    */
   protected $cacheFactory;
 
@@ -82,7 +84,7 @@ abstract class RendererTestBase extends UnitTestCase {
   protected $elementInfo;
 
   /**
-   * @var \Drupal\Core\Cache\CacheBackendInterface
+   * @var \Drupal\Core\Cache\VariationCacheInterface
    */
   protected $memoryCache;
 
@@ -142,11 +144,16 @@ protected function setUp(): void {
     $request = new Request();
     $request->server->set('REQUEST_TIME', $_SERVER['REQUEST_TIME']);
     $this->requestStack->push($request);
-    $this->cacheFactory = $this->createMock('Drupal\Core\Cache\CacheFactoryInterface');
+    $this->cacheFactory = $this->createMock('Drupal\Core\Cache\VariationCacheFactoryInterface');
     $this->cacheContextsManager = $this->getMockBuilder('Drupal\Core\Cache\Context\CacheContextsManager')
       ->disableOriginalConstructor()
       ->getMock();
     $this->cacheContextsManager->method('assertValidTokens')->willReturn(TRUE);
+    $this->cacheContextsManager->expects($this->any())
+      ->method('optimizeTokens')
+      ->willReturnCallback(function ($context_tokens) {
+        return $context_tokens;
+      });
     $current_user_role = &$this->currentUserRole;
     $this->cacheContextsManager->expects($this->any())
       ->method('convertTokensToKeys')
@@ -172,7 +179,7 @@ protected function setUp(): void {
         }
         return new ContextCacheKeys($keys);
       });
-    $this->placeholderGenerator = new PlaceholderGenerator($this->rendererConfig);
+    $this->placeholderGenerator = new PlaceholderGenerator($this->cacheContextsManager, $this->rendererConfig);
     $this->renderCache = new PlaceholderingRenderCache($this->requestStack, $this->cacheFactory, $this->cacheContextsManager, $this->placeholderGenerator);
     $this->renderer = new Renderer($this->controllerResolver, $this->themeManager, $this->elementInfo, $this->placeholderGenerator, $this->renderCache, $this->requestStack, $this->rendererConfig);
 
@@ -214,7 +221,7 @@ protected function setUpUnusedCache() {
    * Sets up a memory-based render cache back-end.
    */
   protected function setupMemoryCache() {
-    $this->memoryCache = $this->memoryCache ?: new MemoryBackend();
+    $this->memoryCache = $this->memoryCache ?: new VariationCache($this->requestStack, new MemoryBackend(), $this->cacheContextsManager);
 
     $this->cacheFactory->expects($this->atLeastOnce())
       ->method('get')
@@ -238,27 +245,27 @@ protected function setUpRequest($method = 'GET') {
   /**
    * Asserts a render cache item.
    *
-   * @param string $cid
-   *   The expected cache ID.
+   * @param string[] $keys
+   *   The expected cache keys.
    * @param mixed $data
    *   The expected data for that cache ID.
    * @param string $bin
    *   The expected cache bin.
    */
-  protected function assertRenderCacheItem($cid, $data, $bin = 'render') {
+  protected function assertRenderCacheItem($keys, $data, $bin = 'render') {
     $cache_backend = $this->cacheFactory->get($bin);
-    $cached = $cache_backend->get($cid);
-    $this->assertNotFalse($cached, sprintf('Expected cache item "%s" exists.', $cid));
+    $cached = $cache_backend->get($keys, CacheableMetadata::createFromRenderArray($data));
+    $this->assertNotFalse($cached, sprintf('Expected cache item "%s" exists.', implode(':', $keys)));
     if ($cached !== FALSE) {
       $this->assertEqualsCanonicalizing(array_keys($data), array_keys($cached->data), 'The cache item contains the same parent array keys.');
       foreach ($data as $key => $value) {
         // We do not want to assert on the order of cacheability information.
         // @see https://www.drupal.org/project/drupal/issues/3225328
         if ($key === '#cache') {
-          $this->assertEqualsCanonicalizing($value, $cached->data[$key], sprintf('Cache item "%s" has the expected data.', $cid));
+          $this->assertEqualsCanonicalizing($value, $cached->data[$key], sprintf('Cache item "%s" has the expected data.', implode(':', $keys)));
         }
         else {
-          $this->assertEquals($value, $cached->data[$key], sprintf('Cache item "%s" has the expected data.', $cid));
+          $this->assertEquals($value, $cached->data[$key], sprintf('Cache item "%s" has the expected data.', implode(':', $keys)));
         }
       }
       $this->assertEqualsCanonicalizing(Cache::mergeTags($data['#cache']['tags'], ['rendered']), $cached->tags, "The cache item's cache tags also has the 'rendered' cache tag.");
-- 
GitLab