diff --git a/core/includes/common.inc b/core/includes/common.inc
index ecf76f1bc49d6dbe083c9815511a97bef0546d9d..c9fdd6f4f2d9bda966a2279e8373a698caa6db69 100644
--- a/core/includes/common.inc
+++ b/core/includes/common.inc
@@ -32,6 +32,7 @@
 use Drupal\Core\Datetime\DrupalDateTime;
 use Drupal\Core\Routing\GeneratorNotInitializedException;
 use Drupal\Core\Template\Attribute;
+use Drupal\Core\Render\BubbleableMetadata;
 use Drupal\Core\Render\Element;
 use Drupal\Core\Session\AnonymousUserSession;
 
@@ -571,10 +572,10 @@ function drupal_js_defaults($data = NULL) {
  *   The merged #attached array.
  *
  * @deprecated To be removed in Drupal 8.0.x. Use
- *   \Drupal\Core\Render\Renderer::mergeAttachments() instead.
+ *   \Drupal\Core\Render\BubbleableMetadata::mergeAttachments() instead.
  */
 function drupal_merge_attached(array $a, array $b) {
-  return \Drupal::service('renderer')->mergeAttachments($a, $b);
+  return BubbleableMetadata::mergeAttachments($a, $b);
 }
 
 /**
diff --git a/core/lib/Drupal/Core/Ajax/AjaxResponse.php b/core/lib/Drupal/Core/Ajax/AjaxResponse.php
index befb3be302093a69ea5da5c1530fce4f69f7b5ba..a83898d4d4bf937ec6737a01da2ede6a62999dfa 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\BubbleableMetadata;
 use Drupal\Core\Render\Renderer;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\HttpFoundation\JsonResponse;
@@ -79,7 +80,7 @@ public function addCommand(CommandInterface $command, $prepend = FALSE) {
         'library' => $assets->getLibraries(),
         'drupalSettings' => $assets->getSettings(),
       ];
-      $attachments = $this->getRenderer()->mergeAttachments($this->attachments, $attachments);
+      $attachments = BubbleableMetadata::mergeAttachments($this->attachments, $attachments);
       $this->setAttachments($attachments);
     }
 
diff --git a/core/lib/Drupal/Core/Cache/CacheableMetadata.php b/core/lib/Drupal/Core/Cache/CacheableMetadata.php
index 0a22f56b420c4d03feeaaa96a76531a8511891a5..1bd01a584cab4bf974f9e1b4ce3421826b4b82bf 100644
--- a/core/lib/Drupal/Core/Cache/CacheableMetadata.php
+++ b/core/lib/Drupal/Core/Cache/CacheableMetadata.php
@@ -140,9 +140,38 @@ public function setCacheMaxAge($max_age) {
    */
   public function merge(CacheableMetadata $other) {
     $result = new static();
-    $result->contexts = Cache::mergeContexts($this->contexts, $other->contexts);
-    $result->tags = Cache::mergeTags($this->tags, $other->tags);
-    $result->maxAge = Cache::mergeMaxAges($this->maxAge, $other->maxAge);
+
+    // This is called many times per request, so avoid merging unless absolutely
+    // necessary.
+    if (empty($this->contexts)) {
+      $result->contexts = $other->contexts;
+    }
+    elseif (empty($other->contexts)) {
+      $result->contexts = $this->contexts;
+    }
+    else {
+      $result->contexts = Cache::mergeContexts($this->contexts, $other->contexts);
+    }
+
+    if (empty($this->tags)) {
+      $result->tags = $other->tags;
+    }
+    elseif (empty($other->tags)) {
+      $result->tags = $this->tags;
+    }
+    else {
+      $result->tags = Cache::mergeTags($this->tags, $other->tags);
+    }
+
+    if ($this->maxAge === Cache::PERMANENT) {
+      $result->maxAge = $other->maxAge;
+    }
+    elseif ($other->maxAge === Cache::PERMANENT) {
+      $result->maxAge = $this->maxAge;
+    }
+    else {
+      $result->maxAge = Cache::mergeMaxAges($this->maxAge, $other->maxAge);
+    }
     return $result;
   }
 
diff --git a/core/lib/Drupal/Core/Render/BubbleableMetadata.php b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
index d21df553efaf305161336336a4c67d4b34a1a4d8..0e41072135c507f74a236b2c76272dd3f1015e6a 100644
--- a/core/lib/Drupal/Core/Render/BubbleableMetadata.php
+++ b/core/lib/Drupal/Core/Render/BubbleableMetadata.php
@@ -35,9 +35,21 @@ class BubbleableMetadata extends CacheableMetadata {
    */
   public function merge(CacheableMetadata $other) {
     $result = parent::merge($other);
+
+    // This is called many times per request, so avoid merging unless absolutely
+    // necessary.
     if ($other instanceof BubbleableMetadata) {
-      $result->attached = \Drupal::service('renderer')->mergeAttachments($this->attached, $other->attached);
+      if (empty($this->attached)) {
+        $result->attached = $other->attached;
+      }
+      elseif (empty($other->attached)) {
+        $result->attached = $this->attached;
+      }
+      else {
+        $result->attached = static::mergeAttachments($this->attached, $other->attached);
+      }
     }
+
     return $result;
   }
 
@@ -142,4 +154,76 @@ public function setAssets(array $assets) {
     return $this;
   }
 
+  /**
+   * Merges two attachments arrays (which live under the '#attached' key).
+   *
+   * 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 array.
+   * @param array $b
+   *   Another attachments array.
+   *
+   * @return array
+   *   The merged attachments array.
+   */
+  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'])) {
+      $drupalSettings = NestedArray::mergeDeepArray(array($a['drupalSettings'], $b['drupalSettings']), TRUE);
+      // No need for re-merging them.
+      unset($a['drupalSettings']);
+      unset($b['drupalSettings']);
+    }
+    // Optimize merging of placeholders: no need for deep merging.
+    if (!empty($a['placeholders']) && !empty($b['placeholders'])) {
+      $placeholders = $a['placeholders'] + $b['placeholders'];
+      // No need for re-merging them.
+      unset($a['placeholders']);
+      unset($b['placeholders']);
+    }
+    // Apply the normal merge.
+    $a = array_merge_recursive($a, $b);
+    if (isset($drupalSettings)) {
+      // Save the custom merge for the drupalSettings.
+      $a['drupalSettings'] = $drupalSettings;
+    }
+    if (isset($placeholders)) {
+      // Save the custom merge for the placeholders.
+      $a['placeholders'] = $placeholders;
+    }
+    return $a;
+  }
+
 }
