diff --git a/core/lib/Drupal/Core/Render/Renderer.php b/core/lib/Drupal/Core/Render/Renderer.php
index 5fb494e52b9c80f63950f088ce29b85c9afe1ca6..ed9270a999e4e0e1fcfc920090d07ad344131604 100644
--- a/core/lib/Drupal/Core/Render/Renderer.php
+++ b/core/lib/Drupal/Core/Render/Renderer.php
@@ -39,6 +39,13 @@ class Renderer implements RendererInterface {
    */
   protected $elementInfo;
 
+  /**
+   * The stack containing bubbleable rendering metadata.
+   *
+   * @var \SplStack|null
+   */
+  protected static $stack;
+
   /**
    * Constructs a new Renderer.
    *
@@ -65,36 +72,18 @@ public function renderRoot(&$elements) {
   /**
    * {@inheritdoc}
    */
-  public function render(&$elements, $is_root_call = FALSE) {
-    static $stack;
-
-    $update_stack = function(&$element) use (&$stack) {
-      // The latest frame represents the bubbleable data for the subtree.
-      $frame = $stack->top();
-      // Update the frame, but also update the current element, to ensure it
-      // contains up-to-date information in case it gets render cached.
-      $frame->tags = $element['#cache']['tags'] = Cache::mergeTags($element['#cache']['tags'], $frame->tags);
-      $frame->attached = $element['#attached'] = drupal_merge_attached($element['#attached'], $frame->attached);
-      $frame->postRenderCache = $element['#post_render_cache'] = NestedArray::mergeDeep($element['#post_render_cache'], $frame->postRenderCache);
-    };
-
-    $bubble_stack = function() use (&$stack) {
-      // If there's only one frame on the stack, then this is the root call, and
-      // we can't bubble up further. Reset the stack for the next root call.
-      if ($stack->count() === 1) {
-        $stack = NULL;
-        return;
-      }
-
-      // Merge the current and the parent stack frame.
-      $current = $stack->pop();
-      $parent = $stack->pop();
-      $current->tags = Cache::mergeTags($current->tags, $parent->tags);
-      $current->attached = drupal_merge_attached($current->attached, $parent->attached);
-      $current->postRenderCache = NestedArray::mergeDeep($current->postRenderCache, $parent->postRenderCache);
-      $stack->push($current);
-    };
+  public function renderPlain(&$elements) {
+    $current_stack = self::$stack;
+    $this->resetStack();
+    $output = $this->renderRoot($elements);
+    self::$stack = $current_stack;
+    return $output;
+  }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function render(&$elements, $is_root_call = FALSE) {
     if (!isset($elements['#access']) && isset($elements['#access_callback'])) {
       if (is_string($elements['#access_callback']) && strpos($elements['#access_callback'], '::') === FALSE) {
         $elements['#access_callback'] = $this->controllerResolver->getControllerFromDefinition($elements['#access_callback']);
@@ -112,10 +101,10 @@ public function render(&$elements, $is_root_call = FALSE) {
       return '';
     }
 
-    if (!isset($stack)) {
-      $stack = new \SplStack();
+    if (!isset(self::$stack)) {
+      self::$stack = new \SplStack();
     }
-    $stack->push(new RenderStackFrame());
+    self::$stack->push(new RenderStackFrame());
 
     // Try to fetch the prerendered element from cache, run any
     // #post_render_cache callbacks and return the final markup.
@@ -132,10 +121,10 @@ public function render(&$elements, $is_root_call = FALSE) {
         $elements['#markup'] = SafeMarkup::set($elements['#markup']);
         // The render cache item contains all the bubbleable rendering metadata
         // for the subtree.
-        $update_stack($elements);
+        $this->updateStack($elements);
         // Render cache hit, so rendering is finished, all necessary info
         // collected!
-        $bubble_stack();
+        $this->bubbleStack();
         return $elements['#markup'];
       }
     }
@@ -168,7 +157,7 @@ public function render(&$elements, $is_root_call = FALSE) {
         }
         catch (\Exception $e) {
           // Reset stack and re-throw exception.
-          $stack = NULL;
+          $this->resetStack();
           throw $e;
         }
       }
@@ -183,9 +172,9 @@ public function render(&$elements, $is_root_call = FALSE) {
     if (!empty($elements['#printed'])) {
       // The #printed element contains all the bubbleable rendering metadata for
       // the subtree.
-      $update_stack($elements);
+      $this->updateStack($elements);
       // #printed, so rendering is finished, all necessary info collected!
-      $bubble_stack();
+      $this->bubbleStack();
       return '';
     }
 
@@ -311,7 +300,7 @@ public function render(&$elements, $is_root_call = FALSE) {
     $elements['#markup'] = $prefix . $elements['#children'] . $suffix;
 
     // We've rendered this element (and its subtree!), now update the stack.
-    $update_stack($elements);
+    $this->updateStack($elements);
 
     // Cache the processed element if #cache is set.
     if (isset($elements['#cache'])) {
@@ -328,31 +317,83 @@ public function render(&$elements, $is_root_call = FALSE) {
     // Only the case of a cache hit when #cache is enabled, is not handled here,
     // that is handled earlier in Renderer::render().
     if ($is_root_call) {
-      // We've already called $update_stack() earlier, which updated both the
+      // We've already called ::updateStack() earlier, which updated both the
       // element and current stack frame. However,
       // Renderer::processPostRenderCache() can both change the element
       // further and create and render new child elements, so provide a fresh
       // stack frame to collect those additions, merge them back to the element,
       // and then update the current frame to match the modified element state.
-      $stack->push(new RenderStackFrame());
+      self::$stack->push(new RenderStackFrame());
       $this->processPostRenderCache($elements);
-      $post_render_additions = $stack->pop();
+      $post_render_additions = self::$stack->pop();
       $elements['#cache']['tags'] = Cache::mergeTags($elements['#cache']['tags'], $post_render_additions->tags);
       $elements['#attached'] = drupal_merge_attached($elements['#attached'], $post_render_additions->attached);
       $elements['#post_render_cache'] = NestedArray::mergeDeep($elements['#post_render_cache'], $post_render_additions->postRenderCache);
-      if ($stack->count() !== 1) {
+      if (self::$stack->count() !== 1) {
         throw new \LogicException('A stray drupal_render() invocation with $is_root_call = TRUE is causing bubbling of attached assets to break.');
       }
     }
 
     // Rendering is finished, all necessary info collected!
-    $bubble_stack();
+    $this->bubbleStack();
 
     $elements['#printed'] = TRUE;
     $elements['#markup'] = SafeMarkup::set($elements['#markup']);
     return $elements['#markup'];
   }
 
+  /**
+   * Resets the renderer service's internal stack (used for bubbling metadata).
+   *
+   * Only necessary in very rare/advanced situations, such as when rendering an
+   * error page if an exception occurred *during* rendering.
+   */
+  protected function resetStack() {
+    self::$stack = NULL;
+  }
+
+  /**
+   * Updates the stack.
+   *
+   * @param array &$element
+   *   The element of the render array that has just been rendered. The stack
+   *   frame for this element will be updated with the bubbleable rendering
+   *   metadata of this element.
+   */
+  protected function updateStack(&$element) {
+    // The latest frame represents the bubbleable metadata for the subtree.
+    $frame = self::$stack->top();
+    // Update the frame, but also update the current element, to ensure it
+    // contains up-to-date information in case it gets render cached.
+    $frame->tags = $element['#cache']['tags'] = Cache::mergeTags($element['#cache']['tags'], $frame->tags);
+    $frame->attached = $element['#attached'] = drupal_merge_attached($element['#attached'], $frame->attached);
+    $frame->postRenderCache = $element['#post_render_cache'] = NestedArray::mergeDeep($element['#post_render_cache'], $frame->postRenderCache);
+  }
+
+  /**
+   * Bubbles the stack.
+   *
+   * Whenever another level in the render array has been rendered, the stack
+   * must be bubbled, to merge its rendering metadata with that of the parent
+   * element.
+   */
+  protected function bubbleStack() {
+    // If there's only one frame on the stack, then this is the root call, and
+    // we can't bubble up further. Reset the stack for the next root call.
+    if (self::$stack->count() === 1) {
+      $this->resetStack();
+      return;
+    }
+
+    // Merge the current and the parent stack frame.
+    $current = self::$stack->pop();
+    $parent = self::$stack->pop();
+    $current->tags = Cache::mergeTags($current->tags, $parent->tags);
+    $current->attached = drupal_merge_attached($current->attached, $parent->attached);
+    $current->postRenderCache = NestedArray::mergeDeep($current->postRenderCache, $parent->postRenderCache);
+    self::$stack->push($current);
+  }
+
   /**
    * Processes #post_render_cache callbacks.
    *
diff --git a/core/lib/Drupal/Core/Render/RendererInterface.php b/core/lib/Drupal/Core/Render/RendererInterface.php
index a6ec2ed16d83f412070b6c70a5c80d870893549b..97f09b6c7fb0cc10c04947e9b4156b8aa6550456 100644
--- a/core/lib/Drupal/Core/Render/RendererInterface.php
+++ b/core/lib/Drupal/Core/Render/RendererInterface.php
@@ -33,6 +33,34 @@ interface RendererInterface {
    */
   public function renderRoot(&$elements);
 
+  /**
+   * Renders final HTML in situations where no assets are needed.
+   *
+   * Calls ::render() in such a way that #post_render_cache callbacks are
+   * applied.
+   *
+   * Useful for e.g. rendering the values of tokens or e-mails, which need a
+   * render array being turned into a string, but don't need any of the
+   * bubbleable metadata (the attached assets the cache tags).
+   *
+   * Some of these are a relatively common use case and happen *within* a
+   * ::renderRoot() call, but that is generally highly problematic (and hence an
+   * exception is thrown when a ::renderRoot() call happens within another
+   * ::renderRoot() call). However, in this case, we only care about the output,
+   * not about the bubbling. Hence this uses a separate render stack, to not
+   * affect the parent ::renderRoot() call.
+   *
+   * @param array $elements
+   *   The structured array describing the data to be rendered.
+   *
+   * @return string
+   *   The rendered HTML.
+   *
+   * @see ::renderRoot()
+   * @see ::render()
+   */
+  public function renderPlain(&$elements);
+
   /**
    * Renders HTML given a structured array tree.
    *
diff --git a/core/modules/filter/filter.module b/core/modules/filter/filter.module
index 25fb2749dd145debab6975482849fe8940989a88..538467ac970838740c54beb049410a4fcd9d82a6 100644
--- a/core/modules/filter/filter.module
+++ b/core/modules/filter/filter.module
@@ -336,7 +336,7 @@ function check_markup($text, $format_id = NULL, $langcode = '', $filter_types_to
     '#filter_types_to_skip' => $filter_types_to_skip,
     '#langcode' => $langcode,
   );
-  return drupal_render_root($build);
+  return \Drupal::service('renderer')->renderPlain($build);
 }
 
 /**
diff --git a/core/modules/system/src/Tests/Common/RenderTest.php b/core/modules/system/src/Tests/Common/RenderTest.php
index 739e3b5d4e0a27889850e6a3d9f5de2228f6dabd..523ab62823e0b7f9b45ad109e367c85afedf3ed3 100644
--- a/core/modules/system/src/Tests/Common/RenderTest.php
+++ b/core/modules/system/src/Tests/Common/RenderTest.php
@@ -1197,4 +1197,106 @@ public function testDrupalProcessAttached() {
     }
   }
 
+  /**
+   * Tests \Drupal\Core\Render\Renderer::renderPlain().
+   */
+  public function testRenderPlain() {
+    $renderer = \Drupal::service('renderer');
+
+    $complex_child_markup = '<p>Imagine this is a render array for an entity.</p>';
+    $parent_markup = '<p>Rendered!</p>';
+
+    $complex_child_template = [
+      '#markup' => $complex_child_markup,
+      '#attached' => [
+        'library' => [
+          'core/drupal',
+        ],
+      ],
+      '#cache' => [
+        'tags' => [
+          'test:complex_child',
+        ],
+      ],
+      '#post_render_cache' => [
+        'common_test_post_render_cache' => [
+          ['foo' => $this->randomString()],
+        ],
+      ],
+    ];
+
+    // Case 1: ::renderRoot() with nested ::renderRoot().
+    $this->pass('Renderer::renderRoot() may not be called inside of another Renderer::renderRoot() call, this must trigger an exception.');
+    try {
+      $complex_child = $complex_child_template;
+      $page = [
+        'content' => [
+          '#pre_render' => [
+            function () use ($renderer, $complex_child) {
+              $renderer->renderRoot($complex_child);
+            }
+          ],
+          '#suffix' => $parent_markup,
+        ]
+      ];
+      $renderer->renderRoot($page);
+      $this->fail('No exception triggered.');
+    }
+    catch (\LogicException $e) {
+      $this->pass('Exception triggered.');
+    }
+
+    // Case 2: ::renderRoot() with nested ::render().
+    $this->pass('Renderer::render() may be called from anywhere, including from inside of another Renderer::renderRoot() call. Bubbling must be performed.');
+    try {
+      $complex_child = $complex_child_template;
+      $page = [
+        'content' => [
+          '#pre_render' => [
+            function ($elements) use ($renderer, $complex_child, $complex_child_markup, $parent_markup) {
+              $elements['#markup'] = $renderer->render($complex_child);
+              $this->assertEqual($complex_child_markup, $elements['#markup'], 'Rendered complex child output as expected, without the #post_render_cache callback executed.');
+              return $elements;
+            }
+          ],
+          '#suffix' => $parent_markup,
+        ]
+      ];
+      $output = $renderer->renderRoot($page);
+      $this->pass('No exception triggered.');
+      $this->assertEqual('<p>overridden</p>', $output, 'Rendered output as expected, with the #post_render_cache callback executed.');
+      $this->assertTrue(in_array('test:complex_child', $page['#cache']['tags']), 'Cache tag bubbling performed.');
+      $this->assertTrue(in_array('core/drupal', $page['#attached']['library']), 'Asset bubbling performed.');
+    }
+    catch (\LogicException $e) {
+      $this->fail('Exception triggered.');
+    }
+
+    // Case 3: ::renderRoot() with nested ::renderPlain().
+    $this->pass('Renderer::renderPlain() may be called from anywhere, including from inside of another Renderer::renderRoot() call.');
+    try {
+      $complex_child = $complex_child_template;
+      $page = [
+        'content' => [
+          '#pre_render' => [
+            function ($elements) use ($renderer, $complex_child, $parent_markup) {
+              $elements['#markup'] = $renderer->renderPlain($complex_child);
+              $this->assertEqual('<p>overridden</p>', $elements['#markup'], 'Rendered complex child output as expected, with the #post_render_cache callback executed.');
+              return $elements;
+            }
+          ],
+          '#suffix' => $parent_markup,
+        ]
+      ];
+      $output = $renderer->renderRoot($page);
+      $this->pass('No exception triggered.');
+      $this->assertEqual('<p>overridden</p>' . $parent_markup, $output, 'Rendered output as expected, with the #post_render_cache callback executed.');
+      $this->assertFalse(in_array('test:complex_child', $page['#cache']['tags']), 'Cache tag bubbling not performed.');
+      $this->assertTrue(empty($page['#attached']), 'Asset bubbling not performed.');
+    }
+    catch (\LogicException $e) {
+      $this->fail('Exception triggered.');
+    }
+  }
+
 }