Commit dd3a5976 authored by catch's avatar catch

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);
}
}
This diff is collapsed.
<?php
/**
* @file
* Contains \Drupal\Core\Asset\AssetResolverInterface.
*/
namespace Drupal\Core\Asset;
/**
* Resolves asset libraries into concrete CSS and JavaScript assets.
*
* Given an attached assets collection (to be loaded for the current response),
* the asset resolver can resolve those asset libraries into a list of concrete
* CSS and JavaScript assets.
*
* In other words: this allows developers to translate Drupal's asset
* abstraction (asset libraries) into concrete assets.
*
* @see \Drupal\Core\Asset\AttachedAssetsInterface
* @see \Drupal\Core\Asset\LibraryDependencyResolverInterface
*/
interface AssetResolverInterface {
/**
* Returns the CSS assets for the current response's libraries.
*
* It returns the CSS assets in order, according to the SMACSS categories
* specified in the assets' weights:
* 1. CSS_BASE
* 2. CSS_LAYOUT
* 3. CSS_COMPONENT
* 4. CSS_STATE
* 5. CSS_THEME
* @see https://www.drupal.org/node/1887918#separate-concerns
* This ensures proper cascading of styles so themes can easily override
* module styles through CSS selectors.
*
* Themes may replace module-defined CSS files by adding a stylesheet with the
* same filename. For example, themes/bartik/system-menus.css would replace
* modules/system/system-menus.css. This allows themes to override complete
* CSS files, rather than specific selectors, when necessary.
*
* Also invokes hook_css_alter(), to allow CSS assets to be altered.
*
* @param \Drupal\Core\Asset\AttachedAssetsInterface $assets
* The assets attached to the current response.
* @param bool $optimize
* Whether to apply the CSS asset collection optimizer, to return an
* optimized CSS asset collection rather than an unoptimized one.
*
* @return array
* A (possibly optimized) collection of CSS assets.
*/
public function getCssAssets(AttachedAssetsInterface $assets, $optimize);
/**
* Returns the JavaScript assets for the current response's libraries.
*
* References to JavaScript files are placed in a certain order: first, all
* 'core' files, then all 'module' and finally all 'theme' JavaScript files
* are added to the page. Then, all settings are output, followed by 'inline'
* JavaScript code. If running update.php, all preprocessing is disabled.
*
* Note that hook_js_alter(&$javascript) is called during this function call
* to allow alterations of the JavaScript during its presentation. The correct
* way to add JavaScript during hook_js_alter() is to add another element to
* the $javascript array, deriving from drupal_js_defaults(). See
* locale_js_alter() for an example of this.
*
* @param \Drupal\Core\Asset\AttachedAssetsInterface $assets
* The assets attached to the current response.
* @param bool $optimize
* Whether to apply the JavaScript asset collection optimizer, to return
* optimized JavaScript asset collections rather than an unoptimized ones.
*
* @return array
* A nested array containing 2 values:
* - at index zero: the (possibly optimized) collection of JavaScript assets
* for the top of the page
* - at index one: the (possibly optimized) collection of JavaScript assets
* for the bottom of the page
*/
public function getJsAssets(AttachedAssetsInterface $assets, $optimize);
}
<?php
/**
* @file
* Contains \Drupal\Core\Asset\AttachedAssets.
*/
namespace Drupal\Core\Asset;
/**
* The default attached assets collection.
*/
class AttachedAssets implements AttachedAssetsInterface {
/**
* The (ordered) list of asset libraries attached to the current response.
*
* @var string[]
*/
public $libraries = [];
/**
* The JavaScript settings attached to the current response.
*
* @var array
*/
public $settings = [];
/**
* The set of asset libraries that the client has already loaded.
*
* @var string[]
*/
protected $alreadyLoadedLibraries = [];
/**
* {@inheritdoc}
*/
public static function createFromRenderArray(array $render_array) {
if (!isset($render_array['#attached'])) {
throw new \LogicException('The render array has not yet been rendered, hence not all attachments have been collected yet.');
}
$assets = new static();
if (isset($render_array['#attached']['library'])) {
$assets->setLibraries($render_array['#attached']['library']);
}
if (isset($render_array['#attached']['drupalSettings'])) {
$assets->setSettings($render_array['#attached']['drupalSettings']);
}
return $assets;
}
/**
* {@inheritdoc}
*/
public function setLibraries(array $libraries) {
$this->libraries = array_unique($libraries);
return $this;
}
/**
* {@inheritdoc}
*/
public function getLibraries() {
return $this->libraries;
}
/**
* {@inheritdoc}
*/
public function setSettings(array $settings) {
$this->settings = $settings;
return $this;
}
/**
* {@inheritdoc}
*/
public function getSettings() {
return $this->settings;
}
/**
* {@inheritdoc}
*/
public function getAlreadyLoadedLibraries() {
return $this->alreadyLoadedLibraries;
}
/**
* {@inheritdoc}
*/
public function setAlreadyLoadedLibraries(array $libraries) {
$this->alreadyLoadedLibraries = $libraries;
return $this;
}
}
<?php
/**
* @file
* Contains \Drupal\Core\Asset\AttachedAssetsInterface.
*/
namespace Drupal\Core\Asset;
/**
* The attached assets collection for the current response.
*
* Allows for storage of:
* - an ordered list of asset libraries (to be loaded for the current response)
* - attached JavaScript settings (to be loaded for the current response)
* - a set of asset libraries that the client already has loaded (as indicated
* in the request, to *not* be loaded for the current response)
*
* @see \Drupal\Core\Asset\AssetResolverInterface
*/
interface AttachedAssetsInterface {
/**
* Creates an AttachedAssetsInterface object from a render array.
*
* @param array $render_array
* A render array.
*
* @return \Drupal\Core\Asset\AttachedAssetsInterface
*
* @throws \LogicException
*/
public static function createFromRenderArray(array $render_array);
/**
* Sets the asset libraries attached to the current response.
*
* @param string[] $libraries
* A list of libraries, in the order they should be loaded.
*
* @return $this
*/
public function setLibraries(array $libraries);
/**
* Returns the asset libraries attached to the current response.
*
* @return string[]
*/
public function getLibraries();
/**
* Sets the JavaScript settings that are attached to the current response.
*
* @param array $settings
* The needed JavaScript settings.
*
* @return $this
*/
public function setSettings(array $settings);
/**
* Returns the settings attached to the current response.
*
* @return array
*/
public function getSettings();
/**
* Sets the asset libraries that the current request marked as already loaded.
*
* @param string[] $libraries
* The set of already loaded libraries.
*
* @return $this
*/
public function setAlreadyLoadedLibraries(array $libraries);
/**
* Returns the set of already loaded asset libraries.
*
* @return string[]
*/
public function getAlreadyLoadedLibraries();
}
......@@ -58,7 +58,7 @@ public function group(array $css_assets) {
// Group file items if their 'preprocess' flag is TRUE.
// Help ensure maximum reuse of aggregate files by only grouping
// together items that share the same 'group' value and 'every_page'
// flag. See _drupal_add_css() for details about that.
// flag.
$group_keys = $item['preprocess'] ? array($item['type'], $item['group'], $item['every_page'], $item['media'], $item['browsers']) : FALSE;
break;
......
......@@ -11,6 +11,49 @@
/**
* Renders CSS assets.
*
* For production websites, LINK tags are preferable to STYLE tags with @import
* statements, because:
* - They are the standard tag intended for linking to a resource.
* - On Firefox 2 and perhaps other browsers, CSS files included with @import
* statements don't get saved when saving the complete web page for offline
* use: http://drupal.org/node/145218.
* - On IE, if only LINK tags and no @import statements are used, all the CSS
* files are downloaded in parallel, resulting in faster page load, but if
* @import statements are used and span across multiple STYLE tags, all the
* ones from one STYLE tag must be downloaded before downloading begins for
* the next STYLE tag. Furthermore, IE7 does not support media declaration on
* the @import statement, so multiple STYLE tags must be used when different
* files are for different media types. Non-IE browsers always download in
* parallel, so this is an IE-specific performance quirk:
* http://www.stevesouders.com/blog/2009/04/09/dont-use-import/.
*
* However, IE has an annoying limit of 31 total CSS inclusion tags
* (http://drupal.org/node/228818) and LINK tags are limited to one file per
* tag, whereas STYLE tags can contain multiple @import statements allowing
* multiple files to be loaded per tag. When CSS aggregation is disabled, a
* Drupal site can easily have more than 31 CSS files that need to be loaded, so
* using LINK tags exclusively would result in a site that would display
* incorrectly in IE. Depending on different needs, different strategies can be
* employed to decide when to use LINK tags and when to use STYLE tags.
*
* The strategy employed by this class is to use LINK tags for all aggregate
* files and for all files that cannot be aggregated (e.g., if 'preprocess' is
* set to FALSE or the type is 'external'), and to use STYLE tags for groups
* of files that could be aggregated together but aren't (e.g., if the site-wide
* aggregation setting is disabled). This results in all LINK tags when
* aggregation is enabled, a guarantee that as many or only slightly more tags
* are used with aggregation disabled than enabled (so that if the limit were to
* be crossed with aggregation enabled, the site developer would also notice the
* problem while aggregation is disabled), and an easy way for a developer to
* view HTML source while aggregation is disabled and know what files will be
* aggregated together when aggregation becomes enabled.
*
* This class evaluates the aggregation enabled/disabled condition on a group
* by group basis by testing whether an aggregate file has been made for the
* group rather than by testing the site-wide aggregation setting. This allows
* this class to work correctly even if modules have implemented custom
* logic for grouping and aggregating files.
*/
class CssCollectionRenderer implements AssetCollectionRendererInterface {
......
......@@ -45,7 +45,7 @@ public function group(array $js_assets) {
// Group file items if their 'preprocess' flag is TRUE.
// Help ensure maximum reuse of aggregate files by only grouping
// together items that share the same 'group' value and 'every_page'
// flag. See _drupal_add_js() for details about that.
// flag.
$group_keys = $item['preprocess'] ? array($item['type'], $item['group'], $item['every_page'], $item['browsers']) : FALSE;
break;
......
......@@ -22,7 +22,7 @@ class JsCollectionRenderer implements AssetCollectionRendererInterface {
protected $state;
/**
* Constructs a CssCollectionRenderer.
* Constructs a JsCollectionRenderer.
*
* @param \Drupal\Core\State\StateInterface
* The state key/value store.
......@@ -33,6 +33,12 @@ public function __construct(StateInterface $state) {
/**
* {@inheritdoc}
*
* This class evaluates the aggregation enabled/disabled condition on a group
* by group basis by testing whether an aggregate file has been made for the
* group rather than by testing the site-wide aggregation setting. This allows
* this class to work correctly even if modules have implemented custom
* logic for grouping and aggregating files.
*/
public function render(array $js_assets) {
$elements = array();
......@@ -40,9 +46,8 @@ public function render(array $js_assets) {
// A dummy query-string is added to filenames, to gain control over
// browser-caching. The string changes on every update or full cache
// flush, forcing browsers to load a new copy of the files, as the
// URL changed. Files that should not be cached (see _drupal_add_js())
// get REQUEST_TIME as query-string instead, to enforce reload on every
// page request.
// URL changed. Files that should not be cached get REQUEST_TIME as
// query-string instead, to enforce reload on every page request.
$default_query_string = $this->state->get('system.css_js_query_string') ?: '0';
// For inline JavaScript to validate as XHTML, all JavaScript containing
......
<?php
/**
* @file
* Contains \Drupal\Core\Asset\LibraryDependencyResolver.
*/
namespace Drupal\Core\Asset;
/**
* Resolves the dependencies of asset (CSS/JavaScript) libraries.
*/
class LibraryDependencyResolver implements LibraryDependencyResolverInterface {
/**
* The library discovery service.
*
* @var \Drupal\Core\Asset\LibraryDiscoveryInterface
*/
protected $libraryDiscovery;
/**
* Constructs a new LibraryDependencyResolver instance.
*
* @param \Drupal\Core\Asset\LibraryDiscoveryInterface $library_discovery
* The library discovery service.
*/
public function __construct(LibraryDiscoveryInterface $library_discovery) {
$this->libraryDiscovery = $library_discovery;
}
/**
* {@inheritdoc}
*/
public function getLibrariesWithDependencies(array $libraries) {
return $this->doGetDependencies($libraries);
}
/**
* Gets the given libraries with its dependencies.
*
* Helper method for ::getLibrariesWithDependencies().
*
* @param string[] $libraries_with_unresolved_dependencies
* A list of libraries, with unresolved dependencies, in the order they
* should be loaded.
* @param string[] $final_libraries
* The final list of libraries (the return value) that is being built
* recursively.
*
* @return string[]
* A list of libraries, in the order they should be loaded, including their
* dependencies.
*/
protected function doGetDependencies(array $libraries_with_unresolved_dependencies, array $final_libraries = []) {
foreach ($libraries_with_unresolved_dependencies as $library) {
if (!in_array($library, $final_libraries)) {
list($extension, $name) = explode('/', $library, 2);
$definition = $this->libraryDiscovery->getLibraryByName($extension, $name);
if (!empty($definition['dependencies'])) {
$final_libraries = $this->doGetDependencies($definition['dependencies'], $final_libraries);
}
$final_libraries[] = $library;
}
}
return $final_libraries;
}
/**
* {@inheritdoc}
*/
public function getMinimalRepresentativeSubset(array $libraries) {
$minimal = [];
// Determine each library's dependencies.
$with_deps = [];
foreach ($libraries as $library) {
$with_deps[$library] = $this->getLibrariesWithDependencies([$library]);
}
foreach ($libraries as $library)