diff --git a/core/core.services.yml b/core/core.services.yml index de88d89..966ed3d 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1059,7 +1059,7 @@ services: html_response.attachments_processor: class: Drupal\Core\Render\HtmlResponseAttachmentsProcessor tags: - arguments: ['@asset.resolver', '@config.factory', '@asset.css.collection_renderer', '@asset.js.collection_renderer', '@request_stack', '@renderer'] + arguments: ['@asset.resolver', '@config.factory', '@asset.css.collection_renderer', '@asset.js.collection_renderer', '@request_stack', '@renderer', '@module_handler'] html_response.subscriber: class: Drupal\Core\EventSubscriber\HtmlResponseSubscriber tags: diff --git a/core/includes/common.inc b/core/includes/common.inc index 43c5384..406ef6f 100644 --- a/core/includes/common.inc +++ b/core/includes/common.inc @@ -148,62 +148,6 @@ const LOCALE_PLURAL_DELIMITER = "\03"; /** - * Adds output to the HEAD tag of the HTML page. - * - * This function can be called as long as the headers aren't sent. Pass no - * arguments (or NULL for both) to retrieve the currently stored elements. - * - * @param $data - * A renderable array. If the '#type' key is not set then 'html_tag' will be - * added as the default '#type'. - * @param $key - * A unique string key to allow implementations of hook_html_head_alter() to - * identify the element in $data. Required if $data is not NULL. - * - * @return - * An array of all stored HEAD elements. - * - * @see \Drupal\Core\Render\Element\HtmlTag::preRenderHtmlTag() - * - * @deprecated in Drupal 8.0.x, will be removed before Drupal 8.0.0 - * Use #attached on render arrays. - */ -function _drupal_add_html_head($data = NULL, $key = NULL) { - $stored_head = &drupal_static(__FUNCTION__, array()); - - if (isset($data) && isset($key)) { - if (!isset($data['#type'])) { - $data['#type'] = 'html_tag'; - } - $stored_head[$key] = $data; - } - return $stored_head; -} - -/** - * Retrieves output to be displayed in the HEAD tag of the HTML page. - * - * @param bool $render - * If TRUE render the HEAD elements, otherwise return just the elements. - * - * @return string|array - * Return the rendered HTML head or the elements itself. - * - * @deprecated in Drupal 8.0.x, will be removed before Drupal 8.0.0 - * Use #attached on render arrays. - */ -function drupal_get_html_head($render = TRUE) { - $elements = _drupal_add_html_head(); - \Drupal::moduleHandler()->alter('html_head', $elements); - if ($render) { - return \Drupal::service('renderer')->renderPlain($elements); - } - else { - return $elements; - } -} - -/** * Prepares a 'destination' URL query parameter for use with url(). * * Used to direct the user back to the referring page after completing a form. @@ -477,39 +421,6 @@ function base_path() { } /** - * Adds a LINK tag with a distinct 'rel' attribute to the page's HEAD. - * - * This function can be called as long the HTML header hasn't been sent, which - * on normal pages is up through the preprocess step of _theme('html'). Adding - * a link will overwrite a prior link with the exact same 'rel' and 'href' - * attributes. - * - * @param $attributes - * Associative array of element attributes including 'href' and 'rel'. - * @param $header - * Optional flag to determine if a HTTP 'Link:' header should be sent. - * - * @deprecated in Drupal 8.0.x, will be removed before Drupal 8.0.0 - * Use #attached on render arrays. - */ -function _drupal_add_html_head_link($attributes, $header = FALSE) { - $element = array( - '#tag' => 'link', - '#attributes' => $attributes, - ); - $href = $attributes['href']; - - if ($header) { - // Also add a HTTP header "Link:". - $href = '<' . Html::escape($attributes['href']) . '>;'; - unset($attributes['href']); - $element['#attached']['http_header'][] = array('Link', $href . drupal_http_header_attributes($attributes), TRUE); - } - - _drupal_add_html_head($element, 'html_head_link:' . $attributes['rel'] . ':' . $href); -} - -/** * Deletes old cached CSS files. * * @deprecated in Drupal 8.x, will be removed before Drupal 9.0. @@ -580,39 +491,8 @@ function drupal_js_defaults($data = NULL) { * When attaching something of a non-existing attachment type. */ function drupal_process_attached(array $elements) { - // Asset attachments are handled by \Drupal\Core\Asset\AssetResolver. - foreach (array('library', 'drupalSettings') as $type) { - unset($elements['#attached'][$type]); - } - - // Add additional types of attachments specified in the render() structure. - foreach ($elements['#attached'] as $callback => $options) { - foreach ($elements['#attached'][$callback] as $args) { - // Limit the amount allowed entries. - switch ($callback) { - case 'html_head': - call_user_func_array('_drupal_add_html_head', $args); - break; - case 'feed': - $args = [[ - 'href' => $args[0], - 'rel' => 'alternate', - 'title' => $args[1], - 'type' => 'application/rss+xml', - ]]; - call_user_func_array('_drupal_add_html_head_link', $args); - break; - case 'html_head_link': - call_user_func_array('_drupal_add_html_head_link', $args); - break; - case 'http_header': - // @todo Remove validation in https://www.drupal.org/node/2477223 - break; - default: - throw new \LogicException(sprintf('You are not allowed to use %s in #attached', $callback)); - } - } - } + $build['#attached'] = $elements['#attached']; + \Drupal::service('renderer')->render($build); } /** diff --git a/core/lib/Drupal/Core/Render/AttachmentsResponseProcessorInterface.php b/core/lib/Drupal/Core/Render/AttachmentsResponseProcessorInterface.php index 8257763..6067a86 100644 --- a/core/lib/Drupal/Core/Render/AttachmentsResponseProcessorInterface.php +++ b/core/lib/Drupal/Core/Render/AttachmentsResponseProcessorInterface.php @@ -9,6 +9,8 @@ /** * Defines an interface for processing attachments of responses that have them. * + * @see \Drupal\Core\Ajax\AjaxResponse + * @see \Drupal\Core\Ajax\AjaxResponseAttachmentsProcessor * @see \Drupal\Core\Render\HtmlResponse * @see \Drupal\Core\Render\HtmlResponseAttachmentsProcessor */ @@ -17,13 +19,38 @@ /** * Processes the attachments of a response that has attachments. * + * Libraries, JavaScript settings, feeds, HTML tags, HTML links, + * HTTP headers, and the HTTP status code are attached to render arrays using + * the #attached property. The #attached property is an associative array, + * where the keys are the attachment types and the values are the attached + * data. For example: + * + * @code + * $build['#attached']['library'][] = [ + * 'library' => ['core/jquery'] + * ]; + * $build['#attached']['http_header'][] = [ + * ['Content-Type', 'application/rss+xml; charset=utf-8'], + * ]; + * @endcode + * + * The available keys are: + * - 'library' (asset libraries) + * - 'drupalSettings' (JavaScript settings) + * - 'feed' (RSS feeds) + * - 'html_head' (tags in HTML ) + * - 'html_head_link' ( tags in HTML ) + * - 'http_header' (HTTP headers and status code) + * * @param \Drupal\Core\Render\AttachmentsInterface $response - * The response to process the attachments for. + * The response to process. * * @return \Drupal\Core\Render\AttachmentsInterface * The processed response. * * @throws \InvalidArgumentException + * Thrown when the $response parameter is not the type of response object + * the processor expects. */ public function processAttachments(AttachmentsInterface $response); diff --git a/core/lib/Drupal/Core/Render/HtmlResponseAttachmentsProcessor.php b/core/lib/Drupal/Core/Render/HtmlResponseAttachmentsProcessor.php index 19edef1..6104b0a 100644 --- a/core/lib/Drupal/Core/Render/HtmlResponseAttachmentsProcessor.php +++ b/core/lib/Drupal/Core/Render/HtmlResponseAttachmentsProcessor.php @@ -11,11 +11,23 @@ use Drupal\Core\Asset\AttachedAssets; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Form\EnforcedResponseException; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Render\BubbleableMetadata; +use Drupal\Component\Utility\Html; +use Drupal\Component\Utility\SafeMarkup; use Symfony\Component\HttpFoundation\RequestStack; /** * Processes attachments of HTML responses. * + * This class is used by the rendering service to process the #attached part of + * the render array, for HTML responses. + * + * To render attachments to HTML for testing without a controller, use the + * 'bare_html_page_renderer' service to generate a + * Drupal\Core\Render\HtmlResponse object. Then use its getContent(), + * getStatusCode(), and/or the headers property to access the result. + * * @see template_preprocess_html() * @see \Drupal\Core\Render\AttachmentsResponseProcessorInterface * @see \Drupal\Core\Render\BareHtmlPageRenderer @@ -67,6 +79,13 @@ class HtmlResponseAttachmentsProcessor implements AttachmentsResponseProcessorIn protected $renderer; /** + * The module handler service. + * + * @var \Drupal\Core\Extension\ModuleHandlerInterface + */ + protected $moduleHandler; + + /** * Constructs a HtmlResponseAttachmentsProcessor object. * * @param \Drupal\Core\Asset\AssetResolverInterface $asset_resolver @@ -81,14 +100,17 @@ class HtmlResponseAttachmentsProcessor implements AttachmentsResponseProcessorIn * The request stack. * @param \Drupal\Core\Render\RendererInterface $renderer * The renderer. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler + * The module handler service. */ - public function __construct(AssetResolverInterface $asset_resolver, ConfigFactoryInterface $config_factory, AssetCollectionRendererInterface $css_collection_renderer, AssetCollectionRendererInterface $js_collection_renderer, RequestStack $request_stack, RendererInterface $renderer) { + public function __construct(AssetResolverInterface $asset_resolver, ConfigFactoryInterface $config_factory, AssetCollectionRendererInterface $css_collection_renderer, AssetCollectionRendererInterface $js_collection_renderer, RequestStack $request_stack, RendererInterface $renderer, ModuleHandlerInterface $module_handler) { $this->assetResolver = $asset_resolver; $this->config = $config_factory->get('system.performance'); $this->cssCollectionRenderer = $css_collection_renderer; $this->jsCollectionRenderer = $js_collection_renderer; $this->requestStack = $request_stack; $this->renderer = $renderer; + $this->moduleHandler = $module_handler; } /** @@ -117,27 +139,67 @@ public function processAttachments(AttachmentsInterface $response) { return $e->getResponse(); } + // Get a reference to the attachments. $attached = $response->getAttachments(); + // Send a message back if the render array has unsupported #attached types. + $unsupported_types = array_diff( + array_keys($attached), + ['html_head', 'feed', 'html_head_link', 'http_header', 'library', 'html_response_attachment_placeholders', 'placeholders', 'drupalSettings'] + ); + if (!empty($unsupported_types)) { + throw new \LogicException(sprintf('You are not allowed to use %s in #attached.', implode(', ', $unsupported_types))); + } + // Get the placeholders from attached and then remove them. $attachment_placeholders = $attached['html_response_attachment_placeholders']; unset($attached['html_response_attachment_placeholders']); $variables = $this->processAssetLibraries($attached, $attachment_placeholders); - // Handle all non-asset attachments. This populates drupal_get_html_head(). - $all_attached = ['#attached' => $attached]; - drupal_process_attached($all_attached); + // Since we can only replace content in the HTML head section if there's a + // placeholder for it, we can safely avoid processing the render array if + // it's not present. + if (!empty($attachment_placeholders['head'])) { + // 'feed' is a special case of 'html_head_link'. We process them into + // 'html_head_link' entries and merge them. + if (!empty($attached['feed'])) { + $attached = BubbleableMetadata::mergeAttachments( + $attached, + $this->processFeed($attached['feed']) + ); + } + // 'html_head_link' is a special case of 'html_head' which can be present + // as a head element, but also as a Link: HTTP header depending on + // settings in the render array. Processing it can add to both the + // 'html_head' and 'http_header' keys of '#attached', so we must address + // it before 'html_head'. + if (!empty($attached['html_head_link'])) { + // Merge the processed 'html_head_link' into $attached so that its + // 'html_head' and 'http_header' values are present for further + // processing. + $attached = BubbleableMetadata::mergeAttachments( + $attached, + $this->processHtmlHeadLink($attached['html_head_link']) + ); + } - // Get HTML head elements - if present. - if (isset($attachment_placeholders['head'])) { - $variables['head'] = drupal_get_html_head(FALSE); + // Now we can process 'html_head', which contains both 'feed' and + // 'html_head_link'. + if (!empty($attached['html_head'])) { + $html_head = $this->processHtmlHead($attached['html_head']); + // Invoke hook_html_head_alter(). + $this->moduleHandler->alter('html_head', $html_head); + // Store the result in $variables so it can be inserted into the + // placeholder. + $variables['head'] = $html_head; + } } // Now replace the attachment placeholders. $this->renderHtmlResponseAttachmentPlaceholders($response, $attachment_placeholders, $variables); - // Finally set the headers on the response if any bubbled. + // Set the HTTP headers and status code on the response if any bubbled. if (!empty($attached['http_header'])) { $this->setHeaders($response, $attached['http_header']); } @@ -243,6 +305,9 @@ protected function processAssetLibraries(array $attached, array $placeholders) { /** * Renders HTML response attachment placeholders. * + * This is the last step where all of the attachments are placed into the + * response object's contents. + * * @param \Drupal\Core\Render\HtmlResponse $response * The HTML response to update. * @param array $placeholders @@ -268,7 +333,13 @@ protected function renderHtmlResponseAttachmentPlaceholders(HtmlResponse $respon * @param \Drupal\Core\Render\HtmlResponse $response * The HTML response to update. * @param array $headers - * The headers to set. + * The headers to set, as an array. The items in this array should be as + * follows: + * - The header name. + * - The header value. + * - (optional) Whether to replace a current value with the new one, or add + * it to the others. If the value is not replaced, it will be appended, + * resulting in a header like this: 'Header: value1,value2' */ protected function setHeaders(HtmlResponse $response, array $headers) { foreach ($headers as $values) { @@ -281,8 +352,105 @@ protected function setHeaders(HtmlResponse $response, array $headers) { if (strtolower($name) === 'status') { $response->setStatusCode($value); } - $response->headers->set($name, $value, $replace); + else { + $response->headers->set($name, $value, $replace); + } + } + } + + /** + * Ensure proper key/data order and defaults for renderable head items. + * + * @param array $html_head + * The ['#attached']['html_head'] portion of a render array. + * + * @return array + * The ['#attached']['html_head'] portion of a render array with #type of + * html_tag added for items without a #type. + */ + protected function processHtmlHead(array $html_head) { + $head = []; + foreach ($html_head as $item) { + list($data, $key) = $item; + if (!isset($data['#type'])) { + $data['#type'] = 'html_tag'; + } + $head[$key] = $data; + } + return $head; + } + + /** + * Transform a html_head_link array into html_head and http_header arrays. + * + * html_head_link is a special case of html_head which can be present as + * a link item in the HTML head section, and also as a Link: HTTP header, + * depending on options in the render array. Processing it can add to both the + * html_head and http_header sections. + * + * @param array $html_head_link + * The 'html_head_link' value of a render array. Each head link is specified + * by a two-element array: + * - An array specifying the attributes of the link. + * - A boolean specifying whether the link should also be a Link: HTTP + * header. + * + * @return array + * An ['#attached'] section of a render array. This allows us to easily + * merge the results with other render arrays. The array could contain the + * following keys: + * - http_header + * - html_head + */ + protected function processHtmlHeadLink(array $html_head_link) { + $attached = []; + + foreach ($html_head_link as $item) { + $attributes = $item[0]; + $should_add_header = isset($item[1]) ? $item[1] : FALSE; + + $element = array( + '#tag' => 'link', + '#attributes' => $attributes, + ); + $href = $attributes['href']; + $attached['html_head'][] = [$element, 'html_head_link:' . $attributes['rel'] . ':' . $href]; + + if ($should_add_header) { + // Also add a HTTP header "Link:". + $href = '<' . Html::escape($attributes['href'] . '>'); + unset($attributes['href']); + $attached['http_header'][] = ['Link', $href . drupal_http_header_attributes($attributes), TRUE]; + } + } + return $attached; + } + + /** + * Transform a 'feed' attachment into an 'html_head_link' attachment. + * + * The RSS feed is a special case of 'html_head_link', so we just turn it into + * one. + * + * @param array $attached_feed + * The ['#attached']['feed'] portion of a render array. + * + * @return array + * An ['#attached']['html_head_link'] array, suitable for merging with + * another 'html_head_link' array. + */ + protected function processFeed($attached_feed) { + $html_head_link = []; + foreach($attached_feed as $item) { + $feed_link = [ + 'href' => $item[0], + 'rel' => 'alternate', + 'title' => empty($item[1]) ? '' : $item[1], + 'type' => 'application/rss+xml', + ]; + $html_head_link[] = [$feed_link, FALSE]; } + return ['html_head_link' => $html_head_link]; } } diff --git a/core/lib/Drupal/Core/Render/theme.api.php b/core/lib/Drupal/Core/Render/theme.api.php index e9144cf..3139224 100644 --- a/core/lib/Drupal/Core/Render/theme.api.php +++ b/core/lib/Drupal/Core/Render/theme.api.php @@ -375,7 +375,10 @@ * Libraries, JavaScript settings, feeds, HTML tags and HTML links * are attached to elements using the #attached property. The #attached property * is an associative array, where the keys are the attachment types and the - * values are the attached data. For example: + * values are the attached data. + * + * The #attached property can also be used to specify HTTP headers and the + * response status code. * * The #attached property allows loading of asset libraries (which may contain * CSS assets, JavaScript assets, and JavaScript setting assets), JavaScript @@ -386,10 +389,11 @@ * @code * $build['#attached']['library'][] = 'core/jquery'; * $build['#attached']['drupalSettings']['foo'] = 'bar'; - * $build['#attached']['feed'][] = ['aggregator/rss', $this->t('Feed title')]; + * $build['#attached']['feed'][] = [$url, $this->t('Feed title')]; * @endcode * - * See drupal_process_attached() for additional information. + * See \Drupal\Core\Render\AttachmentsResponseProcessorInterface for additional + * information. * * See \Drupal\Core\Asset\LibraryDiscoveryParser::parseLibraryInfo() for more * information on how to define libraries. diff --git a/core/modules/book/book.module b/core/modules/book/book.module index 3a48671..dc543fc 100644 --- a/core/modules/book/book.module +++ b/core/modules/book/book.module @@ -466,7 +466,6 @@ function template_preprocess_book_export_html(&$variables) { $variables['base_url'] = $base_url; $variables['language'] = $language_interface; $variables['language_rtl'] = ($language_interface->getDirection() == LanguageInterface::DIRECTION_RTL); - $variables['head'] = drupal_get_html_head(); // HTML element attributes. $attributes = array(); diff --git a/core/modules/simpletest/src/KernelTestBase.php b/core/modules/simpletest/src/KernelTestBase.php index 309bb2b..0e2bf5c 100644 --- a/core/modules/simpletest/src/KernelTestBase.php +++ b/core/modules/simpletest/src/KernelTestBase.php @@ -586,7 +586,6 @@ protected function registerStreamWrapper($scheme, $class, $type = StreamWrapperI */ protected function render(array &$elements) { $content = $this->container->get('renderer')->renderRoot($elements); - drupal_process_attached($elements); $this->setRawContent($content); $this->verbose('
' . Html::escape($content));
     return $content;
diff --git a/core/modules/system/src/Tests/Common/AddFeedTest.php b/core/modules/system/src/Tests/Common/AddFeedTest.php
index 1aa6a44..0620051 100644
--- a/core/modules/system/src/Tests/Common/AddFeedTest.php
+++ b/core/modules/system/src/Tests/Common/AddFeedTest.php
@@ -68,7 +68,6 @@ function testBasicFeedAddNoTitle() {
     );
     // Glean the content from the response object.
     $this->setRawContent($response->getContent());
-
     // Assert that the content contains the RSS links we specified.
     foreach ($urls as $description => $feed_info) {
       $this->assertPattern($this->urlToRSSLinkPattern($feed_info['url'], $feed_info['title']), format_string('Found correct feed header for %description', array('%description' => $description)));
diff --git a/core/modules/system/src/Tests/Common/RenderTest.php b/core/modules/system/src/Tests/Common/RenderTest.php
index 94ad431..df0278b 100644
--- a/core/modules/system/src/Tests/Common/RenderTest.php
+++ b/core/modules/system/src/Tests/Common/RenderTest.php
@@ -55,12 +55,13 @@ function testDrupalRenderThemePreprocessAttached() {
   /**
    * Tests that we get an exception when we try to attach an illegal type.
    */
-  public function testDrupalProcessAttached() {
+  public function testProcessAttached() {
     // Specify invalid attachments in a render array.
     $build['#attached']['library'][] = 'core/drupal.states';
     $build['#attached']['drupal_process_states'][] = [];
+    $renderer = $this->container->get('bare_html_page_renderer');
     try {
-      $this->render($build);
+      $renderer->renderBarePage($build, '', $this->container->get('theme.manager')->getActiveTheme()->getName());
       $this->fail("Invalid #attachment 'drupal_process_states' allowed");
     }
     catch (\LogicException $e) {
diff --git a/core/modules/system/src/Tests/HttpKernel/HeadersResponseCodeRenderTest.php b/core/modules/system/src/Tests/HttpKernel/HeadersResponseCodeRenderTest.php
deleted file mode 100644
index 7a81a0d..0000000
--- a/core/modules/system/src/Tests/HttpKernel/HeadersResponseCodeRenderTest.php
+++ /dev/null
@@ -1,37 +0,0 @@
-drupalGet('/httpkernel-test/teapot');
-    $this->assertResponse(418);
-    $this->assertHeader('X-Test-Teapot', 'Teapot Mode Active');
-    $this->assertHeader('X-Test-Teapot-Replace', 'Teapot replaced');
-    $this->assertHeader('X-Test-Teapot-No-Replace', 'This value is not replaced,This one is added');
-  }
-
-}
diff --git a/core/modules/system/src/Tests/Render/HtmlResponseAttachmentsTest.php b/core/modules/system/src/Tests/Render/HtmlResponseAttachmentsTest.php
new file mode 100644
index 0000000..2656ccf
--- /dev/null
+++ b/core/modules/system/src/Tests/Render/HtmlResponseAttachmentsTest.php
@@ -0,0 +1,175 @@
+drupalGet('/render_attached_test/teapot');
+    $this->assertResponse(418);
+    $this->assertHeader('X-Drupal-Cache', 'MISS');
+    // Repeat for the cache.
+    $this->drupalGet('/render_attached_test/teapot');
+    $this->assertResponse(418);
+    $this->assertHeader('X-Drupal-Cache', 'HIT');
+
+    // Test ['#attached']['http_header'] with various replacement rules.
+    $this->drupalGet('/render_attached_test/header');
+    $this->assertTeapotHeaders();
+    $this->assertHeader('X-Drupal-Cache', 'MISS');
+    // Repeat for the cache.
+    $this->drupalGet('/render_attached_test/header');
+    $this->assertHeader('X-Drupal-Cache', 'HIT');
+
+    // Test ['#attached']['feed'].
+    $this->drupalGet('/render_attached_test/feed');
+    $this->assertHeader('X-Drupal-Cache', 'MISS');
+    $this->assertFeed();
+    // Repeat for the cache.
+    $this->drupalGet('/render_attached_test/feed');
+    $this->assertHeader('X-Drupal-Cache', 'HIT');
+
+    // Test ['#attached']['html_head'].
+    $this->drupalGet('/render_attached_test/head');
+    $this->assertHeader('X-Drupal-Cache', 'MISS');
+    $this->assertHead();
+    // Repeat for the cache.
+    $this->drupalGet('/render_attached_test/head');
+    $this->assertHeader('X-Drupal-Cache', 'HIT');
+
+    // Now repeat all of the preceeding using drupal_process_attached().
+    // Test ['#attached']['http_header] = ['Status', $code].
+    $this->drupalGet('/render_attached_test/teapot_dpa');
+    $this->assertResponse(418);
+    $this->assertHeader('X-Drupal-Cache', 'MISS');
+    // Repeat for the cache.
+    $this->drupalGet('/render_attached_test/teapot_dpa');
+    $this->assertResponse(418);
+    $this->assertHeader('X-Drupal-Cache', 'HIT');
+
+    // Test ['#attached']['http_header'] with various replacement rules.
+    $this->drupalGet('/render_attached_test/header_dpa');
+    $this->assertHeader('X-Drupal-Cache', 'MISS');
+    $this->assertTeapotHeaders();
+    // Repeat for the cache.
+    $this->drupalGet('/render_attached_test/header_dpa');
+    $this->assertHeader('X-Drupal-Cache', 'HIT');
+
+    // Test ['#attached']['feed'].
+    $this->drupalGet('/render_attached_test/feed_dpa');
+    $this->assertHeader('X-Drupal-Cache', 'MISS');
+    $this->assertFeed();
+    // Repeat for the cache.
+    $this->drupalGet('/render_attached_test/feed_dpa');
+    $this->assertHeader('X-Drupal-Cache', 'HIT');
+
+    // Test ['#attached']['html_head'].
+    $this->drupalGet('/render_attached_test/head_dpa');
+    $this->assertHeader('X-Drupal-Cache', 'MISS');
+    $this->assertHead();
+    // Repeat for the cache.
+    $this->drupalGet('/render_attached_test/head_dpa');
+    $this->assertHeader('X-Drupal-Cache', 'HIT');
+  }
+
+  /**
+   * Test caching of ['#attached'].
+   */
+  public function testRenderCachedBlock() {
+    // Make sure our test block is visible.
+    $this->drupalPlaceBlock('drupal_process_attached_block', ['region' => 'content']);
+
+    // Get the front page, which should now have our visible block.
+    $this->drupalGet('');
+    // Make sure our block is visible.
+    $this->assertText('Headers handled by drupal_process_attached().');
+    // Test that all our attached items are present.
+    $this->assertFeed();
+    $this->assertHead();
+    $this->assertResponse(418);
+    $this->assertTeapotHeaders();
+
+    // Reload the page, to test caching.
+    $this->drupalGet('');
+    // Make sure our block is visible.
+    $this->assertText('Headers handled by drupal_process_attached().');
+    // The header should be present again.
+    $this->assertHeader('X-Test-Teapot', 'Teapot Mode Active');
+  }
+
+  /**
+   * Helper function to make assertions about added HTTP headers.
+   */
+  protected function assertTeapotHeaders() {
+    $this->assertHeader('X-Test-Teapot', 'Teapot Mode Active');
+    $this->assertHeader('X-Test-Teapot-Replace', 'Teapot replaced');
+    $this->assertHeader('X-Test-Teapot-No-Replace', 'This value is not replaced,This one is added');
+  }
+
+  /**
+   * Helper function to make assertions about the presence of an RSS feed.
+   */
+  protected function assertFeed() {
+    // Discover the DOM element for the feed link.
+    $test_meta = $this->xpath('//head/link[@href="test://url"]');
+    $this->assertEqual(1, count($test_meta), 'Link has URL.');
+    // Reconcile the other attributes.
+    $test_meta_attributes = [
+      'href' => 'test://url',
+      'rel' => 'alternate',
+      'type' => 'application/rss+xml',
+      'title' => 'Your RSS feed.',
+    ];
+    $test_meta = reset($test_meta);
+    if (empty($test_meta)) {
+      $this->fail('Unable to find feed link.');
+    }
+    else {
+      foreach ($test_meta->attributes() as $attribute => $value) {
+        $this->assertEqual($value, $test_meta_attributes[$attribute]);
+      }
+    }
+  }
+
+  /**
+   * Helper function to make assertions about HTML head elements.
+   */
+  protected function assertHead() {
+    // Discover the DOM element for the meta link.
+    $test_meta = $this->xpath('//head/meta[@test-attribute="testvalue"]');
+    $this->assertEqual(1, count($test_meta), 'There\'s only one test attribute.');
+    // Grab the only DOM element.
+    $test_meta = reset($test_meta);
+    if (empty($test_meta)) {
+      $this->fail('Unable to find the head meta.');
+    }
+    else {
+      $test_meta_attributes = $test_meta->attributes();
+      $this->assertEqual($test_meta_attributes['test-attribute'], 'testvalue');
+    }
+  }
+
+}
diff --git a/core/modules/system/tests/modules/httpkernel_test/httpkernel_test.routing.yml b/core/modules/system/tests/modules/httpkernel_test/httpkernel_test.routing.yml
index 8f5762a..7de4338 100644
--- a/core/modules/system/tests/modules/httpkernel_test/httpkernel_test.routing.yml
+++ b/core/modules/system/tests/modules/httpkernel_test/httpkernel_test.routing.yml
@@ -4,9 +4,3 @@ httpkernel_test.empty:
     _controller: '\Drupal\httpkernel_test\Controller\TestController::get'
   requirements:
     _access: 'TRUE'
-httpkernel_test.teapot:
-  path: '/httpkernel-test/teapot'
-  defaults:
-    _controller: '\Drupal\httpkernel_test\Controller\TestController::teapot'
-  requirements:
-    _access: 'TRUE'
diff --git a/core/modules/system/tests/modules/httpkernel_test/src/Controller/TestController.php b/core/modules/system/tests/modules/httpkernel_test/src/Controller/TestController.php
index 5730080..1699594 100644
--- a/core/modules/system/tests/modules/httpkernel_test/src/Controller/TestController.php
+++ b/core/modules/system/tests/modules/httpkernel_test/src/Controller/TestController.php
@@ -21,21 +21,4 @@ public function get() {
     return new Response();
   }
 
-  /**
-   * Test special header and status code rendering.
-   *
-   * @return array
-   *   A render array using features of the 'http_header' directive.
-   */
-  public function teapot() {
-    $render = [];
-    $render['#attached']['http_header'][] = ['X-Test-Teapot-Replace', 'This value gets replaced'];
-    $render['#attached']['http_header'][] = ['X-Test-Teapot-Replace', 'Teapot replaced', TRUE];
-    $render['#attached']['http_header'][] = ['X-Test-Teapot-No-Replace', 'This value is not replaced'];
-    $render['#attached']['http_header'][] = ['X-Test-Teapot-No-Replace', 'This one is added', FALSE];
-    $render['#attached']['http_header'][] = ['X-Test-Teapot', 'Teapot Mode Active'];
-    $render['#attached']['http_header'][] = ['Status', "418 I'm a teapot."];
-    return $render;
-  }
-
 }
diff --git a/core/modules/system/tests/modules/render_attached_test/render_attached_test.info.yml b/core/modules/system/tests/modules/render_attached_test/render_attached_test.info.yml
new file mode 100644
index 0000000..111fb96
--- /dev/null
+++ b/core/modules/system/tests/modules/render_attached_test/render_attached_test.info.yml
@@ -0,0 +1,8 @@
+name: 'Rendering #attached test'
+type: module
+description: 'Support module for HtmlResponseAttachmentsTest.'
+package: Testing
+version: VERSION
+core: 8.x
+dependencies:
+  - block
diff --git a/core/modules/system/tests/modules/render_attached_test/render_attached_test.routing.yml b/core/modules/system/tests/modules/render_attached_test/render_attached_test.routing.yml
new file mode 100644
index 0000000..d3d578b
--- /dev/null
+++ b/core/modules/system/tests/modules/render_attached_test/render_attached_test.routing.yml
@@ -0,0 +1,55 @@
+render_attached.teapot:
+  path: '/render_attached_test/teapot'
+  defaults:
+    _controller: '\Drupal\render_attached_test\Controller\TestController::teapotHeaderStatus'
+  requirements:
+    _access: 'TRUE'
+
+render_attached.header:
+  path: '/render_attached_test/header'
+  defaults:
+    _controller: '\Drupal\render_attached_test\Controller\TestController::header'
+  requirements:
+    _access: 'TRUE'
+
+render_attached.head:
+  path: '/render_attached_test/head'
+  defaults:
+    _controller: '\Drupal\render_attached_test\Controller\TestController::head'
+  requirements:
+    _access: 'TRUE'
+
+render_attached.feed_single:
+  path: '/render_attached_test/feed'
+  defaults:
+    _controller: '\Drupal\render_attached_test\Controller\TestController::feed'
+  requirements:
+    _access: 'TRUE'
+
+render_attached.teapot_dpa:
+  path: '/render_attached_test/teapot_dpa'
+  defaults:
+    _controller: '\Drupal\render_attached_test\Controller\TestController::teapotHeaderStatusDpa'
+  requirements:
+    _access: 'TRUE'
+
+render_attached.header_dpa:
+  path: '/render_attached_test/header_dpa'
+  defaults:
+    _controller: '\Drupal\render_attached_test\Controller\TestController::headerDpa'
+  requirements:
+    _access: 'TRUE'
+
+render_attached.head_dpa:
+  path: '/render_attached_test/head_dpa'
+  defaults:
+    _controller: '\Drupal\render_attached_test\Controller\TestController::headDpa'
+  requirements:
+    _access: 'TRUE'
+
+render_attached.feed_single_dpa:
+  path: '/render_attached_test/feed_dpa'
+  defaults:
+    _controller: '\Drupal\render_attached_test\Controller\TestController::feedDpa'
+  requirements:
+    _access: 'TRUE'
diff --git a/core/modules/system/tests/modules/render_attached_test/src/Controller/TestController.php b/core/modules/system/tests/modules/render_attached_test/src/Controller/TestController.php
new file mode 100644
index 0000000..8e71bf0
--- /dev/null
+++ b/core/modules/system/tests/modules/render_attached_test/src/Controller/TestController.php
@@ -0,0 +1,121 @@
+ 'meta',
+        '#attributes' => [
+          'test-attribute' => 'testvalue',
+        ],
+      ],
+      'test_head_attribute',
+    ];
+
+    $render = [];
+    $render['#attached']['html_head'][] = $head;
+    return $render;
+  }
+
+  /**
+   * Test attached feed rendering.
+   *
+   * @return array
+   *   A render array using the 'feed' directive.
+   */
+  public function feed() {
+    $render = [];
+    $render['#attached']['feed'][] = ['test://url', 'Your RSS feed.'];
+    return $render;
+  }
+
+  /**
+   * Test special header and status code rendering as a side-effect.
+   *
+   * @return array
+   *   A generic render array.
+   */
+  public function teapotHeaderStatusDpa() {
+    \drupal_process_attached($this->teapotHeaderStatus());
+    return ['#markup' => "I'm some markup here to fool the kernel into rendering this page."];
+  }
+
+  /**
+   * Test attached HTML head rendering as a side-effect.
+   *
+   * @return array
+   *   A render array using the 'http_header' directive.
+   */
+  public function headerDpa() {
+    \drupal_process_attached($this->header());
+    return ['#markup' => "I'm some markup here to fool the kernel into rendering this page."];
+  }
+
+  /**
+   * Test attached HTML head rendering as a side-effect.
+   *
+   * @return array
+   *   A render array using the 'html_head' directive.
+   */
+  public function headDpa() {
+    \drupal_process_attached($this->head());
+    return ['#markup' => "I'm some markup here to fool the kernel into rendering this page."];
+  }
+
+  /**
+   * Test attached feed rendering as a side-effect.
+   *
+   * @return array
+   *   A render array using the 'feed' directive.
+   */
+  public function feedDpa() {
+    \drupal_process_attached($this->feed());
+    return ['#markup' => "I'm some markup here to fool the kernel into rendering this page."];
+  }
+
+}
diff --git a/core/modules/system/tests/modules/render_attached_test/src/Plugin/Block/DrupalProcessAttachedBlock.php b/core/modules/system/tests/modules/render_attached_test/src/Plugin/Block/DrupalProcessAttachedBlock.php
new file mode 100644
index 0000000..3e7b342
--- /dev/null
+++ b/core/modules/system/tests/modules/render_attached_test/src/Plugin/Block/DrupalProcessAttachedBlock.php
@@ -0,0 +1,52 @@
+feed(), $controller->head());
+    $attached = BubbleableMetadata::mergeAttachments($attached, $controller->header());
+    $attached = BubbleableMetadata::mergeAttachments($attached, $controller->teapotHeaderStatus());
+
+    // Use drupal_process_attached() to attach all the #attached stuff.
+    drupal_process_attached($attached);
+
+    // Return some arbitrary markup so the block doesn't disappear.
+    return ['#markup' => 'Headers handled by drupal_process_attached().'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCacheMaxAge() {
+    return Cache::PERMANENT;
+  }
+
+}