diff --git a/core/lib/Drupal/Core/Render/RendererInterface.php b/core/lib/Drupal/Core/Render/RendererInterface.php
index dfad73f37b5263e872ef6ae5054f4e30fe96c6d6..5d59d4d8165df5cb96442c077f4eef5d0f433b7e 100644
--- a/core/lib/Drupal/Core/Render/RendererInterface.php
+++ b/core/lib/Drupal/Core/Render/RendererInterface.php
@@ -349,48 +349,4 @@ public function mergeBubbleableMetadata(array $a, array $b);
    */
   public function addCacheableDependency(array &$elements, $dependency);
 
-  /**
-   * Merges two attachments arrays (which live under the '#attached' key).
-   *
-   * 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 array.
-   * @param array $b
-   *   Another attachments array.
-   *
-   * @return array
-   *   The merged attachments array.
-   */
-  public function mergeAttachments(array $a, array $b);
-
 }
diff --git a/core/modules/system/src/Tests/Common/MergeAttachmentsTest.php b/core/modules/system/src/Tests/Common/MergeAttachmentsTest.php
deleted file mode 100644
index 466ed3aa85173114b300f4f2faa76405427fc0ae..0000000000000000000000000000000000000000
--- a/core/modules/system/src/Tests/Common/MergeAttachmentsTest.php
+++ /dev/null
@@ -1,411 +0,0 @@
-<?php
-
-/**
- * @file
- * Contains \Drupal\system\Tests\Common\MergeAttachmentsTest.
- */
-
-namespace Drupal\system\Tests\Common;
-
-use Drupal\simpletest\KernelTestBase;
-
-/**
- * Tests the merging of attachments.
- *
- * @see \Drupal::service('renderer')->mergeAttachments()
- *
- * @group Common
- */
-class MergeAttachmentsTest extends KernelTestBase {
-
-  /**
-   * Tests library asset merging.
-   */
-  function testLibraryMerging() {
-    $renderer = \Drupal::service('renderer');
-
-    $a['#attached'] = array(
-      'library' => array(
-        'core/drupal',
-        'core/drupalSettings',
-      ),
-      'drupalSettings' => [
-        'foo' => ['d'],
-      ],
-    );
-    $b['#attached'] = array(
-      'library' => array(
-        'core/jquery',
-      ),
-      'drupalSettings' => [
-        'bar' => ['a', 'b', 'c'],
-      ],
-    );
-    $expected['#attached'] = array(
-      'library' => array(
-        'core/drupal',
-        'core/drupalSettings',
-        'core/jquery',
-      ),
-      'drupalSettings' => [
-        'foo' => ['d'],
-        'bar' => ['a', 'b', 'c'],
-      ],
-    );
-    $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(
-      'library' => array(
-        'core/jquery',
-        'core/drupal',
-        'core/drupalSettings',
-      ),
-      'drupalSettings' => [
-        'bar' => ['a', 'b', 'c'],
-        'foo' => ['d'],
-      ],
-    );
-    $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.
-    $b['#attached']['library'][] = 'core/drupalSettings';
-    $expected['#attached'] = array(
-      'library' => array(
-        'core/drupal',
-        'core/drupalSettings',
-        'core/jquery',
-        'core/drupalSettings',
-      ),
-      'drupalSettings' => [
-        'foo' => ['d'],
-        'bar' => ['a', 'b', 'c'],
-      ],
-    );
-    $this->assertIdentical($expected['#attached'], $renderer->mergeAttachments($a['#attached'], $b['#attached']), 'Attachments merged correctly; duplicates are retained.');
-
-    // Merging with duplicates (simple case).
-    $b['#attached']['drupalSettings']['foo'] = ['a', 'b', 'c'];
-    $expected['#attached'] = array(
-      'library' => array(
-        'core/drupal',
-        'core/drupalSettings',
-        'core/jquery',
-        'core/drupalSettings',
-      ),
-      'drupalSettings' => [
-        'foo' => ['a', 'b', 'c'],
-        'bar' => ['a', 'b', 'c'],
-      ],
-    );
-    $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.
-    $expected['#attached'] = array(
-      'library' => array(
-        'core/jquery',
-        'core/drupalSettings',
-        'core/drupal',
-        'core/drupalSettings',
-      ),
-      'drupalSettings' => [
-        'bar' => ['a', 'b', 'c'],
-        'foo' => ['d', 'b', 'c'],
-      ],
-    );
-    $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.
-    $build = array();
-    $build['a']['#attached']['drupalSettings']['commonTest'] = 'firstValue';
-    $build['b']['#attached']['drupalSettings']['commonTest'] = 'secondValue';
-    // Only the second of these entries should appear in drupalSettings.
-    $build['a']['#attached']['drupalSettings']['commonTestJsArrayLiteral'] = ['firstValue'];
-    $build['b']['#attached']['drupalSettings']['commonTestJsArrayLiteral'] = ['secondValue'];
-    // Only the second of these two entries should appear in drupalSettings.
-    $build['a']['#attached']['drupalSettings']['commonTestJsObjectLiteral'] = ['key' => 'firstValue'];
-    $build['b']['#attached']['drupalSettings']['commonTestJsObjectLiteral'] = ['key' => 'secondValue'];
-    // Real world test case: multiple elements in a render array are adding the
-    // same (or nearly the same) JavaScript settings. When merged, they should
-    // contain all settings and not duplicate some settings.
-    $settings_one = array('moduleName' => array('ui' => array('button A', 'button B'), 'magical flag' => 3.14159265359));
-    $build['a']['#attached']['drupalSettings']['commonTestRealWorldIdentical'] = $settings_one;
-    $build['b']['#attached']['drupalSettings']['commonTestRealWorldIdentical'] = $settings_one;
-    $settings_two_a = array('moduleName' => array('ui' => array('button A', 'button B', 'button C'), 'magical flag' => 3.14159265359, 'thingiesOnPage' => array('id1' => array())));
-    $build['a']['#attached']['drupalSettings']['commonTestRealWorldAlmostIdentical'] = $settings_two_a;
-    $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 = $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']);
-
-    // Test whether #attached can be used to add and override a JavaScript
-    // array literal (an indexed PHP array) values.
-    $this->assertIdentical('secondValue', $merged['drupalSettings']['commonTestJsArrayLiteral'][0]);
-
-    // Test whether #attached can be used to add and override a JavaScript
-    // object literal (an associate PHP array) values.
-    $this->assertIdentical('secondValue', $merged['drupalSettings']['commonTestJsObjectLiteral']['key']);
-
-    // Test whether the two real world cases are handled correctly: the first
-    // adds the exact same settings twice and hence tests idempotency, the
-    // second adds *almost* the same settings twice: the second time, some
-    // values are altered, and some key-value pairs are added.
-    $settings_two['moduleName']['thingiesOnPage']['id1'] = array();
-    $this->assertIdentical($settings_one, $merged['drupalSettings']['commonTestRealWorldIdentical']);
-    $expected_settings_two = $settings_two_a;
-    $expected_settings_two['moduleName']['ui'][0] = 'button D';
-    $expected_settings_two['moduleName']['ui'][1] = 'button E';
-    $expected_settings_two['moduleName']['ui'][2] = 'button C';
-    $expected_settings_two['moduleName']['magical flag'] = 3.14;
-    $expected_settings_two['moduleName']['thingiesOnPage']['id2'] = [];
-    $this->assertIdentical($expected_settings_two, $merged['drupalSettings']['commonTestRealWorldAlmostIdentical']);
-  }
-
-  /**
-   * Tests feed asset merging.
-   */
-  function testFeedMerging() {
-    $renderer = \Drupal::service('renderer');
-
-    $a['#attached'] = array(
-      'feed' => array(
-        array(
-          'aggregator/rss',
-          t('Feed title'),
-        ),
-      ),
-    );
-    $b['#attached'] = array(
-      'feed' => array(
-        array(
-          'taxonomy/term/1/feed',
-          'RSS - foo',
-        ),
-      ),
-    );
-    $expected['#attached'] = array(
-      'feed' => array(
-        array(
-          'aggregator/rss',
-          t('Feed title'),
-        ),
-        array(
-          'taxonomy/term/1/feed',
-          'RSS - foo',
-        ),
-      ),
-    );
-    $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(
-      'feed' => array(
-        array(
-          'taxonomy/term/1/feed',
-          'RSS - foo',
-        ),
-        array(
-          'aggregator/rss',
-          t('Feed title'),
-        ),
-      ),
-    );
-    $this->assertIdentical($expected['#attached'], $renderer->mergeAttachments($b['#attached'], $a['#attached']), 'Attachments merged correctly; opposite merging yields opposite order.');
-  }
-
-  /**
-   * Tests html_head asset merging.
-   */
-  function testHtmlHeadMerging() {
-    $renderer = \Drupal::service('renderer');
-
-    $a['#attached'] = array(
-      'html_head' => array(
-        array(
-          '#tag' => 'meta',
-          '#attributes' => array(
-            'charset' => 'utf-8',
-          ),
-          '#weight' => -1000,
-        ),
-        'system_meta_content_type',
-      ),
-    );
-    $b['#attached'] = array(
-      'html_head' => array(
-        array(
-          '#type' => 'html_tag',
-          '#tag' => 'meta',
-          '#attributes' => array(
-            'name' => 'Generator',
-            'content' => 'Kitten 1.0 (https://www.drupal.org/project/kitten)',
-          ),
-        ),
-        'system_meta_generator',
-      ),
-    );
-    $expected['#attached'] = array(
-      'html_head' => array(
-        array(
-          '#tag' => 'meta',
-          '#attributes' => array(
-            'charset' => 'utf-8',
-          ),
-          '#weight' => -1000,
-        ),
-        'system_meta_content_type',
-        array(
-          '#type' => 'html_tag',
-          '#tag' => 'meta',
-          '#attributes' => array(
-            'name' => 'Generator',
-            'content' => 'Kitten 1.0 (https://www.drupal.org/project/kitten)',
-          ),
-        ),
-        'system_meta_generator',
-      ),
-    );
-    $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(
-      'html_head' => array(
-        array(
-          '#type' => 'html_tag',
-          '#tag' => 'meta',
-          '#attributes' => array(
-            'name' => 'Generator',
-            'content' => 'Kitten 1.0 (https://www.drupal.org/project/kitten)',
-          ),
-        ),
-        'system_meta_generator',
-        array(
-          '#tag' => 'meta',
-          '#attributes' => array(
-            'charset' => 'utf-8',
-          ),
-          '#weight' => -1000,
-        ),
-        'system_meta_content_type',
-      ),
-    );
-    $this->assertIdentical($expected['#attached'], $renderer->mergeAttachments($b['#attached'], $a['#attached']), 'Attachments merged correctly; opposite merging yields opposite order.');
-  }
-
-  /**
-   * Tests html_head_link asset merging.
-   */
-  function testHtmlHeadLinkMerging() {
-    $renderer = \Drupal::service('renderer');
-
-    $a['#attached'] = array(
-      'html_head_link' => array(
-        array(
-          'rel' => 'rel',
-          'href' => 'http://rel.example.com',
-        ),
-        TRUE,
-      ),
-    );
-    $b['#attached'] = array(
-      'html_head_link' => array(
-        array(
-          'rel' => 'shortlink',
-          'href' => 'http://shortlink.example.com',
-        ),
-        FALSE,
-      ),
-    );
-    $expected['#attached'] = array(
-      'html_head_link' => array(
-        array(
-          'rel' => 'rel',
-          'href' => 'http://rel.example.com',
-        ),
-        TRUE,
-        array(
-          'rel' => 'shortlink',
-          'href' => 'http://shortlink.example.com',
-        ),
-        FALSE,
-      ),
-    );
-    $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(
-      'html_head_link' => array(
-        array(
-          'rel' => 'shortlink',
-          'href' => 'http://shortlink.example.com',
-        ),
-        FALSE,
-        array(
-          'rel' => 'rel',
-          'href' => 'http://rel.example.com',
-        ),
-        TRUE,
-      ),
-    );
-    $this->assertIdentical($expected['#attached'], $renderer->mergeAttachments($b['#attached'], $a['#attached']), 'Attachments merged correctly; opposite merging yields opposite order.');
-  }
-
-  /**
-   * Tests http_header asset merging.
-   */
-  function testHttpHeaderMerging() {
-    $renderer = \Drupal::service('renderer');
-
-    $a['#attached'] = array(
-      'http_header' => array(
-        array(
-          'Content-Type',
-          'application/rss+xml; charset=utf-8',
-        ),
-      ),
-    );
-    $b['#attached'] = array(
-      'http_header' => array(
-        array(
-          'Expires',
-          'Sun, 19 Nov 1978 05:00:00 GMT',
-        ),
-      ),
-    );
-    $expected['#attached'] = array(
-      'http_header' => array(
-        array(
-          'Content-Type',
-          'application/rss+xml; charset=utf-8',
-        ),
-        array(
-          'Expires',
-          'Sun, 19 Nov 1978 05:00:00 GMT',
-        ),
-      ),
-    );
-    $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(
-      'http_header' => array(
-        array(
-          'Expires',
-          'Sun, 19 Nov 1978 05:00:00 GMT',
-        ),
-        array(
-          'Content-Type',
-          'application/rss+xml; charset=utf-8',
-        ),
-      ),
-    );
-    $this->assertIdentical($expected['#attached'], $renderer->mergeAttachments($b['#attached'], $a['#attached']), 'Attachments merged correctly; opposite merging yields opposite order.');
-  }
-
-}
diff --git a/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php b/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php
index dc0beff63b532083f0772416e5fb3f1b95a62d6a..51ff5062e8daf3504294a88ee168b8384b3db105 100644
--- a/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php
+++ b/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php
@@ -206,4 +206,411 @@ public function providerTestCreateFromRenderArray() {
     return $data;
   }
 
