 core/authorize.php                                 |  6 +-
 core/includes/common.inc                           | 77 ++++++++++++++++++----
 core/includes/theme.inc                            |  2 +-
 core/lib/Drupal/Core/Ajax/AjaxResponseRenderer.php | 10 +--
 .../Drupal/Core/Controller/DialogController.php    |  2 +-
 .../Entity/Controller/EntityViewController.php     |  2 +-
 .../Drupal/Core/EventSubscriber/ViewSubscriber.php |  2 +-
 .../Core/Page/DefaultHtmlFragmentRenderer.php      |  6 +-
 .../Drupal/Core/Page/DefaultHtmlPageRenderer.php   |  6 +-
 core/lib/Drupal/Core/Page/RenderHtmlRenderer.php   | 10 +--
 .../aggregator/src/Plugin/views/row/Rss.php        |  2 +-
 core/modules/ckeditor/ckeditor.module              | 75 +++++++++++++++++++++
 .../src/Plugin/CKEditorPlugin/Internal.php         | 33 ++--------
 core/modules/ckeditor/src/Tests/CKEditorTest.php   |  8 +++
 .../modules/comment/src/CommentPostRenderCache.php |  3 +-
 core/modules/comment/src/Plugin/views/row/Rss.php  |  4 +-
 core/modules/contact/contact.module                |  4 +-
 core/modules/filter/filter.module                  |  2 +-
 core/modules/filter/src/Tests/FilterAPITest.php    |  2 +-
 core/modules/node/node.module                      |  4 +-
 core/modules/node/src/Plugin/views/row/Rss.php     |  4 +-
 .../rest/src/Plugin/views/display/RestExport.php   |  2 +-
 .../rest/src/Tests/Views/StyleSerializerTest.php   |  2 +-
 .../system/src/Controller/BatchController.php      |  6 +-
 .../modules/system/src/Tests/Common/RenderTest.php | 34 +++++-----
 .../system/src/Tests/Common/RenderWebTest.php      |  2 +-
 .../src/Plugin/views/cache/CachePluginBase.php     |  2 +-
 .../views/src/Plugin/views/display/Feed.php        |  4 +-
 .../views/src/Plugin/views/row/RssFields.php       |  2 +-
 .../Tests/Core/Ajax/AjaxResponseRendererTest.php   |  2 +-
 30 files changed, 215 insertions(+), 105 deletions(-)

diff --git a/core/authorize.php b/core/authorize.php
index a676ee1..4b0f249 100644
--- a/core/authorize.php
+++ b/core/authorize.php
@@ -104,7 +104,7 @@ function authorize_access_allowed() {
       '#theme' => 'authorize_report',
       '#messages' => $results['messages'],
     );
