 core/core.services.yml                             |  2 +-
 core/includes/common.inc                           | 55 +---------------------
 core/includes/theme.inc                            |  7 +--
 core/lib/Drupal/Core/Ajax/AjaxResponse.php         |  7 +--
 core/lib/Drupal/Core/Render/BubbleableMetadata.php | 31 +++++-------
 .../Core/Render/MainContent/AjaxRenderer.php       | 31 +++++-------
 .../Core/Render/MainContent/DialogRenderer.php     |  2 +-
 .../Core/Render/MainContent/ModalRenderer.php      |  2 +-
 core/lib/Drupal/Core/Render/Renderer.php           | 14 ++++++
 core/lib/Drupal/Core/Render/RendererInterface.php  | 44 +++++++++++++++++
 core/modules/editor/src/Element.php                |  3 +-
 .../src/Controller/FileWidgetAjaxController.php    |  2 +-
 core/modules/quickedit/src/QuickEditController.php |  4 +-
 .../src/Tests/Common/MergeAttachmentsTest.php      | 19 ++++----
 .../src/Controller/AjaxTestController.php          |  2 +-
 .../ajax_test/src/Form/AjaxTestDialogForm.php      |  2 +-
 .../views/src/Controller/ViewAjaxController.php    |  2 +-
 .../views_ui/src/Form/Ajax/ViewsFormBase.php       |  2 +-
 .../Tests/Core/Render/BubbleableMetadataTest.php   | 42 +++++++++++++++++
 19 files changed, 158 insertions(+), 115 deletions(-)

diff --git a/core/core.services.yml b/core/core.services.yml
index ee5c7f0..b2ea111 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -725,7 +725,7 @@ services:
       - { name: render.main_content_renderer, format: html }
   main_content_renderer.ajax:
     class: Drupal\Core\Render\MainContent\AjaxRenderer
-    arguments: ['@element_info']
+    arguments: ['@element_info', '@renderer']
     tags:
       - { name: render.main_content_renderer, format: drupal_ajax }
   main_content_renderer.dialog:
diff --git a/core/includes/common.inc b/core/includes/common.inc
index 27c9c54..e82ad36 100644
--- a/core/includes/common.inc
+++ b/core/includes/common.inc
@@ -22,6 +22,7 @@
 use Drupal\Component\Utility\UrlHelper;
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Language\LanguageInterface;
+use Drupal\Core\Render\Renderer;
 use Drupal\Core\Site\Settings;
 use Drupal\Core\Url;
 use Symfony\Component\HttpFoundation\Response;
