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], + ]; + } + }