-    $output = drupal_render($authorize_report);
+    $output = drupal_render_root($authorize_report);
 
     $links = array();
     if (is_array($results['tasks'])) {
@@ -122,7 +122,7 @@ function authorize_access_allowed() {
       '#items' => $links,
       '#title' => t('Next steps'),
     );
-    $output .= drupal_render($item_list);
+    $output .= drupal_render_root($item_list);
   }
   // If a batch is running, let it run.
   elseif ($request->query->has('batch')) {
@@ -135,7 +135,7 @@ function authorize_access_allowed() {
     elseif (!$batch = batch_get()) {
       // We have a batch to process, show the filetransfer form.
       $elements = \Drupal::formBuilder()->getForm('Drupal\Core\FileTransfer\Form\FileTransferAuthorizeForm');
-      $output = drupal_render($elements);
+      $output = drupal_render_root($elements);
     }
   }
   // We defer the display of messages until all operations are done.
diff --git a/core/includes/common.inc b/core/includes/common.inc
index eedd47a..0382f01 100644
--- a/core/includes/common.inc
+++ b/core/includes/common.inc
@@ -2531,6 +2531,29 @@ function drupal_prepare_page($page) {
 }
 
 /**
+ * Renders final HTML given a structured array tree.
+ *
+ * Calls drupal_render() in such a way that #post_render_cache callbacks are
+ * applied.
+ *
+ * Should therefore only be used in occasions where the final rendering is
+ * happening, just before sending a Response:
+ * - system internals that are responsible for rendering the final HTML
+ * - render arrays for non-HTML responses, such as feeds
+ *
+ * @param array $elements
+ *   The structured array describing the data to be rendered.
+ *
+ * @return string
+ *   The rendered HTML.
+ *
+ * @see drupal_render()
+ */
+function drupal_render_root(&$elements) {
+  return drupal_render($elements, TRUE);
+}
+
+/**
  * Renders HTML given a structured array tree.
  *
  * Renderable arrays have two kinds of key/value pairs: properties and children.
@@ -2706,18 +2729,29 @@ function drupal_prepare_page($page) {
  *
  * @param array $elements
  *   The structured array describing the data to be rendered.
- * @param bool $is_recursive_call
- *   Whether this is a recursive call or not, for internal use.
+ * @param bool $is_root_call
+ *   (Internal use only.) Whether this is a recursive call or not. See
+ *   drupal_render_root().
  *
  * @return string
  *   The rendered HTML.
  *
+ * @throws \LogicException
+ *   If a root call to drupal_render() does not result in an empty stack, this
+ *   indicates an erroneous drupal_render() root call (a root call within a root
+ *   call, which makes no sense). Therefore, a logic exception is thrown.
+ * @throws \Exception
+ *   If a #pre_render callback throws an exception, it is caught to reset the
+ *   stack used for bubbling rendering metadata, and then the exception is re-
+ *   thrown.
+ *
  * @see element_info()
  * @see _theme()
  * @see drupal_process_states()
  * @see drupal_process_attached()
+ * @see drupal_render_root()
  */
-function drupal_render(&$elements, $is_recursive_call = FALSE) {
+function drupal_render(&$elements, $is_root_call = FALSE) {
   static $stack;
 
   $update_stack = function(&$element) use (&$stack) {
@@ -2732,9 +2766,9 @@ function drupal_render(&$elements, $is_recursive_call = FALSE) {
 
   $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.
+    // we can't bubble up further. Reset the stack for the next root call.
     if ($stack->count() === 1) {
-      $stack->pop();
+      $stack = NULL;
       return;
     }
 
@@ -2776,10 +2810,10 @@ function drupal_render(&$elements, $is_recursive_call = FALSE) {
     $cached_element = drupal_render_cache_get($elements);
     if ($cached_element !== FALSE) {
       $elements = $cached_element;
-      // Only when we're not in a recursive drupal_render() call,
+      // Only when we're not in a root (non-recursive) drupal_render() call,
       // #post_render_cache callbacks must be executed, to prevent breaking the
       // render cache in case of nested elements with #cache set.
-      if (!$is_recursive_call) {
+      if ($is_root_call) {
         _drupal_render_process_post_render_cache($elements);
       }
       $elements['#markup'] = SafeMarkup::set($elements['#markup']);
@@ -2806,7 +2840,23 @@ function drupal_render(&$elements, $is_recursive_call = FALSE) {
       if (is_string($callable) && strpos($callable, '::') === FALSE) {
         $callable = $controller_resolver->getControllerFromDefinition($callable);
       }
-      $elements = call_user_func($callable, $elements);
+      // Since #pre_render callbacks may be used for generating a render array's
+      // content, and we might be rendering the main content for the page, it is
+      // possible that a #pre_render callback throws an exception that will
+      // cause a different page to be rendered (e.g. throwing
+      // \Symfony\Component\HttpKernel\Exception\NotFoundHttpException will
+      // cause the 404 page to be rendered). That page might also use
+      // drupal_render(), but if exceptions aren't caught here, the stack will
+      // be left in an inconsistent state.
+      // Hence, catch all exceptions and reset the stack and re-throw them.
+      try {
+        $elements = call_user_func($callable, $elements);
+      }
+      catch (\Exception $e) {
+        // Reset stack and re-throw exception.
+        $stack = NULL;
+        throw $e;
+      }
     }
   }
 
@@ -2865,7 +2915,7 @@ function drupal_render(&$elements, $is_recursive_call = FALSE) {
   // process as drupal_render_children() but is inlined for speed.
   if ((!$theme_is_implemented || isset($elements['#render_children'])) && empty($elements['#children'])) {
     foreach ($children as $key) {
-      $elements['#children'] .= drupal_render($elements[$key], TRUE);
+      $elements['#children'] .= drupal_render($elements[$key]);
     }
     $elements['#children'] = SafeMarkup::set($elements['#children']);
   }
@@ -2939,7 +2989,7 @@ function drupal_render(&$elements, $is_recursive_call = FALSE) {
     drupal_render_cache_set($elements['#markup'], $elements);
   }
 
-  // Only when we're not in a recursive drupal_render() call,
+  // Only when we're in a root (non-recursive) drupal_render() call,
   // #post_render_cache callbacks must be executed, to prevent breaking the
   // render cache in case of nested elements with #cache set.
   //
@@ -2948,7 +2998,7 @@ function drupal_render(&$elements, $is_recursive_call = FALSE) {
   // - they run when #cache is enabled and there is a cache miss.
   // Only the case of a cache hit when #cache is enabled, is not handled here,
   // that is handled earlier in drupal_render().
-  if (!$is_recursive_call) {
+  if ($is_root_call) {
     // We've already called $update_stack() earlier, which updated both the
     // element and current stack frame. However,
     // _drupal_render_process_post_render_cache() can both change the element
@@ -2961,6 +3011,9 @@ function drupal_render(&$elements, $is_recursive_call = FALSE) {
     $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) {
+      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!
@@ -3025,7 +3078,7 @@ function render(&$element) {
       return $element['#markup'];
     }
     show($element);
-    return drupal_render($element, TRUE);
+    return drupal_render($element);
   }
   else {
     // Safe-guard for inappropriate use of render() on flat variables: return
diff --git a/core/includes/theme.inc b/core/includes/theme.inc
index 303db67..e520354 100644
--- a/core/includes/theme.inc
+++ b/core/includes/theme.inc
@@ -379,7 +379,7 @@ function _theme($hook, $variables = array()) {
     // that we're preprocessing variables for.
     if (isset($variables['#attached'])) {
       $preprocess_attached = ['#attached' => $variables['#attached']];
-      drupal_render($preprocess_attached, TRUE);
+      drupal_render($preprocess_attached);
     }
   }
 
diff --git a/core/lib/Drupal/Core/Ajax/AjaxResponseRenderer.php b/core/lib/Drupal/Core/Ajax/AjaxResponseRenderer.php
index bb55433..ab9aa8e 100644
--- a/core/lib/Drupal/Core/Ajax/AjaxResponseRenderer.php
+++ b/core/lib/Drupal/Core/Ajax/AjaxResponseRenderer.php
@@ -58,14 +58,14 @@ public function render($content) {
       }
     }
 
-    $html = $this->drupalRender($content);
+    $html = $this->drupalRenderRoot($content);
 
     // The selector for the insert command is NULL as the new content will
     // replace the element making the Ajax call. The default 'replaceWith'
     // behavior can be changed with #ajax['method'].
     $response->addCommand(new InsertCommand(NULL, $html));
     $status_messages = array('#theme' => 'status_messages');
-    $output = $this->drupalRender($status_messages);
+    $output = $this->drupalRenderRoot($status_messages);
     if (!empty($output)) {
       $response->addCommand(new PrependCommand(NULL, $output));
     }
@@ -73,12 +73,12 @@ public function render($content) {
   }
 
   /**
-   * Wraps drupal_render().
+   * Wraps drupal_render_root().
    *
    * @todo: Remove as part of https://drupal.org/node/2182149
    */
-  protected function drupalRender(&$elements, $is_recursive_call = FALSE) {
-    $output = drupal_render($elements, $is_recursive_call);
+  protected function drupalRenderRoot(&$elements) {
+    $output = drupal_render_root($elements);
     drupal_process_attached($elements);
     return $output;
   }
diff --git a/core/lib/Drupal/Core/Controller/DialogController.php b/core/lib/Drupal/Core/Controller/DialogController.php
index 48279e5..6aef7fa 100644
--- a/core/lib/Drupal/Core/Controller/DialogController.php
+++ b/core/lib/Drupal/Core/Controller/DialogController.php
@@ -96,7 +96,7 @@ public function dialog(Request $request, RouteMatchInterface $route_match, $_con
       );
     }
 
-    $content = drupal_render($page_content);
+    $content = drupal_render_root($page_content);
     drupal_process_attached($page_content);
     $title = isset($page_content['#title']) ? $page_content['#title'] : $this->titleResolver->getTitle($request, $route_match->getRouteObject());
     $response = new AjaxResponse();
diff --git a/core/lib/Drupal/Core/Entity/Controller/EntityViewController.php b/core/lib/Drupal/Core/Entity/Controller/EntityViewController.php
index 1ef08c9..2b55e24 100644
--- a/core/lib/Drupal/Core/Entity/Controller/EntityViewController.php
+++ b/core/lib/Drupal/Core/Entity/Controller/EntityViewController.php
@@ -79,7 +79,7 @@ public function view(EntityInterface $_entity, $view_mode = 'full', $langcode =
         $build = $this->entityManager->getTranslationFromContext($_entity)
           ->get($label_field)
           ->view($view_mode);
-        $page['#title'] = drupal_render($build, TRUE);
+        $page['#title'] = drupal_render($build);
       }
     }
 
diff --git a/core/lib/Drupal/Core/EventSubscriber/ViewSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/ViewSubscriber.php
index 84b94c9..7f13698 100644
--- a/core/lib/Drupal/Core/EventSubscriber/ViewSubscriber.php
+++ b/core/lib/Drupal/Core/EventSubscriber/ViewSubscriber.php
@@ -116,7 +116,7 @@ public function onView(GetResponseForControllerResultEvent $event) {
         $page_result['#title'] = $this->titleResolver->getTitle($request, $request->attributes->get(RouteObjectInterface::ROUTE_OBJECT));
       }
 
-      $event->setResponse(new Response(drupal_render($page_result)));
+      $event->setResponse(new Response(drupal_render_root($page_result)));
     }
   }
 
diff --git a/core/lib/Drupal/Core/Page/DefaultHtmlFragmentRenderer.php b/core/lib/Drupal/Core/Page/DefaultHtmlFragmentRenderer.php
index 8a22986..2d3ca15 100644
--- a/core/lib/Drupal/Core/Page/DefaultHtmlFragmentRenderer.php
+++ b/core/lib/Drupal/Core/Page/DefaultHtmlFragmentRenderer.php
@@ -55,9 +55,9 @@ public function render(HtmlFragmentInterface $fragment, $status_code = 200) {
     // Build the HtmlPage object.
     $page = new HtmlPage('', array(), $fragment->getTitle());
     $page = $this->preparePage($page, $page_array);
-    $page->setBodyTop(drupal_render($page_array['page_top']));
-    $page->setBodyBottom(drupal_render($page_array['page_bottom']));
-    $page->setContent(drupal_render($page_array));
+    $page->setBodyTop(drupal_render_root($page_array['page_top']));
+    $page->setBodyBottom(drupal_render_root($page_array['page_bottom']));
+    $page->setContent(drupal_render_root($page_array));
     $page->setStatusCode($status_code);
 
     drupal_process_attached($page_array);
diff --git a/core/lib/Drupal/Core/Page/DefaultHtmlPageRenderer.php b/core/lib/Drupal/Core/Page/DefaultHtmlPageRenderer.php
index bc189d3..eea3e16 100644
--- a/core/lib/Drupal/Core/Page/DefaultHtmlPageRenderer.php
+++ b/core/lib/Drupal/Core/Page/DefaultHtmlPageRenderer.php
@@ -108,9 +108,9 @@ public static function renderPage($main, $title = '', $theme = 'maintenance', ar
     //   available in hook_page_alter(), so that HTML attributes can be altered.
     $page = \Drupal::service('html_fragment_renderer')->preparePage($page, $page_array);
 
-    $page->setBodyTop(drupal_render($page_array['page_top']));
-    $page->setBodyBottom(drupal_render($page_array['page_bottom']));
-    $page->setContent(drupal_render($page_array));
+    $page->setBodyTop(drupal_render_root($page_array['page_top']));
+    $page->setBodyBottom(drupal_render_root($page_array['page_bottom']));
+    $page->setContent(drupal_render_root($page_array));
     drupal_process_attached($page_array);
     if (isset($page_array['page_top'])) {
       drupal_process_attached($page_array['page_top']);
diff --git a/core/lib/Drupal/Core/Page/RenderHtmlRenderer.php b/core/lib/Drupal/Core/Page/RenderHtmlRenderer.php
index 6954f06..bc88bc3 100644
--- a/core/lib/Drupal/Core/Page/RenderHtmlRenderer.php
+++ b/core/lib/Drupal/Core/Page/RenderHtmlRenderer.php
@@ -39,7 +39,7 @@ public function __construct(UrlGeneratorInterface $url_generator) {
    * {@inheritdoc}
    */
   public function render(array $render_array) {
-    $content = $this->drupalRender($render_array);
+    $content = $this->drupalRenderRoot($render_array);
     if (!empty($render_array)) {
       drupal_process_attached($render_array);
     }
@@ -78,11 +78,11 @@ public function render(array $render_array) {
   }
 
   /**
-   * Wraps drupal_render().
+   * Wraps drupal_render_root().
    *
-   * @todo: Convert drupal_render into a proper injectable service.
+   * @todo: Convert drupal_render_root into a proper injectable service.
    */
-  protected function drupalRender(&$elements, $is_recursive_call = FALSE) {
-    return drupal_render($elements, $is_recursive_call);
+  protected function drupalRenderRoot(&$elements) {
+    return drupal_render_root($elements);
   }
 }
diff --git a/core/modules/aggregator/src/Plugin/views/row/Rss.php b/core/modules/aggregator/src/Plugin/views/row/Rss.php
index d699d55..2b6dba1 100644
--- a/core/modules/aggregator/src/Plugin/views/row/Rss.php
+++ b/core/modules/aggregator/src/Plugin/views/row/Rss.php
@@ -103,7 +103,7 @@ public function render($row) {
       '#options' => $this->options,
       '#row' => $item,
     );
-    return drupal_render($build);
+    return drupal_render_root($build);
   }
 
 }
diff --git a/core/modules/ckeditor/ckeditor.module b/core/modules/ckeditor/ckeditor.module
index d1476d9..6f35ca3 100644
--- a/core/modules/ckeditor/ckeditor.module
+++ b/core/modules/ckeditor/ckeditor.module
@@ -96,3 +96,78 @@ function _ckeditor_theme_css($theme = NULL) {
   }
   return $css;
 }
+
+/**
+ * Implements hook_ENTITY_TYPE_update().
+ *
+ * Recalculates the 'format_tags' CKEditor setting when a text format is added.
+ *
+ * @see \Drupal\ckeditor\Plugin\CKEditorPlugin\Internal::generateFormatTagsSetting()
+ * @see ckeditor_rebuild()
+ */
+function ckeditor_filter_format_insert() {
+  ckeditor_rebuild();
+}
+
+/**
+ * Implements hook_ENTITY_TYPE_update().
+ *
+ * Recalculates the 'format_tags' CKEditor setting when a text format changes.
+ *
+ * @see \Drupal\ckeditor\Plugin\CKEditorPlugin\Internal::generateFormatTagsSetting()
+ * @see ckeditor_rebuild()
+ */
+function ckeditor_filter_format_update() {
+  ckeditor_rebuild();
+}
+
+/**
+ * Implements hook_rebuild().
+ *
+ * Calculates the 'format_tags' CKEditor setting for each text format.
+ *
+ * If this wouldn't happen in hook_rebuild(), then the first drupal_render()
+ * call that occurs for a page that contains a #type 'text_format' element will
+ * cause the CKEditor::getJSSettings() to be called, which will cause
+ * Internal::generateFormatTagsSetting() to be called, which calls
+ * check_markup(), which finally calls drupal_render() non-recursively, because
+ * a filter might apply #post_render_cache callbacks.
+ * This would be a root call inside a root call, which breaks the stack-based
+ * logic for bubbling rendering metadata.
+ * Therefore this pre-calculates the needed values, and hence performs the
+ * check_markup() calls outside of a drupal_render() call tree.
+ *
+ * @see \Drupal\ckeditor\Plugin\CKEditorPlugin\Internal::generateFormatTagsSetting()
+ * @see ckeditor_filter_format_insert()
+ * @see ckeditor_filter_format_update()
+ */
+function ckeditor_rebuild() {
+  /** @var \Drupal\filter\FilterFormatInterface[] $formats */
+  $formats = filter_formats();
+
+  foreach ($formats as $format) {
+    $key = 'ckeditor_internal_format_tags:' . $format->id();
+
+    // The <p> tag is always allowed — HTML without <p> tags is nonsensical.
+    $format_tags = array('p');
+
+    // Given the list of possible format tags, automatically determine whether
+    // the current text format allows this tag, and thus whether it should show
+    // up in the "Format" dropdown.
+    $possible_format_tags = array('h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre');
+    foreach ($possible_format_tags as $tag) {
+      $input = '<' . $tag . '>TEST</' . $tag . '>';
+      $output = trim(check_markup($input, $format->id()));
+      if ($input == $output) {
+        $format_tags[] = $tag;
+      }
+    }
+    $format_tags = implode(';', $format_tags);
+
+    // Cache the "format_tags" configuration. This cache item is infinitely
+    // valid; it only changes whenever the text format is changed, which is
+    // guaranteed by the hook_ENTITY_TYPE_update() and hook_ENTITY_TYPE_insert()
+    // hook implementations.
+    \Drupal::state()->set($key, $format_tags);
+  }
+}
diff --git a/core/modules/ckeditor/src/Plugin/CKEditorPlugin/Internal.php b/core/modules/ckeditor/src/Plugin/CKEditorPlugin/Internal.php
index d64c806..8dfbc00 100644
--- a/core/modules/ckeditor/src/Plugin/CKEditorPlugin/Internal.php
+++ b/core/modules/ckeditor/src/Plugin/CKEditorPlugin/Internal.php
@@ -302,6 +302,9 @@ public function getButtons() {
    *
    * @return array
    *   An array containing the "format_tags" configuration.
+   *
+   * @see ckeditor_rebuild()
+   * @see ckeditor_filter_format_update()
    */
   protected function generateFormatTagsSetting(Editor $editor) {
     // When no text format is associated yet, assume no tag is allowed.
@@ -311,35 +314,7 @@ protected function generateFormatTagsSetting(Editor $editor) {
     }
 
     $format = $editor->getFilterFormat();
-    $cid = 'ckeditor_internal_format_tags:' . $format->id();
-
-    if ($cached = $this->cache->get($cid)) {
-      $format_tags = $cached->data;
-    }
-    else {
-      // The <p> tag is always allowed — HTML without <p> tags is nonsensical.
-      $format_tags = array('p');
-
-      // Given the list of possible format tags, automatically determine whether
-      // the current text format allows this tag, and thus whether it should show
-      // up in the "Format" dropdown.
-      $possible_format_tags = array('h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre');
-      foreach ($possible_format_tags as $tag) {
-        $input = '<' . $tag . '>TEST</' . $tag . '>';
-        $output = trim(check_markup($input, $editor->id()));
-        if ($input == $output) {
-          $format_tags[] = $tag;
-        }
-      }
-      $format_tags = implode(';', $format_tags);
-
-      // Cache the "format_tags" configuration. This cache item is infinitely
-      // valid; it only changes whenever the text format is changed, hence it's
-      // tagged with the text format's cache tag.
-      $this->cache->set($cid, $format_tags, Cache::PERMANENT, $format->getCacheTags());
-    }
-
-    return $format_tags;
+    return \Drupal::state()->get('ckeditor_internal_format_tags:' . $format->id());
   }
 
   /**
diff --git a/core/modules/ckeditor/src/Tests/CKEditorTest.php b/core/modules/ckeditor/src/Tests/CKEditorTest.php
index 5d7f90c..46390a4 100644
--- a/core/modules/ckeditor/src/Tests/CKEditorTest.php
+++ b/core/modules/ckeditor/src/Tests/CKEditorTest.php
@@ -103,6 +103,9 @@ function testGetJSSettings() {
     $this->container->get('plugin.manager.editor')->clearCachedDefinitions();
     $this->ckeditor = $this->container->get('plugin.manager.editor')->createInstance('ckeditor');
     $this->container->get('plugin.manager.ckeditor.plugin')->clearCachedDefinitions();
+    // KernelTestBase::enableModules() unfortunately doesn't invoke
+    // hook_rebuild() just like a "real" Drupal site would. Do it manually.
+    \Drupal::moduleHandler()->invoke('ckeditor', 'rebuild');
     $settings = $editor->getSettings();
     $settings['toolbar']['rows'][0][0]['items'][] = 'Strike';
     $settings['toolbar']['rows'][0][0]['items'][] = 'Format';
@@ -206,6 +209,11 @@ function testGetJSSettings() {
     $expected_config['format_tags'] = 'p';
     ksort($expected_config);
     $this->assertIdentical($expected_config, $this->ckeditor->getJSSettings($editor), 'Generated JS settings are correct for customized configuration.');
+
+    // Assert that we're robust enough to withstand people messing with State
+    // manually.
+    \Drupal::state()->delete('ckeditor_internal_format_tags:' . $format->id());
+    $this->assertIdentical($expected_config, $this->ckeditor->getJSSettings($editor), 'Even when somebody manually deleted the key-value pair in State with the pre-calculated format_tags setting, it returns "p" — because the <p> tag is always allowed.');
   }
 
   /**
diff --git a/core/modules/comment/src/CommentPostRenderCache.php b/core/modules/comment/src/CommentPostRenderCache.php
index 7bf100d..ce64d6d 100644
--- a/core/modules/comment/src/CommentPostRenderCache.php
+++ b/core/modules/comment/src/CommentPostRenderCache.php
@@ -70,13 +70,12 @@ public function renderForm(array $element, array $context) {
     );
     $comment = $this->entityManager->getStorage('comment')->create($values);
     $form = $this->entityFormBuilder->getForm($comment);
-    // @todo: This only works as long as assets are still tracked in a global
-    //   static variable, see https://drupal.org/node/2238835
     $markup = drupal_render($form);
 
     $callback = 'comment.post_render_cache:renderForm';
     $placeholder = drupal_render_cache_generate_placeholder($callback, $context);
     $element['#markup'] = str_replace($placeholder, $markup, $element['#markup']);
+    $element['#attached'] = drupal_merge_attached($element['#attached'], $form['#attached']);
 
     return $element;
   }
diff --git a/core/modules/comment/src/Plugin/views/row/Rss.php b/core/modules/comment/src/Plugin/views/row/Rss.php
index 010cd42..1dc6c8b 100644
--- a/core/modules/comment/src/Plugin/views/row/Rss.php
+++ b/core/modules/comment/src/Plugin/views/row/Rss.php
@@ -130,7 +130,7 @@ public function render($row) {
 
     if ($view_mode != 'title') {
       // We render comment contents.
-      $item_text .= drupal_render($build);
+      $item_text .= drupal_render_root($build);
     }
 
     $item = new \stdClass();
@@ -146,7 +146,7 @@ public function render($row) {
       '#options' => $this->options,
       '#row' => $item,
     );
-    return drupal_render($build);
+    return drupal_render_root($build);
   }
 
 }
diff --git a/core/modules/contact/contact.module b/core/modules/contact/contact.module
index f6fc4cd..0ad6093 100644
--- a/core/modules/contact/contact.module
+++ b/core/modules/contact/contact.module
@@ -115,7 +115,7 @@ function contact_mail($key, &$message, $params) {
       $message['subject'] .= t('[!form] !subject', $variables, $options);
       $message['body'][] = t("!sender-name (!sender-url) sent a message using the contact form at !form-url.", $variables, $options);
       $build = entity_view($contact_message, 'mail', $language->getId());
-      $message['body'][] = drupal_render($build);
+      $message['body'][] = drupal_render_root($build);
       break;
 
     case 'page_autoreply':
@@ -134,7 +134,7 @@ function contact_mail($key, &$message, $params) {
       $message['body'][] = t("!sender-name (!sender-url) has sent you a message via your contact form at !site-name.", $variables, $options);
       $message['body'][] = t("If you don't want to receive such emails, you can change your settings at !recipient-edit-url.", $variables, $options);
       $build = entity_view($contact_message, 'mail', $language->getId());
-      $message['body'][] = drupal_render($build);
+      $message['body'][] = drupal_render_root($build);
       break;
   }
 }
diff --git a/core/modules/filter/filter.module b/core/modules/filter/filter.module
index d4a2253..e0b98ee 100644
--- a/core/modules/filter/filter.module
+++ b/core/modules/filter/filter.module
@@ -303,7 +303,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($build);
+  return drupal_render_root($build);
 }
 
 /**
diff --git a/core/modules/filter/src/Tests/FilterAPITest.php b/core/modules/filter/src/Tests/FilterAPITest.php
index 955ee87..588546e 100644
--- a/core/modules/filter/src/Tests/FilterAPITest.php
+++ b/core/modules/filter/src/Tests/FilterAPITest.php
@@ -234,7 +234,7 @@ function testProcessedTextElement() {
       '#text' => '<p>Hello, world!</p>',
       '#format' => 'element_test',
     );
-    drupal_render($build);
+    drupal_render_root($build);
 
     // Verify the assets, cache tags and #post_render_cache callbacks.
     $expected_assets = array(
diff --git a/core/modules/node/node.module b/core/modules/node/node.module
index bb4e2b2..d65d59d 100644
--- a/core/modules/node/node.module
+++ b/core/modules/node/node.module
@@ -587,9 +587,9 @@ function template_preprocess_node(&$variables) {
   $variables['node'] = $variables['elements']['#node'];
   /** @var \Drupal\node\NodeInterface $node */
   $node = $variables['node'];
-  $variables['date'] = drupal_render($variables['elements']['created'], TRUE);
+  $variables['date'] = drupal_render($variables['elements']['created']);
   unset($variables['elements']['created']);
-  $variables['author_name'] = drupal_render($variables['elements']['uid'], TRUE);
+  $variables['author_name'] = drupal_render($variables['elements']['uid']);
   unset($variables['elements']['uid']);
 
   $variables['url'] = $node->url('canonical', array(
diff --git a/core/modules/node/src/Plugin/views/row/Rss.php b/core/modules/node/src/Plugin/views/row/Rss.php
index 9c501a7..782d21f 100644
--- a/core/modules/node/src/Plugin/views/row/Rss.php
+++ b/core/modules/node/src/Plugin/views/row/Rss.php
@@ -147,7 +147,7 @@ public function render($row) {
 
     if ($display_mode != 'title') {
       // We render node contents.
-      $item_text .= drupal_render($build);
+      $item_text .= drupal_render_root($build);
     }
 
     $item = new \stdClass();
@@ -162,7 +162,7 @@ public function render($row) {
       '#options' => $this->options,
       '#row' => $item,
     );
-    return drupal_render($theme_function);
+    return drupal_render_root($theme_function);
   }
 
 }
diff --git a/core/modules/rest/src/Plugin/views/display/RestExport.php b/core/modules/rest/src/Plugin/views/display/RestExport.php
index a78b175..076eb1c 100644
--- a/core/modules/rest/src/Plugin/views/display/RestExport.php
+++ b/core/modules/rest/src/Plugin/views/display/RestExport.php
@@ -271,7 +271,7 @@ public function execute() {
     parent::execute();
 
     $output = $this->view->render();
-    return new Response(drupal_render($output), 200, array('Content-type' => $this->getMimeType()));
+    return new Response(drupal_render_root($output), 200, array('Content-type' => $this->getMimeType()));
   }
 
   /**
diff --git a/core/modules/rest/src/Tests/Views/StyleSerializerTest.php b/core/modules/rest/src/Tests/Views/StyleSerializerTest.php
index 0822eab..87206e0 100644
--- a/core/modules/rest/src/Tests/Views/StyleSerializerTest.php
+++ b/core/modules/rest/src/Tests/Views/StyleSerializerTest.php
@@ -92,7 +92,7 @@ public function testSerializerResponses() {
     // Mock the request content type by setting it on the display handler.
     $view->display_handler->setContentType('json');
     $output = $view->preview();
-    $this->assertIdentical($actual_json, drupal_render($output), 'The expected JSON preview output was found.');
+    $this->assertIdentical($actual_json, drupal_render_root($output), 'The expected JSON preview output was found.');
 
     // Test a 403 callback.
     $this->drupalGet('test/serialize/denied');
diff --git a/core/modules/system/src/Controller/BatchController.php b/core/modules/system/src/Controller/BatchController.php
index 3547667..4245480 100644
--- a/core/modules/system/src/Controller/BatchController.php
+++ b/core/modules/system/src/Controller/BatchController.php
@@ -106,9 +106,9 @@ public function render(array $output, $status_code = 200) {
 
     $page = $this->fragmentRenderer->preparePage($page, $page_array);
 
-    $page->setBodyTop(drupal_render($page_array['page_top']));
-    $page->setBodyBottom(drupal_render($page_array['page_bottom']));
-    $page->setContent(drupal_render($page_array));
+    $page->setBodyTop(drupal_render_root($page_array['page_top']));
+    $page->setBodyBottom(drupal_render_root($page_array['page_bottom']));
+    $page->setContent(drupal_render_root($page_array));
 
     drupal_process_attached($page_array);
     if (isset($page_array['page_top'])) {
diff --git a/core/modules/system/src/Tests/Common/RenderTest.php b/core/modules/system/src/Tests/Common/RenderTest.php
index 15da0e5..381ff8d 100644
--- a/core/modules/system/src/Tests/Common/RenderTest.php
+++ b/core/modules/system/src/Tests/Common/RenderTest.php
@@ -501,7 +501,7 @@ function testDrupalRenderPostRenderCache() {
     // #cache disabled.
     $element = $test_element;
     $element['#markup'] = '<p>#cache disabled</p>';
-    $output = drupal_render($element);
+    $output = drupal_render_root($element);
     $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
     $this->assertIdentical($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
     $expected_js = [
@@ -518,7 +518,7 @@ function testDrupalRenderPostRenderCache() {
     $element = $test_element;
     $element['#cache'] = array('cid' => 'post_render_cache_test_GET');
     $element['#markup'] = '<p>#cache enabled, GET</p>';
-    $output = drupal_render($element);
+    $output = drupal_render_root($element);
     $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
     $this->assertTrue(isset($element['#printed']), 'No cache hit');
     $this->assertIdentical($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
@@ -542,7 +542,7 @@ function testDrupalRenderPostRenderCache() {
     // GET request: #cache enabled, cache hit.
     $element['#cache'] = array('cid' => 'post_render_cache_test_GET');
     $element['#markup'] = '<p>#cache enabled, GET</p>';
-    $output = drupal_render($element);
+    $output = drupal_render_root($element);
     $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
     $this->assertFalse(isset($element['#printed']), 'Cache hit');
     $this->assertIdentical($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
@@ -560,7 +560,7 @@ function testDrupalRenderPostRenderCache() {
     $element = $test_element;
     $element['#cache'] = array('cid' => 'post_render_cache_test_POST');
     $element['#markup'] = '<p>#cache enabled, POST</p>';
-    $output = drupal_render($element);
+    $output = drupal_render_root($element);
     $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
     $this->assertTrue(isset($element['#printed']), 'No cache hit');
     $this->assertIdentical($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
@@ -624,7 +624,7 @@ function testDrupalRenderChildrenPostRenderCache() {
       '#markup' => 'Subchild',
     );
     $element = $test_element;
-    $output = drupal_render($element);
+    $output = drupal_render_root($element);
     $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
     $this->assertTrue(isset($element['#printed']), 'No cache hit');
     $this->assertIdentical($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
@@ -672,7 +672,7 @@ function testDrupalRenderChildrenPostRenderCache() {
 
     // GET request: #cache enabled, cache hit.
     $element = $test_element;
-    $output = drupal_render($element);
+    $output = drupal_render_root($element);
     $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
     $this->assertFalse(isset($element['#printed']), 'Cache hit');
     $this->assertIdentical($element['#attached']['js'], $expected_js, '#attached is modified; both the original JavaScript setting and the ones added by each #post_render_cache callback exist.');
@@ -681,7 +681,7 @@ function testDrupalRenderChildrenPostRenderCache() {
     // Use the exact same element, but now unset #cache.
     unset($test_element['#cache']);
     $element = $test_element;
-    $output = drupal_render($element);
+    $output = drupal_render_root($element);
     $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
     $this->assertIdentical($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
     $this->assertIdentical($element['#attached']['js'], $expected_js, '#attached is modified; both the original JavaScript setting and the ones added by each #post_render_cache callback exist.');
@@ -697,7 +697,7 @@ function testDrupalRenderChildrenPostRenderCache() {
     $element = $test_element;
     $element['#cache']['keys'] = array('simpletest', 'drupal_render', 'children_post_render_cache', 'nested_cache_parent');
     $element['child']['#cache']['keys'] = array('simpletest', 'drupal_render', 'children_post_render_cache', 'nested_cache_child');
-    $output = drupal_render($element);
+    $output = drupal_render_root($element);
     $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
     $this->assertTrue(isset($element['#printed']), 'No cache hit');
     $this->assertIdentical($element['#markup'], '<p>overridden</p>', '#markup is overridden.');
@@ -768,7 +768,7 @@ function testDrupalRenderChildrenPostRenderCache() {
     // GET request: #cache enabled, cache hit, parent element.
     $element = $test_element;
     $element['#cache']['keys'] = array('simpletest', 'drupal_render', 'children_post_render_cache', 'nested_cache_parent');
-    $output = drupal_render($element);
+    $output = drupal_render_root($element);
     $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
     $this->assertFalse(isset($element['#printed']), 'Cache hit');
     $this->assertIdentical($element['#attached']['js'], $expected_js, '#attached is modified; both the original JavaScript setting and the ones added by each #post_render_cache callback exist.');
@@ -777,7 +777,7 @@ function testDrupalRenderChildrenPostRenderCache() {
     $element = $test_element;
     $element['child']['#cache']['keys'] = array('simpletest', 'drupal_render', 'children_post_render_cache', 'nested_cache_child');
     $element = $element['child'];
-    $output = drupal_render($element);
+    $output = drupal_render_root($element);
     $this->assertIdentical($output, '<p>overridden</p>', 'Output is overridden.');
     $this->assertFalse(isset($element['#printed']), 'Cache hit');
     $expected_js = [
@@ -815,7 +815,7 @@ function testDrupalRenderRenderCachePlaceholder() {
 
     // #cache disabled.
     $element = $test_element;
-    $output = drupal_render($element);
+    $output = drupal_render_root($element);
     $this->assertIdentical($output, $expected_output, 'Placeholder was replaced in output');
     $expected_js = [
       ['type' => 'setting', 'data' => ['common_test' => $context]],
@@ -829,7 +829,7 @@ function testDrupalRenderRenderCachePlaceholder() {
     // GET request: #cache enabled, cache miss.
     $element = $test_element;
     $element['#cache'] = array('cid' => 'render_cache_placeholder_test_GET');
-    $output = drupal_render($element);
+    $output = drupal_render_root($element);
     $this->assertIdentical($output, $expected_output, 'Placeholder was replaced in output');
     $this->assertTrue(isset($element['#printed']), 'No cache hit');
     $this->assertIdentical($element['#markup'], $expected_output, 'Placeholder was replaced in #markup.');
@@ -866,7 +866,7 @@ function testDrupalRenderRenderCachePlaceholder() {
     // GET request: #cache enabled, cache hit.
     $element = $test_element;
     $element['#cache'] = array('cid' => 'render_cache_placeholder_test_GET');
-    $output = drupal_render($element);
+    $output = drupal_render_root($element);
     $this->assertIdentical($output, $expected_output, 'Placeholder was replaced in output');
     $this->assertFalse(isset($element['#printed']), 'Cache hit');
     $this->assertIdentical($element['#markup'], $expected_output, 'Placeholder was replaced in #markup.');
@@ -903,7 +903,7 @@ function testDrupalRenderChildElementRenderCachePlaceholder() {
 
     // #cache disabled.
     $element = $test_element;
-    $output = drupal_render($element);
+    $output = drupal_render_root($element);
     $this->assertIdentical($output, $expected_output, 'Placeholder was replaced in output');
     $expected_js = [
       ['type' => 'setting', 'data' => ['common_test' => $context]],
@@ -919,7 +919,7 @@ function testDrupalRenderChildElementRenderCachePlaceholder() {
     $element['#cache'] = array('cid' => 'render_cache_placeholder_test_GET');
     $element['foo']['#cache'] = array('cid' => 'render_cache_placeholder_test_child_GET');
     // Render, which will use the common-test-render-element.html.twig template.
-    $output = drupal_render($element);
+    $output = drupal_render_root($element);
     $this->assertIdentical($output, $expected_output); //, 'Placeholder was replaced in output');
     $this->assertTrue(isset($element['#printed']), 'No cache hit');
     $this->assertIdentical($element['#markup'], $expected_output, 'Placeholder was replaced in #markup.');
@@ -1014,7 +1014,7 @@ function testDrupalRenderChildElementRenderCachePlaceholder() {
     $element = $test_element;
     $element['#cache'] = array('cid' => 'render_cache_placeholder_test_GET');
     // Render, which will use the common-test-render-element.html.twig template.
-    $output = drupal_render($element);
+    $output = drupal_render_root($element);
     $this->assertIdentical($output, $expected_output, 'Placeholder was replaced in output');
     $this->assertFalse(isset($element['#printed']), 'Cache hit');
     $this->assertIdentical($element['#markup'], $expected_output, 'Placeholder was replaced in #markup.');
@@ -1110,7 +1110,7 @@ function testDrupalRenderBubbling() {
       \Drupal::cache('render')->set('cached_nested', array('#markup' => 'Cached nested!', '#attached' => array(), '#cache' => array('tags' => array()), '#post_render_cache' => array()));
       \Drupal::cache('render')->delete('uncached_nested');
 
-      $output = drupal_render($test_element);
+      $output = drupal_render_root($test_element);
       // Assert top-level.
       $this->assertEqual('Cache tag!Asset!Post-render cache!barquxNested!Cached nested!', trim($output), 'Expected HTML generated.');
       $this->assertEqual(array('child:cache_tag'), $test_element['#cache']['tags'], 'Expected cache tags found.');
diff --git a/core/modules/system/src/Tests/Common/RenderWebTest.php b/core/modules/system/src/Tests/Common/RenderWebTest.php
index 1eb0669..c2cb88f 100644
--- a/core/modules/system/src/Tests/Common/RenderWebTest.php
+++ b/core/modules/system/src/Tests/Common/RenderWebTest.php
@@ -149,7 +149,7 @@ function testDrupalRenderFormElements() {
    */
   protected function assertRenderedElement(array $element, $xpath, array $xpath_args = array()) {
     $original_element = $element;
-    $this->drupalSetContent(drupal_render($element));
+    $this->drupalSetContent(drupal_render_root($element));
     $this->verbose('<hr />' . $this->drupalGetContent());
 
     // @see \Drupal\simpletest\WebTestBase::xpath()
diff --git a/core/modules/views/src/Plugin/views/cache/CachePluginBase.php b/core/modules/views/src/Plugin/views/cache/CachePluginBase.php
index d8cdd7f..6685ee1 100644
--- a/core/modules/views/src/Plugin/views/cache/CachePluginBase.php
+++ b/core/modules/views/src/Plugin/views/cache/CachePluginBase.php
@@ -143,7 +143,7 @@ public function cacheSet($type) {
         break;
       case 'output':
         $this->gatherHeaders($this->view->display_handler->output);
-        $this->storage['output'] = drupal_render($this->view->display_handler->output, TRUE);
+        $this->storage['output'] = drupal_render($this->view->display_handler->output);
         \Drupal::cache($this->outputBin)->set($this->generateOutputKey(), $this->storage, $this->cacheSetExpire($type), $this->getCacheTags());
         break;
     }
diff --git a/core/modules/views/src/Plugin/views/display/Feed.php b/core/modules/views/src/Plugin/views/display/Feed.php
index da96d29..7d1c5fe 100644
--- a/core/modules/views/src/Plugin/views/display/Feed.php
+++ b/core/modules/views/src/Plugin/views/display/Feed.php
@@ -81,7 +81,7 @@ public function execute() {
 
     $response = $this->view->getResponse();
 
-    $response->setContent(drupal_render($output));
+    $response->setContent(drupal_render_root($output));
 
     return $response;
   }
@@ -95,7 +95,7 @@ public function preview() {
     if (!empty($this->view->live_preview)) {
       $output = array(
         '#prefix' => '<pre>',
-        '#markup' => String::checkPlain(drupal_render($output)),
+        '#markup' => String::checkPlain(drupal_render_root($output)),
         '#suffix' => '</pre>',
       );
     }
diff --git a/core/modules/views/src/Plugin/views/row/RssFields.php b/core/modules/views/src/Plugin/views/row/RssFields.php
index d7f9bb0..6d0ae5b 100644
--- a/core/modules/views/src/Plugin/views/row/RssFields.php
+++ b/core/modules/views/src/Plugin/views/row/RssFields.php
@@ -180,7 +180,7 @@ public function render($row) {
       '#row' => $item,
       '#field_alias' => isset($this->field_alias) ? $this->field_alias : '',
     );
-    return drupal_render($build);
+    return drupal_render_root($build);
   }
 
   /**
diff --git a/core/tests/Drupal/Tests/Core/Ajax/AjaxResponseRendererTest.php b/core/tests/Drupal/Tests/Core/Ajax/AjaxResponseRendererTest.php
index 035c1e4..2c71d87 100644
--- a/core/tests/Drupal/Tests/Core/Ajax/AjaxResponseRendererTest.php
+++ b/core/tests/Drupal/Tests/Core/Ajax/AjaxResponseRendererTest.php
@@ -101,7 +101,7 @@ class TestAjaxResponseRenderer extends AjaxResponseRenderer {
   /**
    * {@inheritdoc}
    */
-  protected function drupalRender(&$elements, $is_recursive_call = FALSE) {
+  protected function drupalRenderRoot(&$elements) {
     if (isset($elements['#markup'])) {
       return $elements['#markup'];
     }