@@ -849,58 +850,6 @@ function drupal_js_defaults($data = NULL) {
 }
 
 /**
- * Merges two #attached arrays.
- *
- * The values under the 'drupalSettings' key are merged in a special way, to
- * match the behavior of
- *
- * @code
- *   jQuery.extend(true, {}, $settings_items[0], $settings_items[1], ...)
- * @endcode
- *
- * This means integer indices are preserved just like string indices are,
- * rather than re-indexed as is common in PHP array merging.
- *
- * Example:
- * @code
- * function module1_page_attachments(&$page) {
- *   $page['a']['#attached']['drupalSettings']['foo'] = ['a', 'b', 'c'];
- * }
- * function module2_page_attachments(&$page) {
- *   $page['#attached']['drupalSettings']['foo'] = ['d'];
- * }
- * // When the page is rendered after the above code, and the browser runs the
- * // resulting <SCRIPT> tags, the value of drupalSettings.foo is
- * // ['d', 'b', 'c'], not ['a', 'b', 'c', 'd'].
- * @endcode
- *
- * By following jQuery.extend() merge logic rather than common PHP array merge
- * logic, the following are ensured:
- * - Attaching JavaScript settings is idempotent: attaching the same settings
- *   twice does not change the output sent to the browser.
- * - If pieces of the page are rendered in separate PHP requests and the
- *   returned settings are merged by JavaScript, the resulting settings are the
- *   same as if rendered in one PHP request and merged by PHP.
- *
- * @param array $a
- *   An #attached array.
- * @param array $b
- *   Another #attached array.
- *
- * @return array
- *   The merged #attached array.
- */
-function drupal_merge_attached(array $a, array $b) {
-  // If both #attached arrays contain drupalSettings, then merge them correctly;
-  // adding the same settings multiple times needs to behave idempotently.
-  if (!empty($a['drupalSettings']) && !empty($b['drupalSettings'])) {
-    $a['drupalSettings'] = NestedArray::mergeDeepArray([$a['drupalSettings'], $b['drupalSettings']], TRUE);
-    unset($b['drupalSettings']);
-  }
-  return NestedArray::mergeDeep($a, $b);
-}
-
-/**
  * Processes non-asset attachments in a render() structure.
  *
  * Libraries, JavaScript settings, feeds, HTML <head> tags and HTML <head> links
@@ -1407,7 +1356,7 @@ function drupal_pre_render_links($element) {
     }
     // Merge attachments.
     if (isset($child['#attached'])) {
-      $element['#attached'] = drupal_merge_attached($element['#attached'], $child['#attached']);
+      $element['#attached'] = Renderer::mergeAttachments($element['#attached'], $child['#attached']);
     }
   }
   return $element;
diff --git a/core/includes/theme.inc b/core/includes/theme.inc
index 43d9b8f..dc0db8e 100644
--- a/core/includes/theme.inc
+++ b/core/includes/theme.inc
@@ -16,6 +16,7 @@
 use Drupal\Component\Utility\UrlHelper;
 use Drupal\Component\Utility\Xss;
 use Drupal\Core\Asset\AttachedAssets;
+use Drupal\Core\Render\Renderer;
 use Drupal\Core\Config\Config;
 use Drupal\Core\Config\StorageException;
 use Drupal\Core\Extension\Extension;
@@ -1390,12 +1391,12 @@ function template_preprocess_html(&$variables) {
   // #type => html, to ensure that attachments added in #pre_render callbacks
   // for #type => html are included.
   $attached = $variables['html']['#attached'];
-  $attached = drupal_merge_attached($attached, $variables['page']['#attached']);
+  $attached = Renderer::mergeAttached($attached, $variables['page']['#attached']);
   if (isset($variables['page_top'])) {
-    $attached = drupal_merge_attached($attached, $variables['page_top']['#attached']);
+    $attached = Renderer::mergeAttached($attached, $variables['page_top']['#attached']);
   }
   if (isset($variables['page_bottom'])) {
-    $attached = drupal_merge_attached($attached, $variables['page_bottom']['#attached']);
+    $attached = Renderer::mergeAttached($attached, $variables['page_bottom']['#attached']);
   }
 
   // Render the attachments into HTML markup to be used directly in the template
diff --git a/core/lib/Drupal/Core/Ajax/AjaxResponse.php b/core/lib/Drupal/Core/Ajax/AjaxResponse.php
index fcb45af..5206f26 100644
--- a/core/lib/Drupal/Core/Ajax/AjaxResponse.php
+++ b/core/lib/Drupal/Core/Ajax/AjaxResponse.php
@@ -8,6 +8,7 @@
 namespace Drupal\Core\Ajax;
 
 use Drupal\Core\Asset\AttachedAssets;
+use Drupal\Core\Render\Renderer;
 use Symfony\Component\HttpFoundation\JsonResponse;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
@@ -43,12 +44,12 @@ class AjaxResponse extends JsonResponse {
    * necessary Ajax commands, if any.
    *
    * @param array $attachments
-   *   An #attached array.
+   *   An attachments (#attached) array.
    *
    * @return $this
    */
