Commit 99b367da authored by Dries's avatar Dries

Issue #2099131 by jessebeach, Wim Leers, catch, Berdir, benjifisher,...

Issue #2099131 by jessebeach, Wim Leers, catch, Berdir, benjifisher, martin107, andypost: Use #pre_render pattern for entity render caching.
parent b0821d88
......@@ -3158,7 +3158,7 @@ function drupal_pre_render_link($element) {
* A typical example comes from node links, which are stored in a renderable
* array similar to this:
* @code
* $node->content['links'] = array(
* $build['links'] = array(
* '#theme' => 'links__node',
* '#pre_render' => array('drupal_pre_render_links'),
* 'comment' => array(
......@@ -3193,7 +3193,7 @@ function drupal_pre_render_link($element) {
* {{ content.links.comment }}
* @endcode
*
* (where $node->content has been transformed into $content before handing
* (where a node's content has been transformed into $content before handing
* control to the node.html.twig template).
*
* The pre_render function defined here allows the above flexibility, but also
......@@ -3529,16 +3529,6 @@ function drupal_render(&$elements, $is_recursive_call = FALSE) {
return '';
}
// Collect all #post_render_cache callbacks associated with this element when:
// - about to store this element in the render cache, or when;
// - about to apply #post_render_cache callbacks.
if (isset($elements['#cache']) || !$is_recursive_call) {
$post_render_cache = drupal_render_collect_post_render_cache($elements);
if ($post_render_cache) {
$elements['#post_render_cache'] = $post_render_cache;
}
}
// Add any JavaScript state information associated with the element.
if (!empty($elements['#states'])) {
drupal_process_states($elements);
......@@ -3642,6 +3632,15 @@ function drupal_render(&$elements, $is_recursive_call = FALSE) {
$suffix = isset($elements['#suffix']) ? $elements['#suffix'] : '';
$elements['#markup'] = $prefix . $elements['#children'] . $suffix;
// Collect all #post_render_cache callbacks associated with this element when:
// - about to store this element in the render cache, or when;
// - about to apply #post_render_cache callbacks.
if (!$is_recursive_call || isset($elements['#cache'])) {
$post_render_cache = drupal_render_collect_post_render_cache($elements);
if ($post_render_cache) {
$elements['#post_render_cache'] = $post_render_cache;
}
}
// Collect all cache tags. This allows the caller of drupal_render() to also
// access the complete list of cache tags.
if (!$is_recursive_call || isset($elements['#cache'])) {
......@@ -3720,6 +3719,10 @@ function render(&$element) {
return NULL;
}
if (is_array($element)) {
// Early return if this element was pre-rendered (no need to re-render).
if (isset($element['#printed']) && $element['#printed'] == TRUE && isset($element['#markup']) && strlen($element['#markup']) > 0) {
return $element['#markup'];
}
show($element);
return drupal_render($element, TRUE);
}
......@@ -3880,6 +3883,9 @@ function drupal_render_cache_set(&$markup, array $elements) {
* @param string $token
* A unique token to uniquely identify the placeholder.
*
* @return string
* The generated placeholder HTML.
*
* @see drupal_render_cache_get()
*/
function drupal_render_cache_generate_placeholder($callback, array $context, $token) {
......@@ -3892,37 +3898,10 @@ function drupal_render_cache_generate_placeholder($callback, array $context, $to
}
/**
* Pre-render callback: Renders a render cache placeholder into #markup.
*
* @param $elements
* A structured array whose keys form the arguments to l():
* - #callback: The #post_render_cache callback that will replace the
* placeholder with its eventual markup.
* - #context: An array providing context for the #post_render_cache callback.
*
* @return
* The passed-in element containing a render cache placeholder in '#markup'
* and a callback with context, keyed by a generated unique token in
* '#post_render_cache'.
*
* @see drupal_render_cache_generate_placeholder()
* Generates a unique token for use in a #post_render_cache placeholder.
*/
function drupal_pre_render_render_cache_placeholder($element) {
$callback = $element['#callback'];
if (!is_callable($callback)) {
throw new Exception(t('#callback must be a callable function.'));
}
$context = $element['#context'];
if (!is_array($context)) {
throw new Exception(t('#context must be an array.'));
}
$token = \Drupal\Component\Utility\Crypt::randomBytesBase64(55);
// Generate placeholder markup and store #post_render_cache callback.
$element['#markup'] = drupal_render_cache_generate_placeholder($callback, $context, $token);
$element['#post_render_cache'][$callback][$token] = $context;
return $element;
function drupal_render_cache_generate_token() {
return \Drupal\Component\Utility\Crypt::randomBytesBase64(55);
}
/**
......@@ -3948,42 +3927,11 @@ function _drupal_render_process_post_render_cache(array &$elements) {
// and if keyed by a number, no token is passed, otherwise, the token string
// is passed to the callback as well. This token is used to uniquely
// identify the placeholder in the markup.
$modified_elements = $elements;
foreach ($elements['#post_render_cache'] as $callback => $options) {
foreach ($elements['#post_render_cache'][$callback] as $token => $context) {
// The advanced option, when setting #post_render_cache directly.
if (is_numeric($token)) {
$modified_elements = call_user_func_array($callback, array($modified_elements, $context));
}
// The simple option, when using the standard placeholders, and hence
// also when using #type => render_cache_placeholder.
else {
// Call #post_render_cache callback to generate the element that will
// fill in the placeholder.
$generated_element = call_user_func_array($callback, array($context));
// Update #attached based on the generated element.
if (isset($generated_element['#attached'])) {
if (!isset($modified_elements['#attached'])) {
$modified_elements['#attached'] = array();
}
$modified_elements['#attached'] = drupal_merge_attached($modified_elements['#attached'], drupal_render_collect_attached($generated_element, TRUE));
}
// Replace the placeholder with the rendered markup of the generated
// element.
$placeholder = drupal_render_cache_generate_placeholder($callback, $context, $token);
$modified_elements['#markup'] = str_replace($placeholder, drupal_render($generated_element), $modified_elements['#markup']);
}
$elements = call_user_func_array($callback, array($elements, $context));
}
}
// Only retain changes to the #markup and #attached properties, as would be
// the case when the render cache was actually being used.
$elements['#markup'] = $modified_elements['#markup'];
if (isset($modified_elements['#attached'])) {
$elements['#attached'] = $modified_elements['#attached'];
}
// Make sure that any attachments added in #post_render_cache callbacks are
// also executed.
if (isset($elements['#attached'])) {
......@@ -4040,13 +3988,6 @@ function drupal_render_collect_post_render_cache(array &$elements, array $callba
}
}
// If this is a render cache placeholder that hasn't been rendered yet, then
// render it now, because we must be able to collect its #post_render_cache
// callback.
if (!isset($elements['#post_render_cache']) && isset($elements['#type']) && $elements['#type'] === 'render_cache_placeholder') {
$elements = drupal_pre_render_render_cache_placeholder($elements);
}
// Collect all #post_render_cache callbacks for this element.
if (isset($elements['#post_render_cache'])) {
$callbacks = NestedArray::mergeDeep($callbacks, $elements['#post_render_cache']);
......
......@@ -34,7 +34,7 @@ public function build(ContentEntityInterface $entity);
*
* This only includes the components handled by the Display object, but
* excludes 'extra fields', that are typically rendered through specific,
* ad-hoc code in EntityViewBuilderInterface::buildContent() or in
* ad-hoc code in EntityViewBuilderInterface::buildComponents() or in
* hook_entity_view() implementations.
*
* hook_entity_display_build_alter() is invoked on each entity, allowing 3rd
......
......@@ -10,11 +10,13 @@
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Entity\Display\EntityViewDisplayInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Field\FieldItemInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Language\Language;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\TypedData\TranslatableInterface;
use Drupal\Core\Render\Element;
use Drupal\entity\Entity\EntityViewDisplay;
use Symfony\Component\DependencyInjection\ContainerInterface;
......@@ -89,13 +91,9 @@ public static function createInstance(ContainerInterface $container, EntityTypeI
/**
* {@inheritdoc}
*/
public function buildContent(array $entities, array $displays, $view_mode, $langcode = NULL) {
public function buildComponents(array &$build, array $entities, array $displays, $view_mode, $langcode = NULL) {
$entities_by_bundle = array();
foreach ($entities as $id => $entity) {
// Remove previously built content, if exists.
$entity->content = array(
'#view_mode' => $view_mode,
);
// Initialize the field item attributes for the fields being displayed.
// The entity can include fields that are not displayed, and the display
// can include components that are not fields, so we want to act on the
......@@ -114,13 +112,13 @@ public function buildContent(array $entities, array $displays, $view_mode, $lang
}
// Invoke hook_entity_prepare_view().
\Drupal::moduleHandler()->invokeAll('entity_prepare_view', array($this->entityTypeId, $entities, $displays, $view_mode));
$this->moduleHandler()->invokeAll('entity_prepare_view', array($this->entityTypeId, $entities, $displays, $view_mode));
// Let the displays build their render arrays.
foreach ($entities_by_bundle as $bundle => $bundle_entities) {
$build = $displays[$bundle]->buildMultiple($bundle_entities);
$display_build = $displays[$bundle]->buildMultiple($bundle_entities);
foreach ($bundle_entities as $id => $entity) {
$entity->content += $build[$id];
$build[$id] += $display_build[$id];
}
}
}
......@@ -133,26 +131,31 @@ public function buildContent(array $entities, array $displays, $view_mode, $lang
* @param string $view_mode
* The view mode that should be used.
* @param string $langcode
* (optional) For which language the entity should be prepared, defaults to
* For which language the entity should be prepared, defaults to
* the current content language.
*
* @return array
*/
protected function getBuildDefaults(EntityInterface $entity, $view_mode, $langcode) {
$return = array(
// Allow modules to change the view mode.
$context = array('langcode' => $langcode);
$this->moduleHandler()->alter('entity_view_mode', $view_mode, $entity, $context);
$build = array(
'#theme' => $this->entityTypeId,
"#{$this->entityTypeId}" => $entity,
'#view_mode' => $view_mode,
'#langcode' => $langcode,
// Collect cache defaults for this entity.
'#cache' => array(
'tags' => NestedArray::mergeDeep($this->getCacheTag(), $entity->getCacheTag()),
'tags' => NestedArray::mergeDeep($this->getCacheTag(), $entity->getCacheTag()),
),
);
// Cache the rendered output if permitted by the view mode and global entity
// type configuration.
if ($this->isViewModeCacheable($view_mode) && !$entity->isNew() && $entity->isDefaultRevision() && $this->entityType->isRenderCacheable()) {
$return['#cache'] += array(
$build['#cache'] += array(
'keys' => array(
'entity_view',
$this->entityTypeId,
......@@ -165,11 +168,11 @@ protected function getBuildDefaults(EntityInterface $entity, $view_mode, $langco
);
if ($entity instanceof TranslatableInterface && count($entity->getTranslationLanguages()) > 1) {
$return['#cache']['keys'][] = $langcode;
$build['#cache']['keys'][] = $langcode;
}
}
return $return;
return $build;
}
/**
......@@ -194,8 +197,16 @@ protected function alterBuild(array &$build, EntityInterface $entity, EntityView
* {@inheritdoc}
*/
public function view(EntityInterface $entity, $view_mode = 'full', $langcode = NULL) {
$buildList = $this->viewMultiple(array($entity), $view_mode, $langcode);
return $buildList[0];
$build_list = $this->viewMultiple(array($entity), $view_mode, $langcode);
// The default ::buildMultiple() #pre_render callback won't run, because we
// extract a child element of the default renderable array. Thus we must
// assign an alternative #pre_render callback that applies the necessary
// transformations and then still calls ::buildMultiple().
$build = $build_list[0];
$build['#pre_render'][] = array($this, 'build');
return $build;
}
/**
......@@ -206,62 +217,122 @@ public function viewMultiple(array $entities = array(), $view_mode = 'full', $la
$langcode = $this->languageManager->getCurrentLanguage(Language::TYPE_CONTENT)->id;
}
// Build the view modes and display objects.
$view_modes = array();
$context = array('langcode' => $langcode);
$build_list = array(
'#sorted' => TRUE,
'#pre_render' => array(array($this, 'buildMultiple')),
'#langcode' => $langcode,
);
$weight = 0;
foreach ($entities as $key => $entity) {
$bundle = $entity->bundle();
// Ensure that from now on we are dealing with the proper translation
// object.
$entity = $this->entityManager->getTranslationFromContext($entity, $langcode);
$entities[$key] = $entity;
// Allow modules to change the view mode.
$entity_view_mode = $view_mode;
$this->moduleHandler->alter('entity_view_mode', $entity_view_mode, $entity, $context);
// Store entities for rendering by view_mode.
$view_modes[$entity_view_mode][$entity->id()] = $entity;
}
// Set build defaults.
$build_list[$key] = $this->getBuildDefaults($entity, $view_mode, $langcode);
$entityType = $this->entityTypeId;
$this->moduleHandler()->alter(array($entityType . '_build_defaults', 'entity_build_defaults'), $build_list[$key], $entity, $view_mode, $langcode);
foreach ($view_modes as $mode => $view_mode_entities) {
$displays[$mode] = EntityViewDisplay::collectRenderDisplays($view_mode_entities, $mode);
$this->buildContent($view_mode_entities, $displays[$mode], $mode, $langcode);
$build_list[$key]['#weight'] = $weight++;
}
return $build_list;
}
/**
* Builds an entity's view; augments entity defaults.
*
* This function is assigned as a #pre_render callback in ::view().
*
* It transforms the renderable array for a single entity to the same
* structure as if we were rendering multiple entities, and then calls the
* default ::buildMultiple() #pre_render callback.
*
* @param array $build
* A renderable array containing build information and context for an entity
* view.
*
* @return array
* The updated renderable array.
*
* @see drupal_render()
*/
public function build(array $build) {
$build_list = array(
'#langcode' => $build['#langcode'],
);
$build_list[] = $build;
$build_list = $this->buildMultiple($build_list);
return $build_list[0];
}
/**
* Builds multiple entities' views; augments entity defaults.
*
* This function is assigned as a #pre_render callback in ::viewMultiple().
*
* By delaying the building of an entity until the #pre_render processing in
* drupal_render(), the processing cost of assembling an entity's renderable
* array is saved on cache-hit requests.
*
* @param array $build_list
* A renderable array containing build information and context for an
* entity view.
*
* @return array
* The updated renderable array.
*
* @see drupal_render()
*/
public function buildMultiple(array $build_list) {
// Build the view modes and display objects.
$view_modes = array();
$langcode = $build_list['#langcode'];
$entity_type_key = "#{$this->entityTypeId}";
$view_hook = "{$this->entityTypeId}_view";
$build = array('#sorted' => TRUE);
$weight = 0;
foreach ($entities as $key => $entity) {
$entity_view_mode = isset($entity->content['#view_mode']) ? $entity->content['#view_mode'] : $view_mode;
$display = $displays[$entity_view_mode][$entity->bundle()];
\Drupal::moduleHandler()->invokeAll($view_hook, array($entity, $display, $entity_view_mode, $langcode));
\Drupal::moduleHandler()->invokeAll('entity_view', array($entity, $display, $entity_view_mode, $langcode));
$build[$key] = $entity->content;
// We don't need duplicate rendering info in $entity->content.
unset($entity->content);
$build[$key] += $this->getBuildDefaults($entity, $entity_view_mode, $langcode);
$this->alterBuild($build[$key], $entity, $display, $entity_view_mode, $langcode);
// Assign the weights configured in the display.
// @todo: Once https://drupal.org/node/1875974 provides the missing API,
// only do it for 'extra fields', since other components have been taken
// care of in EntityViewDisplay::buildMultiple().
foreach ($display->getComponents() as $name => $options) {
if (isset($build[$key][$name])) {
$build[$key][$name]['#weight'] = $options['weight'];
// Find the keys for the ContentEntities in the build; Store entities for
// rendering by view_mode.
$children = Element::children($build_list);
foreach ($children as $key) {
if (isset($build_list[$key][$entity_type_key])) {
$entity = $build_list[$key][$entity_type_key];
if ($entity instanceof ContentEntityInterface) {
$view_modes[$build_list[$key]['#view_mode']][$key] = $entity;
}
}
}
$build[$key]['#weight'] = $weight++;
// Build content for the displays represented by the entities.
foreach ($view_modes as $view_mode => $view_mode_entities) {
$displays = EntityViewDisplay::collectRenderDisplays($view_mode_entities, $view_mode);
$this->buildComponents($build_list, $view_mode_entities, $displays, $view_mode, $langcode);
foreach (array_keys($view_mode_entities) as $key) {
// Allow for alterations while building, before rendering.
$entity = $build_list[$key][$entity_type_key];
$display = $displays[$entity->bundle()];
$this->moduleHandler()->invokeAll($view_hook, array(&$build_list[$key], $entity, $display, $view_mode, $langcode));
$this->moduleHandler()->invokeAll('entity_view', array(&$build_list[$key], $entity, $display, $view_mode, $langcode));
$this->alterBuild($build_list[$key], $entity, $display, $view_mode, $langcode);
// Assign the weights configured in the display.
// @todo: Once https://drupal.org/node/1875974 provides the missing API,
// only do it for 'extra fields', since other components have been
// taken care of in EntityViewDisplay::buildMultiple().
foreach ($display->getComponents() as $name => $options) {
if (isset($build_list[$key][$name])) {
$build_list[$key]['#weight'] = $options['weight'];
}
}
// Allow modules to modify the render array.
$this->moduleHandler->alter(array($view_hook, 'entity_view'), $build[$key], $entity, $display);
// Allow modules to modify the render array.
$this->moduleHandler()->alter(array($view_hook, 'entity_view'), $build_list[$key], $entity, $display);
}
}
return $build;
return $build_list;
}
/**
......
......@@ -16,8 +16,10 @@
interface EntityViewBuilderInterface {
/**
* Build the structured $content property on the entity.
* Builds the component fields and properties of a set of entities.
*
* @param &$build
* The renderable array representing the entity content.
* @param \Drupal\Core\Entity\EntityInterface[] $entities
* The entities whose content is being built.
* @param \Drupal\Core\Entity\Display\EntityViewDisplayInterface[] $displays
......@@ -28,11 +30,8 @@ interface EntityViewBuilderInterface {
* @param string $langcode
* (optional) For which language the entity should be build, defaults to
* the current content language.
*
* @return array
* The content array.
*/
public function buildContent(array $entities, array $displays, $view_mode, $langcode = NULL);
public function buildComponents(array &$build, array $entities, array $displays, $view_mode, $langcode = NULL);
/**
* Returns the render array for the provided entity.
......
......@@ -90,21 +90,8 @@ public function view(FieldItemListInterface $items) {
'#object' => $entity,
'#items' => $items,
'#formatter' => $this->getPluginId(),
'#cache' => array('tags' => array())
);
// Gather cache tags from reference fields.
foreach ($items as $item) {
if (isset($item->format)) {
$info['#cache']['tags']['filter_format'] = $item->format;
}
if (isset($item->entity)) {
$info['#cache']['tags'][$item->entity->getEntityTypeId()][] = $item->entity->id();
$info['#cache']['tags'][$item->entity->getEntityTypeId() . '_view'] = TRUE;
}
}
$addition = array_merge($info, $elements);
}
......
......@@ -27,7 +27,7 @@
* is hook_block_view_BASE_BLOCK_ID_alter(), which can be used to target a
* specific block or set of similar blocks.
*
* @param array $build
* @param array &$build
* A renderable array of data, as returned from the build() implementation of
* the plugin that defined the block:
* - #title: The default localized title of the block.
......
<?php
/**
* @file
* Contains \Drupal\custom_block\Tests\CustomBlockBuildContentTest.
*/
namespace Drupal\custom_block\Tests;
/**
* Test to ensure that a block's content is always rebuilt.
*/
class CustomBlockBuildContentTest extends CustomBlockTestBase {
/**
* Declares test information.
*/
public static function getInfo() {
return array(
'name' => 'Rebuild content',
'description' => 'Test the rebuilding of content for full view modes.',
'group' => 'Custom Block',
);
}
/**
* Ensures that content is rebuilt in calls to custom_block_build_content().
*/
public function testCustomBlockRebuildContent() {
$block = $this->createCustomBlock();
// Set a property in the content array so we can test for its existence later on.
$block->content['test_content_property'] = array(
'#value' => $this->randomString(),
);
$content = entity_view_multiple(array($block), 'full');
// If the property doesn't exist it means the block->content was rebuilt.
$this->assertFalse(isset($content['test_content_property']), 'Custom block content was emptied prior to being built.');
}
}
......@@ -7,6 +7,7 @@
namespace Drupal\custom_block\Tests;
use Drupal\Core\Entity\EntityInterface;
use Drupal\system\Tests\Entity\EntityCacheTagsTestBase;
/**
......@@ -34,11 +35,23 @@ protected function createEntity() {
$custom_block = entity_create('custom_block', array(
'info' => 'Llama',
'type' => 'basic',
'body' => 'The name "llama" was adopted by European settlers from native Peruvians.',
'body' => array(
'value' => 'The name "llama" was adopted by European settlers from native Peruvians.',
'format' => 'plain_text',
),
));
$custom_block->save();
return $custom_block;
}
/**