+  /**
+   * Tests library asset merging.
+   *
+   * @covers ::mergeAttachments
+   */
+  function testMergeAttachmentsLibraryMerging() {
+    $a['#attached'] = array(
+      'library' => array(
+        'core/drupal',
+        'core/drupalSettings',
+      ),
+      'drupalSettings' => [
+        'foo' => ['d'],
+      ],
+    );
+    $b['#attached'] = array(
+      'library' => array(
+        'core/jquery',
+      ),
+      'drupalSettings' => [
+        'bar' => ['a', 'b', 'c'],
+      ],
+    );
+    $expected['#attached'] = array(
+      'library' => array(
+        'core/drupal',
+        'core/drupalSettings',
+        'core/jquery',
+      ),
+      'drupalSettings' => [
+        'foo' => ['d'],
+        'bar' => ['a', 'b', 'c'],
+      ],
+    );
+    $this->assertSame($expected['#attached'], BubbleableMetadata::mergeAttachments($a['#attached'], $b['#attached']), 'Attachments merged correctly.');
+
+    // Merging in the opposite direction yields the opposite library order.
+    $expected['#attached'] = array(
+      'library' => array(
+        'core/jquery',
+        'core/drupal',
+        'core/drupalSettings',
+      ),
+      'drupalSettings' => [
+        'bar' => ['a', 'b', 'c'],
+        'foo' => ['d'],
+      ],
+    );
+    $this->assertSame($expected['#attached'], BubbleableMetadata::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.
+    $b['#attached']['library'][] = 'core/drupalSettings';
+    $expected['#attached'] = array(
+      'library' => array(
+        'core/drupal',
+        'core/drupalSettings',
+        'core/jquery',
+        'core/drupalSettings',
+      ),
+      'drupalSettings' => [
+        'foo' => ['d'],
+        'bar' => ['a', 'b', 'c'],
+      ],
+    );
+    $this->assertSame($expected['#attached'], BubbleableMetadata::mergeAttachments($a['#attached'], $b['#attached']), 'Attachments merged correctly; duplicates are retained.');
+
+    // Merging with duplicates (simple case).
+    $b['#attached']['drupalSettings']['foo'] = ['a', 'b', 'c'];
+    $expected['#attached'] = array(
+      'library' => array(
+        'core/drupal',
+        'core/drupalSettings',
+        'core/jquery',
+        'core/drupalSettings',
+      ),
+      'drupalSettings' => [
+        'foo' => ['a', 'b', 'c'],
+        'bar' => ['a', 'b', 'c'],
+      ],
+    );
+    $this->assertSame($expected['#attached'], BubbleableMetadata::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.
+    $expected['#attached'] = array(
+      'library' => array(
+        'core/jquery',
+        'core/drupalSettings',
+        'core/drupal',
+        'core/drupalSettings',
+      ),
+      'drupalSettings' => [
+        'bar' => ['a', 'b', 'c'],
+        'foo' => ['d', 'b', 'c'],
+      ],
+    );
+    $this->assertSame($expected['#attached'], BubbleableMetadata::mergeAttachments($b['#attached'], $a['#attached']));
+
+    // Merging with duplicates: complex case.
+    // Only the second of these two entries should appear in drupalSettings.
+    $build = array();
+    $build['a']['#attached']['drupalSettings']['commonTest'] = 'firstValue';
+    $build['b']['#attached']['drupalSettings']['commonTest'] = 'secondValue';
+    // Only the second of these entries should appear in drupalSettings.
+    $build['a']['#attached']['drupalSettings']['commonTestJsArrayLiteral'] = ['firstValue'];
+    $build['b']['#attached']['drupalSettings']['commonTestJsArrayLiteral'] = ['secondValue'];
+    // Only the second of these two entries should appear in drupalSettings.
+    $build['a']['#attached']['drupalSettings']['commonTestJsObjectLiteral'] = ['key' => 'firstValue'];
+    $build['b']['#attached']['drupalSettings']['commonTestJsObjectLiteral'] = ['key' => 'secondValue'];
+    // Real world test case: multiple elements in a render array are adding the
+    // same (or nearly the same) JavaScript settings. When merged, they should
+    // contain all settings and not duplicate some settings.
+    $settings_one = array('moduleName' => array('ui' => array('button A', 'button B'), 'magical flag' => 3.14159265359));
+    $build['a']['#attached']['drupalSettings']['commonTestRealWorldIdentical'] = $settings_one;
+    $build['b']['#attached']['drupalSettings']['commonTestRealWorldIdentical'] = $settings_one;
+    $settings_two_a = array('moduleName' => array('ui' => array('button A', 'button B', 'button C'), 'magical flag' => 3.14159265359, 'thingiesOnPage' => array('id1' => array())));
+    $build['a']['#attached']['drupalSettings']['commonTestRealWorldAlmostIdentical'] = $settings_two_a;
+    $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 = BubbleableMetadata::mergeAttachments($build['a']['#attached'], $build['b']['#attached']);
+
+    // Test whether #attached can be used to override a previous setting.
+    $this->assertSame('secondValue', $merged['drupalSettings']['commonTest']);
+
+    // Test whether #attached can be used to add and override a JavaScript
+    // array literal (an indexed PHP array) values.
+    $this->assertSame('secondValue', $merged['drupalSettings']['commonTestJsArrayLiteral'][0]);
+
+    // Test whether #attached can be used to add and override a JavaScript
+    // object literal (an associate PHP array) values.
+    $this->assertSame('secondValue', $merged['drupalSettings']['commonTestJsObjectLiteral']['key']);
+
+    // Test whether the two real world cases are handled correctly: the first
+    // adds the exact same settings twice and hence tests idempotency, the
+    // second adds *almost* the same settings twice: the second time, some
+    // values are altered, and some key-value pairs are added.
+    $settings_two['moduleName']['thingiesOnPage']['id1'] = array();
+    $this->assertSame($settings_one, $merged['drupalSettings']['commonTestRealWorldIdentical']);
+    $expected_settings_two = $settings_two_a;
+    $expected_settings_two['moduleName']['ui'][0] = 'button D';
+    $expected_settings_two['moduleName']['ui'][1] = 'button E';
+    $expected_settings_two['moduleName']['ui'][2] = 'button C';
+    $expected_settings_two['moduleName']['magical flag'] = 3.14;
+    $expected_settings_two['moduleName']['thingiesOnPage']['id2'] = [];
+    $this->assertSame($expected_settings_two, $merged['drupalSettings']['commonTestRealWorldAlmostIdentical']);
+  }
+
+  /**
+   * Tests feed asset merging.
+   *
+   * @covers ::mergeAttachments
+   *
+   * @dataProvider providerTestMergeAttachmentsFeedMerging
+   */
+  function testMergeAttachmentsFeedMerging($a, $b, $expected) {
+    $this->assertSame($expected, BubbleableMetadata::mergeAttachments($a, $b));
+  }
+
+  /**
+   * Data provider for testMergeAttachmentsFeedMerging
+   *
+   * @return array
+   */
+  public function providerTestMergeAttachmentsFeedMerging() {
+    $feed_a =         [
+      'aggregator/rss',
+      'Feed title',
+    ];
+
+    $feed_b =         [
+      'taxonomy/term/1/feed',
+      'RSS - foo',
+    ];
+
+    $a = [
+      'feed' => [
+        $feed_a,
+      ],
+    ];
+    $b = [
+      'feed' => [
+        $feed_b,
+      ],
+    ];
+
+    $expected_a = [
+      'feed' => [
+        $feed_a,
+        $feed_b,
+      ],
+    ];
+
+    // Merging in the opposite direction yields the opposite library order.
+    $expected_b = [
+      'feed' => [
+        $feed_b,
+        $feed_a,
+      ],
+    ];
+
+    return [
+      [$a, $b, $expected_a],
+      [$b, $a, $expected_b],
+    ];
+  }
+
+  /**
+   * Tests html_head asset merging.
+   *
+   * @covers ::mergeAttachments
+   *
+   * @dataProvider providerTestMergeAttachmentsHtmlHeadMerging
+   */
+  function testMergeAttachmentsHtmlHeadMerging($a, $b, $expected) {
+    $this->assertSame($expected, BubbleableMetadata::mergeAttachments($a, $b));
+  }
+
+  /**
+   * Data provider for testMergeAttachmentsHtmlHeadMerging
+   *
+   * @return array
+   */
+  public function providerTestMergeAttachmentsHtmlHeadMerging() {
+    $meta = [
+      '#tag' => 'meta',
+      '#attributes' => [
+        'charset' => 'utf-8',
+      ],
+      '#weight' => -1000,
+    ];
+
+    $html_tag = [
+      '#type' => 'html_tag',
+      '#tag' => 'meta',
+      '#attributes' => [
+        'name' => 'Generator',
+        'content' => 'Kitten 1.0 (https://www.drupal.org/project/kitten)',
+      ],
+    ];
+
+    $a = [
+      'html_head' => [
+        $meta,
+        'system_meta_content_type',
+      ],
+    ];
+
+    $b = [
+      'html_head' => [
+        $html_tag,
+        'system_meta_generator',
+      ],
+    ];
+
+    $expected_a = [
+      'html_head' => [
+        $meta,
+        'system_meta_content_type',
+        $html_tag,
+        'system_meta_generator',
+      ],
+    ];
+
+    // Merging in the opposite direction yields the opposite library order.
+    $expected_b = [
+      'html_head' => [
+        $html_tag,
+        'system_meta_generator',
+        $meta,
+        'system_meta_content_type',
+      ],
+    ];
+
+    return [
+      [$a, $b, $expected_a],
+      [$b, $a, $expected_b],
+    ];
+  }
+
+  /**
+   * Tests html_head_link asset merging.
+   *
+   * @covers ::mergeAttachments
+   *
+   * @dataProvider providerTestMergeAttachementsHtmlHeadLinkMerging
+   */
+  function testMergeAttachementsHtmlHeadLinkMerging($a, $b, $expected) {
+    $this->assertSame($expected, BubbleableMetadata::mergeAttachments($a, $b));
+  }
+
+  /**
+   * Data provider for testMergeAttachementsHtmlHeadLinkMerging
+   *
+   * @return array
+   */
+  public function providerTestMergeAttachementsHtmlHeadLinkMerging() {
+    $rel =         [
+      'rel' => 'rel',
+      'href' => 'http://rel.example.com',
+    ];
+
+    $shortlink =         [
+      'rel' => 'shortlink',
+      'href' => 'http://shortlink.example.com',
+    ];
+
+    $a = [
+      'html_head_link' => [
+        $rel,
+        TRUE,
+      ],
+    ];
+
+    $b = [
+      'html_head_link' => [
+        $shortlink,
+        FALSE,
+      ],
+    ];
+
+    $expected_a = [
+      'html_head_link' => [
+        $rel,
+        TRUE,
+        $shortlink,
+        FALSE,
+      ],
+    ];
+
+    // Merging in the opposite direction yields the opposite library order.
+    $expected_b = [
+      'html_head_link' => [
+        $shortlink,
+        FALSE,
+        $rel,
+        TRUE,
+      ],
+    ];
+
+    return [
+      [$a, $b, $expected_a],
+      [$b, $a, $expected_b],
+    ];
+  }
+
+  /**
+   * Tests http_header asset merging.
+   *
+   * @covers ::mergeAttachments
+   *
+   * @dataProvider providerTestMergeAttachmentsHttpHeaderMerging
+   */
+  function testMergeAttachmentsHttpHeaderMerging($a, $b, $expected) {
+    $this->assertSame($expected, BubbleableMetadata::mergeAttachments($a, $b));
+  }
+
+  /**
+   * Data provider for testMergeAttachmentsHttpHeaderMerging
+   *
+   * @return array
+   */
+  public function providerTestMergeAttachmentsHttpHeaderMerging() {
+    $content_type = [
+      'Content-Type',
+      'application/rss+xml; charset=utf-8',
+    ];
+
+    $expires = [
+      'Expires',
+      'Sun, 19 Nov 1978 05:00:00 GMT',
+    ];
+
+    $a = [
+      'http_header' => [
+        $content_type,
+      ],
+    ];
+
+    $b = [
+      'http_header' => [
+        $expires,
+      ],
+    ];
+
+    $expected_a = [
+      'http_header' => [
+        $content_type,
+        $expires,
+      ],
+    ];
+
+    // Merging in the opposite direction yields the opposite library order.
+    $expected_b = [
+      'http_header' => [
+        $expires,
+        $content_type,
+      ],
+    ];
+
+    return [
+      [$a, $b, $expected_a],
+      [$b, $a, $expected_b],
+    ];
+  }
+
 }