Commit dc53be5b authored by alexpott's avatar alexpott

Issue #2483433 by damiankloip, Wim Leers, catch, dawehner: Optimize...

Issue #2483433 by damiankloip, Wim Leers, catch, dawehner: Optimize CacheableMetadata::merge() + BubbleableMetadata::merge()
parent 068edfc1
......@@ -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);
}
/**
......
......@@ -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);
}
......
......@@ -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;
}
......
......@@ -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;
}
}
......@@ -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);
}
<?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(