-  public function setAttachments(array $attachments) {
-    $this->attachments = $attachments;
+  public function addAttachments(array $attachments) {
+    $this->attachments = Renderer::mergeAttachments($this->attachments, $attachments);
     return $this;
   }
 
diff --git a/core/lib/Drupal/Core/Render/BubbleableMetadata.php b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
index c56b421..44dd26a 100644
--- a/core/lib/Drupal/Core/Render/BubbleableMetadata.php
+++ b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
@@ -25,11 +25,11 @@ class BubbleableMetadata {
   protected $tags;
 
   /**
-   * Attached assets.
+   * Attachments.
    *
    * @var string[][]
    */
-  protected $attached;
+  protected $attachments;
 
   /**
    * #post_render_cache metadata.
@@ -43,14 +43,14 @@ class BubbleableMetadata {
    *
    * @param string[] $tags
    *   An array of cache tags.
-   * @param array $attached
-   *   An array of attached assets.
+   * @param array $attachments
+   *   An array of attachments.
    * @param array $post_render_cache
    *   An array of #post_render_cache metadata.
    */
-  public function __construct(array $tags = [], array $attached = [], array $post_render_cache = []) {
+  public function __construct(array $tags = [], array $attachments = [], array $post_render_cache = []) {
     $this->tags = $tags;
-    $this->attached = $attached;
+    $this->attachments = $attachments;
     $this->postRenderCache = $post_render_cache;
   }
 
@@ -61,16 +61,11 @@ public function __construct(array $tags = [], array $attached = [], array $post_
    *   The other bubbleable metadata object.
    * @return static
    *   A new bubbleable metadata object, with the merged data.
-   *
-   * @todo Add unit test for this in
-   *       \Drupal\Tests\Core\Render\BubbleableMetadataTest when
-   *       drupal_merge_attached() no longer is a procedural function and remove
-   *       the '@codeCoverageIgnore' annotation.
    */
   public function merge(BubbleableMetadata $other) {
     $result = new BubbleableMetadata();
     $result->tags = Cache::mergeTags($this->tags, $other->tags);
-    $result->attached = drupal_merge_attached($this->attached, $other->attached);
+    $result->attachments = Renderer::mergeAttachments($this->attachments, $other->attachments);
     $result->postRenderCache = NestedArray::mergeDeep($this->postRenderCache, $other->postRenderCache);
     return $result;
   }
@@ -83,7 +78,7 @@ public function merge(BubbleableMetadata $other) {
    */
   public function applyTo(array &$build) {
     $build['#cache']['tags'] = $this->tags;
-    $build['#attached'] = $this->attached;
+    $build['#attached'] = $this->attachments;
     $build['#post_render_cache'] = $this->postRenderCache;
   }
 
@@ -96,11 +91,11 @@ public function applyTo(array &$build) {
    * @return static
    */
   public static function createFromRenderArray(array $build) {
-    $meta = new static();
-    $meta->tags = (isset($build['#cache']['tags'])) ? $build['#cache']['tags'] : [];
-    $meta->attached = (isset($build['#attached'])) ? $build['#attached'] : [];
-    $meta->postRenderCache = (isset($build['#post_render_cache'])) ? $build['#post_render_cache'] : [];
-    return $meta;
+    return new static(
+      (isset($build['#cache']['tags'])) ? $build['#cache']['tags'] : [],
+      (isset($build['#attached'])) ? $build['#attached'] : [],
+      (isset($build['#post_render_cache'])) ? $build['#post_render_cache'] : []
+    );
   }
 
 }
diff --git a/core/lib/Drupal/Core/Render/MainContent/AjaxRenderer.php b/core/lib/Drupal/Core/Render/MainContent/AjaxRenderer.php
index 6cddc0c..6b76802 100644
--- a/core/lib/Drupal/Core/Render/MainContent/AjaxRenderer.php
+++ b/core/lib/Drupal/Core/Render/MainContent/AjaxRenderer.php
@@ -12,6 +12,7 @@
 use Drupal\Core\Ajax\InsertCommand;
 use Drupal\Core\Ajax\PrependCommand;
 use Drupal\Core\Render\ElementInfoManagerInterface;
+use Drupal\Core\Render\RendererInterface;
 use Drupal\Core\Routing\RouteMatchInterface;
 use Symfony\Component\HttpFoundation\Request;
 
@@ -21,27 +22,30 @@
 class AjaxRenderer implements MainContentRendererInterface {
 
   /**
-   * The controller resolver.
+   * The element info manager.
    *
-   * @var \Drupal\Core\Controller\ControllerResolverInterface
+   * @var \Drupal\Core\Render\ElementInfoManagerInterface
    */
-  protected $controllerResolver;
+  protected $elementInfoManager;
 
   /**
-   * The element info manager.
+   * The renderer.
    *
-   * @var \Drupal\Core\Render\ElementInfoManagerInterface
+   * @var \Drupal\Core\Render\RendererInterface
    */
-  protected $elementInfoManager;
+  protected $renderer;
 
   /**
    * Constructs a new AjaxRenderer instance.
    *
    * @param \Drupal\Core\Render\ElementInfoManagerInterface $element_info_manager
    *   The element info manager.
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer.
    */
-  public function __construct(ElementInfoManagerInterface $element_info_manager) {
+  public function __construct(ElementInfoManagerInterface $element_info_manager, RendererInterface $renderer) {
     $this->elementInfoManager = $element_info_manager;
+    $this->renderer = $renderer;
   }
 
   /**
@@ -65,27 +69,18 @@ public function renderResponse(array $main_content, Request $request, RouteMatch
     }
 
     $html = $this->drupalRenderRoot($main_content);
-    $response->setAttachments($main_content['#attached']);
+    $response->addAttachments($main_content['#attached']);
 
     // 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->drupalRenderRoot($status_messages);
+    $output = $this->renderer->renderRoot($status_messages);
     if (!empty($output)) {
       $response->addCommand(new PrependCommand(NULL, $output));
     }
     return $response;
   }
 
-  /**
-   * Wraps drupal_render_root().
-   *
-   * @todo: Remove as part of https://drupal.org/node/2182149
-   */
-  protected function drupalRenderRoot(&$elements) {
-    return drupal_render_root($elements);
-  }
-
 }
diff --git a/core/lib/Drupal/Core/Render/MainContent/DialogRenderer.php b/core/lib/Drupal/Core/Render/MainContent/DialogRenderer.php
index 7af9d93..eb1c094 100644
--- a/core/lib/Drupal/Core/Render/MainContent/DialogRenderer.php
+++ b/core/lib/Drupal/Core/Render/MainContent/DialogRenderer.php
@@ -47,7 +47,7 @@ public function renderResponse(array $main_content, Request $request, RouteMatch
     // Attach the library necessary for using the OpenDialogCommand and set the
     // attachments for this Ajax response.
     $main_content['#attached']['library'][] = 'core/drupal.dialog.ajax';
-    $response->setAttachments($main_content['#attached']);
+    $response->addAttachments($main_content['#attached']);
 
     // Determine the title: use the title provided by the main content if any,
     // otherwise get it from the routing information.
diff --git a/core/lib/Drupal/Core/Render/MainContent/ModalRenderer.php b/core/lib/Drupal/Core/Render/MainContent/ModalRenderer.php
index 945a76c..662778c 100644
--- a/core/lib/Drupal/Core/Render/MainContent/ModalRenderer.php
+++ b/core/lib/Drupal/Core/Render/MainContent/ModalRenderer.php
@@ -30,7 +30,7 @@ public function renderResponse(array $main_content, Request $request, RouteMatch
     // Attach the library necessary for using the OpenModalDialogCommand and set
     // the attachments for this Ajax response.
     $main_content['#attached']['library'][] = 'core/drupal.dialog.ajax';
-    $response->setAttachments($main_content['#attached']);
+    $response->addAttachments($main_content['#attached']);
 
     // If the main content doesn't provide a title, use the title resolver.
     $title = isset($main_content['#title']) ? $main_content['#title'] : $this->titleResolver->getTitle($request, $route_match->getRouteObject());
diff --git a/core/lib/Drupal/Core/Render/Renderer.php b/core/lib/Drupal/Core/Render/Renderer.php
index 357ee6a..a62d325 100644
--- a/core/lib/Drupal/Core/Render/Renderer.php
+++ b/core/lib/Drupal/Core/Render/Renderer.php
@@ -7,6 +7,7 @@
 
 namespace Drupal\Core\Render;
 
+use Drupal\Component\Utility\NestedArray;
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Cache\CacheContexts;
 use Drupal\Core\Cache\CacheFactoryInterface;
@@ -582,4 +583,17 @@ public static function mergeBubbleableMetadata(array $a, array $b) {
     return $a;
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public static function mergeAttachments(array $a, array $b) {
+    // If both #attached arrays contain drupalSettings, then merge them correctly;
+    // adding the same settings multiple times needs to behave idempotently.
+    if (!empty($a['drupalSettings']) && !empty($b['drupalSettings'])) {
+      $a['drupalSettings'] = NestedArray::mergeDeepArray([$a['drupalSettings'], $b['drupalSettings']], TRUE);
+      unset($b['drupalSettings']);
+    }
+    return NestedArray::mergeDeep($a, $b);
+  }
+
 }
diff --git a/core/lib/Drupal/Core/Render/RendererInterface.php b/core/lib/Drupal/Core/Render/RendererInterface.php
index 3625e66..3f00568 100644
--- a/core/lib/Drupal/Core/Render/RendererInterface.php
+++ b/core/lib/Drupal/Core/Render/RendererInterface.php
@@ -304,4 +304,48 @@ public function getCacheableRenderArray(array $elements);
    */
   public static function mergeBubbleableMetadata(array $a, array $b);
 
+  /**
+   * Merges two attachments arrays.
+   *
+   * The values under the 'drupalSettings' key are merged in a special way, to
+   * match the behavior of
+   *
+   * @code
+   *   jQuery.extend(true, {}, $settings_items[0], $settings_items[1], ...)
+   * @endcode
+   *
+   * This means integer indices are preserved just like string indices are,
+   * rather than re-indexed as is common in PHP array merging.
+   *
+   * Example:
+   * @code
+   * function module1_page_attachments(&$page) {
+   *   $page['a']['#attached']['drupalSettings']['foo'] = ['a', 'b', 'c'];
+   * }
+   * function module2_page_attachments(&$page) {
+   *   $page['#attached']['drupalSettings']['foo'] = ['d'];
+   * }
+   * // When the page is rendered after the above code, and the browser runs the
+   * // resulting <SCRIPT> tags, the value of drupalSettings.foo is
+   * // ['d', 'b', 'c'], not ['a', 'b', 'c', 'd'].
+   * @endcode
+   *
+   * By following jQuery.extend() merge logic rather than common PHP array merge
+   * logic, the following are ensured:
+   * - Attaching JavaScript settings is idempotent: attaching the same settings
+   *   twice does not change the output sent to the browser.
+   * - If pieces of the page are rendered in separate PHP requests and the
+   *   returned settings are merged by JavaScript, the resulting settings are the
+   *   same as if rendered in one PHP request and merged by PHP.
+   *
+   * @param array $a
+   *   An attachments (#attached) array.
+   * @param array $b
+   *   Another attachments (#attached) array.
+   *
+   * @return array
+   *   The merged attachments (#attached) array.
+   */
+  public static function mergeAttachments(array $a, array $b);
+
 }
diff --git a/core/modules/editor/src/Element.php b/core/modules/editor/src/Element.php
index f0b97bf..7a3ecd2 100644
--- a/core/modules/editor/src/Element.php
+++ b/core/modules/editor/src/Element.php
@@ -7,6 +7,7 @@
 
 namespace Drupal\editor;
 
+use Drupal\Core\Render\Renderer;
 use Drupal\editor\Entity\Editor;
 use Drupal\filter\Entity\FilterFormat;
 use Drupal\Component\Plugin\PluginManagerInterface;
@@ -88,7 +89,7 @@ function preRenderTextFormat(array $element) {
     $element['#attached']['library'][] = 'editor/drupal.editor';
 
     // Attach attachments for all available editors.
-    $element['#attached'] = drupal_merge_attached($element['#attached'], $this->pluginManager->getAttachments($format_ids));
+    $element['#attached'] = Renderer::mergeAttachments($element['#attached'], $this->pluginManager->getAttachments($format_ids));
 
     // Apply XSS filters when editing content if necessary. Some types of text
     // editors cannot guarantee that the end user won't become a victim of XSS.
diff --git a/core/modules/file/src/Controller/FileWidgetAjaxController.php b/core/modules/file/src/Controller/FileWidgetAjaxController.php
index e449caf..7df4cc9 100644
--- a/core/modules/file/src/Controller/FileWidgetAjaxController.php
+++ b/core/modules/file/src/Controller/FileWidgetAjaxController.php
@@ -81,7 +81,7 @@ public function upload(Request $request) {
     $output = drupal_render($form);
 
     $response = new AjaxResponse();
-    $response->setAttachments($form['#attached']);
+    $response->addAttachments($form['#attached']);
     foreach ($commands as $command) {
       $response->addCommand($command, TRUE);
     }
diff --git a/core/modules/quickedit/src/QuickEditController.php b/core/modules/quickedit/src/QuickEditController.php
index 339eab4..5ff39a1 100644
--- a/core/modules/quickedit/src/QuickEditController.php
+++ b/core/modules/quickedit/src/QuickEditController.php
@@ -142,7 +142,7 @@ public function attachments(Request $request) {
       throw new NotFoundHttpException();
     }
 
-    $response->setAttachments($this->editorSelector->getEditorAttachments($editors));
+    $response->addAttachments($this->editorSelector->getEditorAttachments($editors));
 
     return $response;
   }
@@ -207,7 +207,7 @@ public function fieldForm(EntityInterface $entity, $field_name, $langcode, $view
       $output = drupal_render($form);
       // When working with a hidden form, we don't want its CSS/JS to be loaded.
       if ($request->request->get('nocssjs') !== 'true') {
-        $response->setAttachments($form['#attached']);
+        $response->addAttachments($form['#attached']);
       }
       $response->addCommand(new FieldFormCommand($output));
 
diff --git a/core/modules/system/src/Tests/Common/MergeAttachmentsTest.php b/core/modules/system/src/Tests/Common/MergeAttachmentsTest.php
index f59e395..20ddffb 100644
--- a/core/modules/system/src/Tests/Common/MergeAttachmentsTest.php
+++ b/core/modules/system/src/Tests/Common/MergeAttachmentsTest.php
@@ -7,12 +7,13 @@
 
 namespace Drupal\system\Tests\Common;
 
+use Drupal\Core\Render\Renderer;
 use Drupal\simpletest\KernelTestBase;
 
 /**
  * Tests the merging of attachments.
  *
- * @see drupal_merge_attached()
+ * @see Renderer::mergeAttachments()
  *
  * @group Common
  */
@@ -40,7 +41,7 @@ function testLibraryMerging() {
         'core/jquery',
       ),
     );
-    $this->assertIdentical($expected['#attached'], drupal_merge_attached($a['#attached'], $b['#attached']), 'Attachments merged correctly.');
+    $this->assertIdentical($expected['#attached'], Renderer::mergeAttachments($a['#attached'], $b['#attached']), 'Attachments merged correctly.');
 
     // Merging in the opposite direction yields the opposite library order.
     $expected['#attached'] = array(
@@ -50,7 +51,7 @@ function testLibraryMerging() {
         'core/drupalSettings',
       ),
     );
-    $this->assertIdentical($expected['#attached'], drupal_merge_attached($b['#attached'], $a['#attached']), 'Attachments merged correctly; opposite merging yields opposite order.');
+    $this->assertIdentical($expected['#attached'], Renderer::mergeAttachments($b['#attached'], $a['#attached']), 'Attachments merged correctly; opposite merging yields opposite order.');
 
     // Merging with duplicates: duplicates are simply retained, it's up to the
     // rest of the system to handle duplicates.
@@ -63,7 +64,7 @@ function testLibraryMerging() {
         'core/drupalSettings',
       ),
     );
-    $this->assertIdentical($expected['#attached'], drupal_merge_attached($a['#attached'], $b['#attached']), 'Attachments merged correctly; duplicates are retained.');
+    $this->assertIdentical($expected['#attached'], Renderer::mergeAttachments($a['#attached'], $b['#attached']), 'Attachments merged correctly; duplicates are retained.');
   }
 
   /**
@@ -98,7 +99,7 @@ function testJsSettingMerging() {
         'bar' => ['a', 'b', 'c'],
       ],
     );
-    $this->assertIdentical($expected['#attached'], drupal_merge_attached($a['#attached'], $b['#attached']), 'Attachments merged correctly.');
+    $this->assertIdentical($expected['#attached'], Renderer::mergeAttachments($a['#attached'], $b['#attached']), 'Attachments merged correctly.');
 
     // Merging in the opposite direction yields the opposite JS setting asset
     // order.
@@ -113,7 +114,7 @@ function testJsSettingMerging() {
         'foo' => ['d'],
       ],
     );
-    $this->assertIdentical($expected['#attached'], drupal_merge_attached($b['#attached'], $a['#attached']), 'Attachments merged correctly; opposite merging yields opposite order.');
+    $this->assertIdentical($expected['#attached'], Renderer::mergeAttachments($b['#attached'], $a['#attached']), 'Attachments merged correctly; opposite merging yields opposite order.');
 
     // Merging with duplicates (simple case).
     $b['#attached']['drupalSettings']['foo'] = ['a', 'b', 'c'];
@@ -128,7 +129,7 @@ function testJsSettingMerging() {
         'bar' => ['a', 'b', 'c'],
       ],
     );
-    $this->assertIdentical($expected['#attached'], drupal_merge_attached($a['#attached'], $b['#attached']));
+    $this->assertIdentical($expected['#attached'], Renderer::mergeAttachments($a['#attached'], $b['#attached']));
 
     // Merging with duplicates (simple case) in the opposite direction yields
     // the opposite JS setting asset order, but also opposite overriding order.
@@ -143,7 +144,7 @@ function testJsSettingMerging() {
         'foo' => ['d', 'b', 'c'],
       ],
     );
-    $this->assertIdentical($expected['#attached'], drupal_merge_attached($b['#attached'], $a['#attached']));
+    $this->assertIdentical($expected['#attached'], Renderer::mergeAttachments($b['#attached'], $a['#attached']));
 
     // Merging with duplicates: complex case.
     // Only the second of these two entries should appear in drupalSettings.
@@ -167,7 +168,7 @@ function testJsSettingMerging() {
     $settings_two_b = array('moduleName' => array('ui' => array('button D', 'button E'), 'magical flag' => 3.14, 'thingiesOnPage' => array('id2' => array())));
     $build['b']['#attached']['drupalSettings']['commonTestRealWorldAlmostIdentical'] = $settings_two_b;
 
-    $merged = drupal_merge_attached($build['a']['#attached'], $build['b']['#attached']);
+    $merged = Renderer::mergeAttachments($build['a']['#attached'], $build['b']['#attached']);
 
     // Test whether #attached can be used to override a previous setting.
     $this->assertIdentical('secondValue', $merged['drupalSettings']['commonTest']);
diff --git a/core/modules/system/tests/modules/ajax_test/src/Controller/AjaxTestController.php b/core/modules/system/tests/modules/ajax_test/src/Controller/AjaxTestController.php
index c4d40bd..91f7af4 100644
--- a/core/modules/system/tests/modules/ajax_test/src/Controller/AjaxTestController.php
+++ b/core/modules/system/tests/modules/ajax_test/src/Controller/AjaxTestController.php
@@ -54,7 +54,7 @@ public function order() {
     // HTML insertion command.
     $response->addCommand(new HtmlCommand('body', 'Hello, world!'));
     $build['#attached']['library'][] = 'ajax_test/order';
-    $response->setAttachments($build['#attached']);
+    $response->addAttachments($build['#attached']);
 
     return $response;
   }
diff --git a/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestDialogForm.php b/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestDialogForm.php
index c2a6a07..a1a01f9 100644
--- a/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestDialogForm.php
+++ b/core/modules/system/tests/modules/ajax_test/src/Form/AjaxTestDialogForm.php
@@ -105,7 +105,7 @@ protected function dialog($is_modal = FALSE) {
     // Attach the library necessary for using the Open(Modal)DialogCommand and
     // set the attachments for this Ajax response.
     $content['#attached']['library'][] = 'core/drupal.dialog.ajax';
-    $response->setAttachments($content['#attached']);
+    $response->addAttachments($content['#attached']);
 
     if ($is_modal) {
       $response->addCommand(new OpenModalDialogCommand($title, $html));
diff --git a/core/modules/views/src/Controller/ViewAjaxController.php b/core/modules/views/src/Controller/ViewAjaxController.php
index e938ef8..87902ca 100644
--- a/core/modules/views/src/Controller/ViewAjaxController.php
+++ b/core/modules/views/src/Controller/ViewAjaxController.php
@@ -152,7 +152,7 @@ public function ajaxView(Request $request) {
 
         if ($preview = $view->preview($display_id, $args)) {
           $response->addCommand(new ReplaceCommand(".view-dom-id-$dom_id", $this->renderer->render($preview)));
-          $response->setAttachments($preview['#attached']);
+          $response->addAttachments($preview['#attached']);
         }
         return $response;
       }
diff --git a/core/modules/views_ui/src/Form/Ajax/ViewsFormBase.php b/core/modules/views_ui/src/Form/Ajax/ViewsFormBase.php
index 6dd609e..1568f1d 100644
--- a/core/modules/views_ui/src/Form/Ajax/ViewsFormBase.php
+++ b/core/modules/views_ui/src/Form/Ajax/ViewsFormBase.php
@@ -217,7 +217,7 @@ protected function ajaxFormWrapper($form_class, FormStateInterface &$form_state)
       // Attach the library necessary for using the OpenModalDialogCommand and
       // set the attachments for this Ajax response.
       $form['#attached']['library'][] = 'core/drupal.dialog.ajax';
-      $response->setAttachments($form['#attached']);
+      $response->addAttachments($form['#attached']);
 
       $display = '';
       $status_messages = array('#theme' => 'status_messages');
diff --git a/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php b/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php
index 351b282..28bc0b6 100644
--- a/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php
+++ b/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php
@@ -18,6 +18,48 @@
 class BubbleableMetadataTest extends UnitTestCase {
 
   /**
+   * @covers ::merge
+   * @dataProvider providerTestMerge
+   */
+  public function testMerge(BubbleableMetadata $a, BubbleableMetadata $b, BubbleableMetadata $expected) {
+    $this->assertEquals($expected, $a->merge($b));
+  }
+
+  /**
+   * Provides test data for merge().
+   *
+   * @return array
+   */
+  public function providerTestMerge() {
+    $data = [];
+
+    $empty = new BubbleableMetadata();
+    $cache_tags = new BubbleableMetadata(['foo:bar']);
+    $settings = new BubbleableMetadata([], ['settings' => ['foo' => 'bar']]);
+    $libs_foo = new BubbleableMetadata([], ['libraries' => ['core/foo']]);
+    $libs_bar = new BubbleableMetadata([], ['libraries' => ['core/bar']]);
+
+    $data[] = [$empty, $empty, $empty];
+
+    $data[] = [$empty, $cache_tags, $cache_tags];
+    $data[] = [$cache_tags, $empty, $cache_tags];
+    $data[] = [$cache_tags, $cache_tags, $cache_tags];
+
+    $data[] = [$empty, $settings, $settings];
+    $data[] = [$settings, $empty, $settings];
+    $data[] = [$settings, $settings, $settings];
+
+    $data[] = [$libs_foo, $libs_bar, new BubbleableMetadata([], ['libraries' => ['core/foo', 'core/bar']])];
+    $data[] = [$libs_bar, $libs_foo, new BubbleableMetadata([], ['libraries' => ['core/bar', 'core/foo']])];
+    $data[] = [$libs_foo, $settings, new BubbleableMetadata([], ['settings' => ['foo' => 'bar'], 'libraries' => ['core/foo']])];
+    $data[] = [$settings, $libs_foo, new BubbleableMetadata([], ['settings' => ['foo' => 'bar'], 'libraries' => ['core/foo']])];
+
+    $data[] = [$cache_tags, $libs_foo, new BubbleableMetadata(['foo:bar'], ['libraries' => ['core/foo']])];
+
+    return $data;
+  }
+
+  /**
    * @covers ::apply
    * @dataProvider providerTestApply
    */
