Commit dd3a5976 authored by catch's avatar catch
Browse files

Issue #2368797 by Wim Leers, dawehner, rteijeiro: Optimize ajaxPageState to...

Issue #2368797 by Wim Leers, dawehner, rteijeiro: Optimize ajaxPageState to keep Drupal 8 sites fast on high-latency networks, prevent CSS/JS aggregation from taking down sites and use HTTP GET for AJAX requests
parent a8cccfce
......@@ -77,6 +77,12 @@ drupal.ajax:
version: VERSION
js:
misc/ajax.js: {}
drupalSettings:
# These placeholder values will be set by system_js_settings_alter().
ajaxPageState:
libraries: null
theme: null
theme_token: null
dependencies:
- core/jquery
- core/drupal
......
......@@ -1115,6 +1115,12 @@ services:
library.discovery.parser:
class: Drupal\Core\Asset\LibraryDiscoveryParser
arguments: ['@app.root', '@module_handler']
library.dependency_resolver:
class: Drupal\Core\Asset\LibraryDependencyResolver
arguments: ['@library.discovery']
asset.resolver:
class: Drupal\Core\Asset\AssetResolver
arguments: ['@library.discovery', '@library.dependency_resolver', '@module_handler', '@theme.manager']
info_parser:
class: Drupal\Core\Extension\InfoParser
twig:
......
This diff is collapsed.
......@@ -15,6 +15,7 @@
use Drupal\Component\Utility\Unicode;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Component\Utility\Xss;
use Drupal\Core\Asset\AttachedAssets;
use Drupal\Core\Config\Config;
use Drupal\Core\Config\StorageException;
use Drupal\Core\Extension\Extension;
......@@ -1400,11 +1401,25 @@ function template_preprocess_html(&$variables) {
// Render the attachments into HTML markup to be used directly in the template
// for #type => html: html.html.twig.
$all_attached = ['#attached' => $attached];
$assets = AttachedAssets::createFromRenderArray($all_attached);
// Take Ajax page state into account, to allow for something like Turbolinks
// to be implemented without altering core.
// @see https://github.com/rails/turbolinks/
$ajax_page_state = \Drupal::request()->request->get('ajax_page_state');
$assets->setAlreadyLoadedLibraries(isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : []);
// Optimize CSS/JS if necessary, but only during normal site operation.
$optimize_css = !defined('MAINTENANCE_MODE') && \Drupal::config('system.performance')->get('css.preprocess');
$optimize_js = !defined('MAINTENANCE_MODE') && \Drupal::config('system.performance')->get('js.preprocess');
// Render the asset collections.
$asset_resolver = \Drupal::service('asset.resolver');
$variables['styles'] = \Drupal::service('asset.css.collection_renderer')->render($asset_resolver->getCssAssets($assets, $optimize_css));
list($js_assets_header, $js_assets_footer) = $asset_resolver->getJsAssets($assets, $optimize_js);
$js_collection_renderer = \Drupal::service('asset.js.collection_renderer');
$variables['scripts'] = $js_collection_renderer->render($js_assets_header);
$variables['scripts_bottom'] = $js_collection_renderer->render($js_assets_footer);
// Handle all non-asset attachments.
drupal_process_attached($all_attached);
$variables['styles'] = drupal_get_css();
$variables['scripts'] = drupal_get_js();
$variables['scripts_bottom'] = drupal_get_js('footer');
$variables['head'] = drupal_get_html_head(FALSE);
}
......
......@@ -7,6 +7,7 @@
namespace Drupal\Core\Ajax;
use Drupal\Core\Asset\AttachedAssets;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
......@@ -25,6 +26,32 @@ class AjaxResponse extends JsonResponse {
*/
protected $commands = array();
/**
* The attachments for this Ajax response.
*
* @var array
*/
protected $attachments = [
'library' => [],
'drupalSettings' => [],
];
/**
* Sets attachments for this Ajax response.
*
* When this Ajax response is rendered, it will take care of generating the
* necessary Ajax commands, if any.
*
* @param array $attachments
* An #attached array.
*
* @return $this
*/
public function setAttachments(array $attachments) {
$this->attachments = $attachments;
return $this;
}
/**
* Add an AJAX command to the response.
*
......@@ -90,77 +117,54 @@ public function prepareResponse(Request $request) {
* An array of commands ready to be returned as JSON.
*/
protected function ajaxRender(Request $request) {
// Ajax responses aren't rendered with html.html.twig, so we have to call
// drupal_get_css() and drupal_get_js() here, in order to have new files
// added during this request to be loaded by the page. We only want to send
// back files that the page hasn't already loaded, so we implement simple
// diffing logic using array_diff_key().
$ajax_page_state = $request->request->get('ajax_page_state');
foreach (array('css', 'js') as $type) {
// It is highly suspicious if
// $request->request->get("ajax_page_state[$type]") is empty, since the
// base page ought to have at least one JS file and one CSS file loaded.
// It probably indicates an error, and rather than making the page reload
// all of the files, instead we return no new files.
if (empty($ajax_page_state[$type])) {
$items[$type] = array();
}
else {
$function = '_drupal_add_' . $type;
$items[$type] = $function();
\Drupal::moduleHandler()->alter($type, $items[$type]);
// @todo Inline CSS and JS items are indexed numerically. These can't be
// reliably diffed with array_diff_key(), since the number can change
// due to factors unrelated to the inline content, so for now, we
// strip the inline items from Ajax responses, and can add support for
// them when _drupal_add_css() and _drupal_add_js() are changed to use
// a hash of the inline content as the array key.
foreach ($items[$type] as $key => $item) {
if (is_numeric($key)) {
unset($items[$type][$key]);
}
}
// Ensure that the page doesn't reload what it already has.
$items[$type] = array_diff_key($items[$type], $ajax_page_state[$type]);
}
}
// Aggregate CSS/JS if necessary, but only during normal site operation.
$config = \Drupal::config('system.performance');
$optimize_css = !defined('MAINTENANCE_MODE') && $config->get('css.preprocess');
$optimize_js = !defined('MAINTENANCE_MODE') && $config->get('js.preprocess');
// Resolve the attached libraries into asset collections.
$assets = new AttachedAssets();
$assets->setLibraries(isset($this->attachments['library']) ? $this->attachments['library'] : [])
->setAlreadyLoadedLibraries(isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : [])
->setSettings(isset($this->attachments['drupalSettings']) ? $this->attachments['drupalSettings'] : []);
$asset_resolver = \Drupal::service('asset.resolver');
$css_assets = $asset_resolver->getCssAssets($assets, $optimize_css);
list($js_assets_header, $js_assets_footer) = $asset_resolver->getJsAssets($assets, $optimize_js);
// Render the HTML to load these files, and add AJAX commands to insert this
// HTML in the page. We pass TRUE as the $skip_alter argument to prevent the
// data from being altered again, as we already altered it above. Settings
// are handled separately, afterwards.
if (isset($items['js']['drupalSettings'])) {
unset($items['js']['drupalSettings']);
}
$styles = drupal_get_css($items['css'], TRUE);
$scripts_footer = drupal_get_js('footer', $items['js'], TRUE, TRUE);
$scripts_header = drupal_get_js('header', $items['js'], TRUE, TRUE);
// HTML in the page. Settings are handled separately, afterwards.
$settings = (isset($js_assets_header['drupalSettings'])) ? $js_assets_header['drupalSettings']['data'] : [];
unset($js_assets_header['drupalSettings']);
// Prepend commands to add the resources, preserving their relative order.
// Prepend commands to add the assets, preserving their relative order.
$resource_commands = array();
if (!empty($styles)) {
$resource_commands[] = new AddCssCommand($styles);
$renderer = \Drupal::service('renderer');
if (!empty($css_assets)) {
$css_render_array = \Drupal::service('asset.css.collection_renderer')->render($css_assets);
$resource_commands[] = new AddCssCommand($renderer->render($css_render_array));
}
if (!empty($scripts_header)) {
$resource_commands[] = new PrependCommand('head', $scripts_header);
if (!empty($js_assets_header)) {
$js_header_render_array = \Drupal::service('asset.js.collection_renderer')->render($js_assets_header);
$resource_commands[] = new PrependCommand('head', $renderer->render($js_header_render_array));
}
if (!empty($scripts_footer)) {
$resource_commands[] = new AppendCommand('body', $scripts_footer);
if (!empty($js_assets_footer)) {
$js_footer_render_array = \Drupal::service('asset.js.collection_renderer')->render($js_assets_footer);
$resource_commands[] = new AppendCommand('body', $renderer->render($js_footer_render_array));
}
foreach (array_reverse($resource_commands) as $resource_command) {
$this->addCommand($resource_command, TRUE);
}
// Prepend a command to merge changes and additions to drupalSettings.
$scripts = _drupal_add_js();
if (!empty($scripts['drupalSettings'])) {
$settings = $scripts['drupalSettings']['data'];
if (!empty($settings)) {
// During Ajax requests basic path-specific settings are excluded from
// new drupalSettings values. The original page where this request comes
// from already has the right values. An Ajax request would update them
// with values for the Ajax request and incorrectly override the page's
// values.
// @see _drupal_add_js()
// @see system_js_settings_alter()
unset($settings['path']);
$this->addCommand(new SettingsCommand($settings, TRUE), TRUE);
}
......
......@@ -125,9 +125,6 @@ public function setDialogTitle($title) {
* Implements \Drupal\Core\Ajax\CommandInterface:render().
*/
public function render() {
// Add the library for handling the dialog in the response.
$this->drupalAttachLibrary('core/drupal.dialog.ajax');
// For consistency ensure the modal option is set to TRUE or FALSE.
$this->dialogOptions['modal'] = isset($this->dialogOptions['modal']) && $this->dialogOptions['modal'];
return array(
......@@ -139,18 +136,4 @@ public function render() {
);
}
/**
* Wraps drupal_render.
*
* @param string $name
* The name of the library.
*
* @todo Remove once drupal_render is converted to autoloadable code.
* @see https://drupal.org/node/2171071
*/
protected function drupalAttachLibrary($name) {
$attached['#attached']['library'][] = $name;
drupal_process_attached($attached);
}
}
<?php
/**
* @file
* Contains \Drupal\Core\Asset\AssetResolver.
*/
namespace Drupal\Core\Asset;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Theme\ThemeManagerInterface;
/**
* The default asset resolver.
*/
class AssetResolver implements AssetResolverInterface {
/**
* The library discovery service.
*
* @var \Drupal\Core\Asset\LibraryDiscoveryInterface
*/
protected $libraryDiscovery;
/**
* The library dependency resolver.
*
* @var \Drupal\Core\Asset\LibraryDependencyResolverInterface
*/
protected $libraryDependencyResolver;
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* The theme manager.
*
* @var \Drupal\Core\Theme\ThemeManagerInterface
*/
protected $themeManager;
/**
* Constructs a new AssetResolver instance.
*
* @param \Drupal\Core\Asset\LibraryDiscoveryInterface $library_discovery
* The library discovery service.
* @param \Drupal\Core\Asset\LibraryDependencyResolverInterface $library_dependency_resolver
* The library dependency resolver.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
* @param \Drupal\Core\Theme\ThemeManagerInterface $theme_manager
* The theme manager.
*/
public function __construct(LibraryDiscoveryInterface $library_discovery, LibraryDependencyResolverInterface $library_dependency_resolver, ModuleHandlerInterface $module_handler, ThemeManagerInterface $theme_manager) {
$this->libraryDiscovery = $library_discovery;
$this->libraryDependencyResolver = $library_dependency_resolver;
$this->moduleHandler = $module_handler;
$this->themeManager = $theme_manager;
}
/**
* Returns the libraries that need to be loaded.
*
* For example, with core/a depending on core/c and core/b on core/d:
* @code
* $assets = new AttachedAssets();
* $assets->setLibraries(['core/a', 'core/b', 'core/c']);
* $assets->setAlreadyLoadedLibraries(['core/c']);
* $resolver->getLibrariesToLoad($assets) === ['core/a', 'core/b', 'core/d']
* @endcode
*
* @param \Drupal\Core\Asset\AttachedAssetsInterface $assets
* The assets attached to the current response.
*
* @return string[]
* A list of libraries and their dependencies, in the order they should be
* loaded, excluding any libraries that have already been loaded.
*/
protected function getLibrariesToLoad(AttachedAssetsInterface $assets) {
return array_diff(
$this->libraryDependencyResolver->getLibrariesWithDependencies($assets->getLibraries()),
$this->libraryDependencyResolver->getLibrariesWithDependencies($assets->getAlreadyLoadedLibraries())
);
}
/**
* {@inheritdoc}
*/
public function getCssAssets(AttachedAssetsInterface $assets, $optimize) {
$theme_info = $this->themeManager->getActiveTheme();
$css = [];
foreach ($this->getLibrariesToLoad($assets) as $library) {
list($extension, $name) = explode('/', $library, 2);
$definition = $this->libraryDiscovery->getLibraryByName($extension, $name);
if (isset($definition['css'])) {
foreach ($definition['css'] as $options) {
$options += array(
'type' => 'file',
'group' => CSS_AGGREGATE_DEFAULT,
'weight' => 0,
'every_page' => FALSE,
'media' => 'all',
'preprocess' => TRUE,
'browsers' => array(),
);
$options['browsers'] += array(
'IE' => TRUE,
'!IE' => TRUE,
);
// Files with a query string cannot be preprocessed.
if ($options['type'] === 'file' && $options['preprocess'] && strpos($options['data'], '?') !== FALSE) {
$options['preprocess'] = FALSE;
}
// Always add a tiny value to the weight, to conserve the insertion
// order.
$options['weight'] += count($css) / 1000;
// Add the data to the CSS array depending on the type.
switch ($options['type']) {
case 'file':
// Local CSS files are keyed by basename; if a file with the same
// basename is added more than once, it gets overridden.
// By default, take over the filename as basename.
if (!isset($options['basename'])) {
$options['basename'] = drupal_basename($options['data']);
}
$css[$options['basename']] = $options;
break;
default:
// External files are keyed by their full URI, so the same CSS
// file is not added twice.
$css[$options['data']] = $options;
}
}
}
}
// Allow modules and themes to alter the CSS assets.
$this->moduleHandler->alter('css', $css, $assets);
$this->themeManager->alter('css', $css, $assets);
// Sort CSS items, so that they appear in the correct order.
uasort($css, 'static::sort');
// Allow themes to remove CSS files by basename.
if ($stylesheet_remove = $theme_info->getStyleSheetsRemove()) {
foreach ($css as $key => $options) {
if (isset($options['basename']) && isset($stylesheet_remove[$options['basename']])) {
unset($css[$key]);
}
}
}
// Allow themes to conditionally override CSS files by basename.
if ($stylesheet_override = $theme_info->getStyleSheetsOverride()) {
foreach ($css as $key => $options) {
if (isset($options['basename']) && isset($stylesheet_override[$options['basename']])) {
$css[$key]['data'] = $stylesheet_override[$options['basename']];
}
}
}
if ($optimize) {
$css = \Drupal::service('asset.css.collection_optimizer')->optimize($css);
}
return $css;
}
/**
* Returns the JavaScript settings assets for this response's libraries.
*
* Gathers all drupalSettings from all libraries in the attached assets
* collection and merges them, then it merges individual attached settings,
* and finally invokes hook_js_settings_alter() to allow alterations of
* JavaScript settings by modules and themes.
*
* @param \Drupal\Core\Asset\AttachedAssetsInterface $assets
* The assets attached to the current response.
* @return array
* A (possibly optimized) collection of JavaScript assets.
*/
protected function getJsSettingsAssets(AttachedAssetsInterface $assets) {
$settings = [];
foreach ($this->getLibrariesToLoad($assets) as $library) {
list($extension, $name) = explode('/', $library, 2);
$definition = $this->libraryDiscovery->getLibraryByName($extension, $name);
if (isset($definition['drupalSettings'])) {
$settings = NestedArray::mergeDeepArray([$settings, $definition['drupalSettings']], TRUE);
}
}
// Attached settings win over settings in libraries.
$settings = NestedArray::mergeDeepArray([$settings, $assets->getSettings()], TRUE);
// Allow modules and themes to alter the JavaScript settings.
$this->moduleHandler->alter('js_settings', $settings, $assets);
$this->themeManager->alter('js_settings', $settings, $assets);
return $settings;
}
/**
* {@inheritdoc}
*/
public function getJsAssets(AttachedAssetsInterface $assets, $optimize) {
$javascript = [];
foreach ($this->getLibrariesToLoad($assets) as $library) {
list($extension, $name) = explode('/', $library, 2);
$definition = $this->libraryDiscovery->getLibraryByName($extension, $name);
if (isset($definition['js'])) {
foreach ($definition['js'] as $options) {
$options += array(
'type' => 'file',
'group' => JS_DEFAULT,
'every_page' => FALSE,
'weight' => 0,
'scope' => 'header',
'cache' => TRUE,
'preprocess' => TRUE,
'attributes' => array(),
'version' => NULL,
'browsers' => array(),
);
// Preprocess can only be set if caching is enabled and no attributes
// are set.
$options['preprocess'] = $options['cache'] && empty($options['attributes']) ? $options['preprocess'] : FALSE;
// Always add a tiny value to the weight, to conserve the insertion
// order.
$options['weight'] += count($javascript) / 1000;
// Local and external files must keep their name as the associative
// key so the same JavaScript file is not added twice.
$javascript[$options['data']] = $options;
}
}
}
// Allow modules and themes to alter the JavaScript assets.
$this->moduleHandler->alter('js', $javascript, $assets);
$this->themeManager->alter('js', $javascript, $assets);
// Sort JavaScript assets, so that they appear in the correct order.
uasort($javascript, 'static::sort');
// Prepare the return value: filter JavaScript assets per scope.
$js_assets_header = [];
$js_assets_footer = [];
foreach ($javascript as $key => $item) {
if ($item['scope'] == 'header') {
$js_assets_header[$key] = $item;
}
elseif ($item['scope'] == 'footer') {
$js_assets_footer[$key] = $item;
}
}
// @todo Refactor this when the default scope is changed to 'footer' in
// https://www.drupal.org/node/784626
// If the core/drupalSettings library is being loaded or is already loaded,
// get the JavaScript settings assets, and convert them into a single
// "regular" JavaScript asset.
$libraries_to_load = $this->getLibrariesToLoad($assets);
$settings_needed = in_array('core/drupalSettings', $libraries_to_load) || in_array('core/drupalSettings', $this->libraryDependencyResolver->getLibrariesWithDependencies($assets->getAlreadyLoadedLibraries()));
$settings_have_changed = count($libraries_to_load) > 0 || count($assets->getSettings()) > 0;
if ($settings_needed && $settings_have_changed) {
$settings = $this->getJsSettingsAssets($assets);
if (!empty($settings)) {
// Prepend to the list of JavaScript assets, to render it first.
$settings_as_inline_javascript = [
'type' => 'setting',
'group' => JS_SETTING,
'every_page' => TRUE,
'weight' => 0,
'browsers' => array(),
'data' => $settings,
];
$js_assets_header = ['drupalSettings' => $settings_as_inline_javascript] + $js_assets_header;
}
}
return [
$js_assets_header,
$js_assets_footer,
];
}
/**
* Sorts CSS and JavaScript resources.
*
* This sort order helps optimize front-end performance while providing
* modules and themes with the necessary control for ordering the CSS and
* JavaScript appearing on a page.
*
* @param $a
* First item for comparison. The compared items should be associative
* arrays of member items.
* @param $b
* Second item for comparison.
*
* @return int
*/
public static function sort($a, $b) {
// First order by group, so that all items in the CSS_AGGREGATE_DEFAULT
// group appear before items in the CSS_AGGREGATE_THEME group. Modules may
// create additional groups by defining their own constants.
if ($a['group'] < $b['group']) {
return -1;
}
elseif ($a['group'] > $b['group']) {
return 1;
}
// Within a group, order all infrequently needed, page-specific files after
// common files needed throughout the website. Separating this way allows
// for the aggregate file generated for all of the common files to be reused
// across a site visit without being cut by a page using a less common file.
elseif ($a['every_page'] && !$b['every_page']) {
return -1;
}
elseif (!$a['every_page'] && $b['every_page']) {
return 1;
}
// Finally, order by weight.
elseif ($a['weight'] < $b['weight']) {
return -1;
}
elseif ($a['weight'] > $b['weight']) {
return 1;
}
else {
return 0;
}
}
}
<?php
/**
* @file
* Contains \Drupal\Core\Asset\AssetResolverInterface.
*/
namespace Drupal\Core\Asset;
/**
* Resolves asset libraries into concrete CSS and JavaScript assets.