Commit 45092042 authored by catch's avatar catch
Browse files

Issue #2478483 by Wim Leers, Fabianx: Introduce placeholders (#lazy_builder)...

Issue #2478483 by Wim Leers, Fabianx: Introduce placeholders (#lazy_builder) to replace #post_render_cache
parent c219ec8f
......@@ -1191,33 +1191,6 @@ function show(&$element) {
return $element;
}
/**
* Generates a render cache placeholder.
*
* This can be used to generate placeholders, and hence should also be used by
* #post_render_cache callbacks that want to replace the placeholder with the
* final markup.
*
* @param string $callback
* The #post_render_cache callback that will replace the placeholder with its
* eventual markup.
* @param array $context
* An array providing context for the #post_render_cache callback. This array
* will be altered to provide a 'token' key/value pair, if not already
* provided, to uniquely identify the generated placeholder.
*
* @return string
* The generated placeholder HTML.
*
* @throws \Exception
*
* @deprecated in Drupal 8.0.x-dev, will be removed before Drupal 9.0.0.
* Use \Drupal::service('renderer')->generateCachePlaceholder().
*/
function drupal_render_cache_generate_placeholder($callback, array &$context) {
return \Drupal::service('renderer')->generateCachePlaceholder($callback, $context);
}
/**
* Retrieves the default properties for the defined element type.
*
......
......@@ -24,13 +24,6 @@ class BubbleableMetadata extends CacheableMetadata {
*/
protected $attached = [];
/**
* #post_render_cache metadata.
*
* @var array[]
*/
protected $postRenderCache = [];
/**
* Merges the values of another bubbleable metadata object with this one.
*
......@@ -44,7 +37,6 @@ public function merge(CacheableMetadata $other) {
$result = parent::merge($other);
if ($other instanceof BubbleableMetadata) {
$result->attached = \Drupal::service('renderer')->mergeAttachments($this->attached, $other->attached);
$result->postRenderCache = NestedArray::mergeDeep($this->postRenderCache, $other->postRenderCache);
}
return $result;
}
......@@ -58,7 +50,6 @@ public function merge(CacheableMetadata $other) {
public function applyTo(array &$build) {
parent::applyTo($build);
$build['#attached'] = $this->attached;
$build['#post_render_cache'] = $this->postRenderCache;
}
/**
......@@ -72,82 +63,82 @@ public function applyTo(array &$build) {
public static function createFromRenderArray(array $build) {
$meta = parent::createFromRenderArray($build);
$meta->attached = (isset($build['#attached'])) ? $build['#attached'] : [];
$meta->postRenderCache = (isset($build['#post_render_cache'])) ? $build['#post_render_cache'] : [];
return $meta;
}
/**
* Gets assets.
* Gets attachments.
*
* @return array
* The attachments
*/
public function getAssets() {
public function getAttachments() {
return $this->attached;
}
/**
* Adds assets.
* Adds attachments.
*
* @param array $assets
* The associated assets to be attached.
* @param array $attachments
* The attachments to add.
*
* @return $this
*/
public function addAssets(array $assets) {
$this->attached = NestedArray::mergeDeep($this->attached, $assets);
public function addAttachments(array $attachments) {
$this->attached = \Drupal::service('renderer')->mergeAttachments($this->attached, $attachments);
return $this;
}
/**
* Sets assets.
* Sets attachments.
*
* @param array $assets
* The associated assets to be attached.
* @param array $attachments
* The attachments to set.
*
* @return $this
*/
public function setAssets(array $assets) {
$this->attached = $assets;
public function setAttachments(array $attachments) {
$this->attached = $attachments;
return $this;
}
/**
* Gets #post_render_cache callbacks.
* Gets assets.
*
* @return array
*
* @deprecated Use ::getAttachments() instead. To be removed before Drupal 8.0.0.
*/
public function getPostRenderCacheCallbacks() {
return $this->postRenderCache;
public function getAssets() {
return $this->attached;
}
/**
* Adds #post_render_cache callbacks.
*
* @param string $callback
* The #post_render_cache callback that will replace the placeholder with
* its eventual markup.
* @param array $context
* An array providing context for the #post_render_cache callback.
* Adds assets.
*
* @see \Drupal\Core\Render\RendererInterface::generateCachePlaceholder()
* @param array $assets
* The associated assets to be attached.
*
* @return $this
*
* @deprecated Use ::addAttachments() instead. To be removed before Drupal 8.0.0.
*/
public function addPostRenderCacheCallback($callback, array $context) {
$this->postRenderCache[$callback][] = $context;
return $this;
public function addAssets(array $assets) {
return $this->addAttachments($assets);
}
/**
* Sets #post_render_cache callbacks.
* Sets assets.
*
* @param array $post_render_cache_callbacks
* The associated #post_render_cache callbacks to be executed.
* @param array $assets
* The associated assets to be attached.
*
* @return $this
*
* @deprecated Use ::setAttachments() instead. To be removed before Drupal 8.0.0.
*/
public function setPostRenderCacheCallbacks(array $post_render_cache_callbacks) {
$this->postRenderCache = $post_render_cache_callbacks;
public function setAssets(array $assets) {
$this->attached = $assets;
return $this;
}
......
......@@ -7,9 +7,6 @@
namespace Drupal\Core\Render\Element;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\Site\Settings;
/**
* Provides a messages element.
*
......@@ -37,17 +34,6 @@ public function getInfo() {
/**
* #pre_render callback to generate a placeholder.
*
* Ensures the same token is used for all instances, hence resulting in the
* same placeholder for all places rendering the status messages for this
* request (e.g. in multiple blocks). This ensures we can put the rendered
* messages in all placeholders in one go.
* Also ensures the same context key is used for the #post_render_cache
* property, this ensures that if status messages are rendered multiple times,
* their individual (but identical!) #post_render_cache properties are merged,
* ensuring the callback is only invoked once.
*
* @see ::renderMessages()
*
* @param array $element
* A renderable array.
*
......@@ -55,82 +41,42 @@ public function getInfo() {
* The updated renderable array containing the placeholder.
*/
public static function generatePlaceholder(array $element) {
$plugin_id = 'status_messages';
$callback = get_class() . '::renderMessages';
try {
$hash_salt = Settings::getHashSalt();
}
catch (\RuntimeException $e) {
// Status messages are also shown during the installer, at which time no
// hash salt is defined yet.
$hash_salt = Crypt::randomBytes(8);
}
$key = $plugin_id . $element['#display'];
$context = [
'display' => $element['#display'],
'token' => Crypt::hmacBase64($key, $hash_salt),
];
$placeholder = static::renderer()->generateCachePlaceholder($callback, $context);
$element['#post_render_cache'] = [
$callback => [
$key => $context,
],
$element['messages_placeholder'] = [
'#lazy_builder' => [get_class() . '::renderMessages', [$element['#display']]],
'#create_placeholder' => TRUE,
];
$element['#markup'] = $placeholder;
return $element;
}
/**
* #post_render_cache callback; replaces placeholder with messages.
*
* Note: this is designed to replace all #post_render_cache placeholders for
* messages in a single #post_render_cache callback; hence all placeholders
* must be identical.
* #lazy_builder callback; replaces placeholder with messages.
*
* @see ::getInfo()
*
* @param array $element
* The renderable array that contains the to be replaced placeholder.
* @param array $context
* An array with any context information.
* @param string|null $type
* Limit the messages returned by type. Defaults to NULL, meaning all types.
* Passed on to drupal_get_messages(). These values are supported:
* - NULL
* - 'status'
* - 'warning'
* - 'error'
*
* @return array
* A renderable array containing the messages.
*
* @see drupal_get_messages()
*/
public static function renderMessages(array $element, array $context) {
$renderer = static::renderer();
public static function renderMessages($type) {
// Render the messages.
$messages = [
return [
'#theme' => 'status_messages',
// @todo Improve when https://www.drupal.org/node/2278383 lands.
'#message_list' => drupal_get_messages($context['display']),
'#message_list' => drupal_get_messages($type),
'#status_headings' => [
'status' => t('Status message'),
'error' => t('Error message'),
'warning' => t('Warning message'),
],
];
$markup = $renderer->render($messages);
// Replace the placeholder.
$callback = get_class() . '::renderMessages';
$placeholder = $renderer->generateCachePlaceholder($callback, $context);
$element['#markup'] = str_replace($placeholder, $markup, $element['#markup']);
$element = $renderer->mergeBubbleableMetadata($element, $messages);
return $element;
}
/**
* Wraps the renderer.
*
* @return \Drupal\Core\Render\RendererInterface
*/
protected static function renderer() {
return \Drupal::service('renderer');
}
}
......@@ -121,12 +121,11 @@ public function renderResponse(array $main_content, Request $request, RouteMatch
// The three parts of rendered markup in html.html.twig (page_top, page and
// page_bottom) must be rendered with drupal_render_root(), so that their
// #post_render_cache callbacks are executed (which may attach additional
// assets).
// placeholders are replaced (which may attach additional assets).
// html.html.twig must be able to render the final list of attached assets,
// and hence may not execute any #post_render_cache_callbacks (because they
// might add yet more assets to be attached), and therefore it must be
// rendered with drupal_render(), not drupal_render_root().
// and hence may not replace any placeholders (because they might add yet
// more assets to be attached), and therefore it must be rendered with
// drupal_render(), not drupal_render_root().
$this->renderer->render($html['page'], TRUE);
if (isset($html['page_top'])) {
$this->renderer->render($html['page_top'], TRUE);
......@@ -193,8 +192,8 @@ protected function prepare(array $main_content, Request $request, RouteMatchInte
// We must render the main content now already, because it might provide a
// title. We set its $is_root_call parameter to FALSE, to ensure
// #post_render_cache callbacks are not yet applied. This is essentially
// "pre-rendering" the main content, the "full rendering" will happen in
// placeholders are not yet replaced. This is essentially "pre-rendering"
// the main content, the "full rendering" will happen in
// ::renderResponse().
// @todo Remove this once https://www.drupal.org/node/2359901 lands.
if (!empty($main_content)) {
......@@ -260,15 +259,15 @@ public function invokePageAttachmentHooks(array &$page) {
$function = $module . '_page_attachments';
$function($attachments);
}
if (array_diff(array_keys($attachments), ['#attached', '#post_render_cache', '#cache']) !== []) {
throw new \LogicException('Only #attached, #post_render_cache and #cache may be set in hook_page_attachments().');
if (array_diff(array_keys($attachments), ['#attached', '#cache']) !== []) {
throw new \LogicException('Only #attached and #cache may be set in hook_page_attachments().');
}
// Modules and themes can alter page attachments.
$this->moduleHandler->alter('page_attachments', $attachments);
\Drupal::theme()->alter('page_attachments', $attachments);
if (array_diff(array_keys($attachments), ['#attached', '#post_render_cache', '#cache']) !== []) {
throw new \LogicException('Only #attached, #post_render_cache and #cache may be set in hook_page_attachments_alter().');
if (array_diff(array_keys($attachments), ['#attached', '#cache']) !== []) {
throw new \LogicException('Only #attached and #cache may be set in hook_page_attachments_alter().');
}
// Merge the attachments onto the $page render array.
......
......@@ -300,7 +300,6 @@ public function getCacheableRenderArray(array $elements) {
$data = [
'#markup' => $elements['#markup'],
'#attached' => $elements['#attached'],
'#post_render_cache' => $elements['#post_render_cache'],
'#cache' => [
'contexts' => $elements['#cache']['contexts'],
'tags' => $elements['#cache']['tags'],
......
......@@ -7,12 +7,13 @@
namespace Drupal\Core\Render;
use Drupal\Component\Utility\Crypt;
use Drupal\Component\Utility\NestedArray;
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Controller\ControllerResolverInterface;
use Drupal\Core\Template\Attribute;
use Drupal\Core\Theme\ThemeManagerInterface;
/**
......@@ -102,15 +103,52 @@ public function renderPlain(&$elements) {
return $output;
}
/**
* Renders final HTML for a placeholder.
*
* Renders the placeholder in isolation.
*
* @param string $placeholder
* An attached placeholder to render. (This must be a key of one of the
* values of $elements['#attached']['placeholders'].)
* @param array $elements
* The structured array describing the data to be rendered.
*
* @return array
* The updated $elements.
*
* @see ::replacePlaceholders()
*
* @todo Make public as part of https://www.drupal.org/node/2469431
*/
protected function renderPlaceholder($placeholder, array $elements) {
// Get the render array for the given placeholder
$placeholder_elements = $elements['#attached']['placeholders'][$placeholder];
// Render the placeholder into markup.
$markup = $this->renderPlain($placeholder_elements);
// Replace the placeholder with its rendered markup, and merge its
// bubbleable metadata with the main elements'.
$elements['#markup'] = str_replace($placeholder, $markup, $elements['#markup']);
$elements = $this->mergeBubbleableMetadata($elements, $placeholder_elements);
// Remove the placeholder that we've just rendered.
unset($elements['#attached']['placeholders'][$placeholder]);
return $elements;
}
/**
* {@inheritdoc}
*/
public function render(&$elements, $is_root_call = FALSE) {
// Since #pre_render, #post_render, #post_render_cache callbacks and theme
// functions/templates may be used for generating a render array's content,
// and we might be rendering the main content for the page, it is possible
// that any of them throw an exception that will cause a different page to
// be rendered (e.g. throwing
// Since #pre_render, #post_render, #lazy_builder callbacks and theme
// functions or templates may be used for generating a render array's
// content, and we might be rendering the main content for the page, it is
// possible that any of them throw an exception that will cause a different
// page to be rendered (e.g. throwing
// \Symfony\Component\HttpKernel\Exception\NotFoundHttpException will cause
// the 404 page to be rendered). That page might also use Renderer::render()
// but if exceptions aren't caught here, the stack will be left in an
......@@ -167,17 +205,17 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
}
}
// Try to fetch the prerendered element from cache, run any
// #post_render_cache callbacks and return the final markup.
// Try to fetch the prerendered element from cache, replace any placeholders
// and return the final markup.
if (isset($elements['#cache']['keys'])) {
$cached_element = $this->renderCache->get($elements);
if ($cached_element !== FALSE) {
$elements = $cached_element;
// Only when we're not in a root (non-recursive) drupal_render() call,
// #post_render_cache callbacks must be executed, to prevent breaking
// the render cache in case of nested elements with #cache set.
// Only when we're in a root (non-recursive) Renderer::render() call,
// placeholders must be processed, to prevent breaking the render cache
// in case of nested elements with #cache set.
if ($is_root_call) {
$this->processPostRenderCache($elements);
$this->replacePlaceholders($elements);
}
// Mark the element markup as safe. If we have cached children, we need
// to mark them as safe too. The parent markup contains the child
......@@ -211,6 +249,66 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
$elements += $this->elementInfo->getInfo($elements['#type']);
}
// First validate the usage of #lazy_builder; both of the next if-statements
// use it if available.
if (isset($elements['#lazy_builder'])) {
// @todo Convert to assertions once https://www.drupal.org/node/2408013
// lands.
if (!is_array($elements['#lazy_builder'])) {
throw new \DomainException('The #lazy_builder property must have an array as a value.');
}
if (count($elements['#lazy_builder']) !== 2) {
throw new \DomainException('The #lazy_builder property must have an array as a value, containing two values: the callback, and the arguments for the callback.');
}
if (count($elements['#lazy_builder'][1]) !== count(array_filter($elements['#lazy_builder'][1], function($v) { return is_null($v) || is_scalar($v); }))) {
throw new \DomainException("A #lazy_builder callback's context may only contain scalar values or NULL.");
}
$children = Element::children($elements);
if ($children) {
throw new \DomainException(sprintf('When a #lazy_builder callback is specified, no children can exist; all children must be generated by the #lazy_builder callback. You specified the following children: %s.', implode(', ', $children)));
}
$supported_keys = [
'#lazy_builder',
'#cache',
'#create_placeholder',
// These keys are not actually supported, but they are added automatically
// by the Renderer, so we don't crash on them; them being missing when
// their #lazy_builder callback is invoked won't surprise the developer.
'#weight',
'#printed'
];
$unsupported_keys = array_diff(array_keys($elements), $supported_keys);
if (count($unsupported_keys)) {
throw new \DomainException(sprintf('When a #lazy_builder callback is specified, no properties can exist; all properties must be generated by the #lazy_builder callback. You specified the following properties: %s.', implode(', ', $unsupported_keys)));
}
}
// If instructed to create a placeholder, and a #lazy_builder callback is
// present (without such a callback, it would be impossible to replace the
// placeholder), replace the current element with a placeholder.
if (isset($elements['#create_placeholder']) && $elements['#create_placeholder'] === TRUE) {
if (!isset($elements['#lazy_builder'])) {
throw new \LogicException('When #create_placeholder is set, a #lazy_builder callback must be present as well.');
}
$elements = $this->createPlaceholder($elements);
}
// Build the element if it is still empty.
if (isset($elements['#lazy_builder'])) {
$callable = $elements['#lazy_builder'][0];
$args = $elements['#lazy_builder'][1];
if (is_string($callable) && strpos($callable, '::') === FALSE) {
$callable = $this->controllerResolver->getControllerFromDefinition($callable);
}
$new_elements = call_user_func_array($callable, $args);
// Retain the original cacheability metadata, plus cache keys.
CacheableMetadata::createFromRenderArray($elements)
->merge(CacheableMetadata::createFromRenderArray($new_elements))
->applyTo($new_elements);
if (isset($elements['#cache']['keys'])) {
$new_elements['#cache']['keys'] = $elements['#cache']['keys'];
}
$elements = $new_elements;
$elements['#lazy_builder_built'] = TRUE;
}
// Make any final changes to the element before it is rendered. This means
// that the $element or the children can be altered or corrected before the
// element is rendered into the final text.
......@@ -227,7 +325,6 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
$elements['#cache']['tags'] = isset($elements['#cache']['tags']) ? $elements['#cache']['tags'] : array();
$elements['#cache']['max-age'] = isset($elements['#cache']['max-age']) ? $elements['#cache']['max-age'] : Cache::PERMANENT;
$elements['#attached'] = isset($elements['#attached']) ? $elements['#attached'] : array();
$elements['#post_render_cache'] = isset($elements['#post_render_cache']) ? $elements['#post_render_cache'] : array();
// Allow #pre_render to abort rendering.
if (!empty($elements['#printed'])) {
......@@ -352,9 +449,9 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
}
// We store the resulting output in $elements['#markup'], to be consistent
// with how render cached output gets stored. This ensures that
// #post_render_cache callbacks get the same data to work with, no matter if
// #cache is disabled, #cache is enabled, there is a cache hit or miss.
// with how render cached output gets stored. This ensures that placeholder
// replacement logic gets the same data to work with, no matter if #cache is
// disabled, #cache is enabled, there is a cache hit or miss.
$prefix = isset($elements['#prefix']) ? SafeMarkup::checkAdminXss($elements['#prefix']) : '';
$suffix = isset($elements['#suffix']) ? SafeMarkup::checkAdminXss($elements['#suffix']) : '';
......@@ -372,9 +469,9 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
$this->renderCache->set($elements, $pre_bubbling_elements);
}
// Only when we're in a root (non-recursive) drupal_render() call,
// #post_render_cache callbacks must be executed, to prevent breaking the
// render cache in case of nested elements with #cache set.
// Only when we're in a root (non-recursive) Renderer::render() call,
// placeholders must be processed, to prevent breaking the render cache in
// case of nested elements with #cache set.
//
// By running them here, we ensure that:
// - they run when #cache is disabled,
......@@ -382,21 +479,7 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
// Only the case of a cache hit when #cache is enabled, is not handled here,
// that is handled earlier in Renderer::render().
if ($is_root_call) {
// We've already called ::updateStack() earlier, which updated both the
// element and current stack frame. However,
// Renderer::processPostRenderCache() can both change the element
// further and create and render new child elements, so provide a fresh
// stack frame to collect those additions, merge them back to the element,
// and then update the current frame to match the modified element state.
do {
static::$stack->push(new BubbleableMetadata());
$this->processPostRenderCache($elements);
$post_render_additions = static::$stack->pop();
$elements['#post_render_cache'] = NULL;
BubbleableMetadata::createFromRenderArray($elements)
->merge($post_render_additions)
->applyTo($elements);
} while (!empty($elements['#post_render_cache']));
$this->replacePlaceholders($elements);
if (static::$stack->count() !== 1) {
throw new \LogicException('A stray drupal_render() invocation with $is_root_call = TRUE is causing bubbling of attached assets to break.');
}
......@@ -460,36 +543,79 @@ protected function bubbleStack() {
}
/**
* Processes #post_render_cache callbacks.
* Replaces placeholders.
*
* #post_render_cache callbacks may modify:
* - #markup: to replace placeholders
* - #attached: to add libraries or JavaScript settings
* - #post_render_cache: to execute additional #post_render_cache callbacks
* Placeholders may have:
* - #lazy_builder callback, to build a render array to be rendered into
* markup that can replace the placeholder
* - #cache: to cache the result of the placeholder
*
* Note that in either of these cases, #post_render_cache callbacks are
* implicitly idempotent: a placeholder that has been replaced can't be
* replaced again, and duplicate attachments are ignored.
* Also merges the bubbleable metadata resulting from the rendering of the
* contents of the placeholders. Hence $elements will be contain the entirety
* of bubbleable metadata.
*
* @param array &$elements
* The structured array describing the data being rendered.
* The structured array describing the data being rendered. Including the
* bubbleable metadata associated with the markup that replaced the
* placeholders.