Issue #2543332 by Wim Leers, effulgentsia, Fabianx, Crell, dawehner, borisson_: Auto-placeholdering for #lazy_builder without bubbling

8.0.x
Nathaniel Catchpole 2015-08-06 11:05:23 +01:00
parent c3e2000be2
commit b2303acfd6
10 changed files with 311 additions and 76 deletions

View File

@ -3,6 +3,10 @@ parameters:
twig.config: {}
renderer.config:
required_cache_contexts: ['languages:language_interface', 'theme', 'user.permissions']
auto_placeholder_conditions:
max-age: 0
contexts: ['session', 'user']
tags: []
factory.keyvalue:
default: keyvalue.database
factory.keyvalue.expirable:

View File

@ -170,6 +170,9 @@ class Renderer implements RendererInterface {
// Get the render array for the given placeholder
$placeholder_elements = $elements['#attached']['placeholders'][$placeholder];
// Prevent the render array from being auto-placeholdered again.
$placeholder_elements['#create_placeholder'] = FALSE;
// Render the placeholder into markup.
$markup = $this->renderPlain($placeholder_elements);
@ -337,6 +340,10 @@ class Renderer implements RendererInterface {
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)));
}
}
// Determine whether to do auto-placeholdering.
if (isset($elements['#lazy_builder']) && (!isset($elements['#create_placeholder']) || $elements['#create_placeholder'] !== FALSE) && $this->shouldAutomaticallyPlaceholder($elements)) {
$elements['#create_placeholder'] = TRUE;
}
// 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.
@ -635,6 +642,37 @@ class Renderer implements RendererInterface {
return TRUE;
}
/**
* Whether the given render array should be automatically placeholdered.
*
* @param array $element
* The render array whose cacheability to analyze.
*
* @return bool
* Whether the given render array's cacheability meets the placeholdering
* conditions.
*/
protected function shouldAutomaticallyPlaceholder(array $element) {
$conditions = $this->rendererConfig['auto_placeholder_conditions'];
// Auto-placeholder if max-age is at or below the configured threshold.
if (isset($element['#cache']['max-age']) && $element['#cache']['max-age'] !== Cache::PERMANENT && $element['#cache']['max-age'] <= $conditions['max-age']) {
return TRUE;
}
// Auto-placeholder if a high-cardinality cache context is set.
if (isset($element['#cache']['contexts']) && array_intersect($element['#cache']['contexts'], $conditions['contexts'])) {
return TRUE;
}
// Auto-placeholder if a high-invalidation frequency cache tag is set.
if (isset($element['#cache']['tags']) && array_intersect($element['#cache']['tags'], $conditions['tags'])) {
return TRUE;
}
return FALSE;
}
/**
* Turns this element into a placeholder.
*

View File

@ -78,6 +78,16 @@ class CommentForm extends ContentEntityForm {
$field_definition = $this->entityManager->getFieldDefinitions($entity->getEntityTypeId(), $entity->bundle())[$comment->getFieldName()];
$config = $this->config('user.settings');
// In several places within this function, we vary $form on:
// - The current user's permissions.
// - Whether the current user is authenticated or anonymous.
// - The 'user.settings' configuration.
// - The comment field's definition.
$form['#cache']['contexts'][] = 'user.permissions';
$form['#cache']['contexts'][] = 'user.roles:authenticated';
$this->renderer->addCacheableDependency($form, $config);
$this->renderer->addCacheableDependency($form, $field_definition->getConfig($entity->bundle()));
// Use #comment-form as unique jump target, regardless of entity type.
$form['#id'] = Html::getUniqueId('comment_form');
$form['#theme'] = array('comment_form__' . $entity->getEntityTypeId() . '__' . $entity->bundle() . '__' . $field_name, 'comment_form');
@ -90,10 +100,6 @@ class CommentForm extends ContentEntityForm {
$form['#attributes']['data-user-info-from-browser'] = TRUE;
}
// Vary per role, because we check a permission above and attach an asset
// library only for authenticated users.
$form['#cache']['contexts'][] = 'user.roles';
// If not replying to a comment, use our dedicated page callback for new
// Comments on entities.
if (!$comment->id() && !$comment->hasParentComment()) {
@ -164,6 +170,7 @@ class CommentForm extends ContentEntityForm {
$form['author']['name']['#value'] = $form['author']['name']['#default_value'];
$form['author']['name']['#theme'] = 'username';
$form['author']['name']['#account'] = $this->currentUser;
$form['author']['name']['#cache']['contexts'][] = 'user';
}
elseif($this->currentUser->isAnonymous()) {
$form['author']['name']['#attributes']['data-drupal-default-value'] = $config->get('anonymous');
@ -210,10 +217,6 @@ class CommentForm extends ContentEntityForm {
'#access' => $is_admin,
);
$this->renderer->addCacheableDependency($form, $config);
// The form depends on the field definition.
$this->renderer->addCacheableDependency($form, $field_definition->getConfig($entity->bundle()));
return parent::form($form, $form_state, $comment);
}

View File

@ -184,30 +184,23 @@ class CommentDefaultFormatter extends FormatterBase implements ContainerFactoryP
// Only show the add comment form if the user has permission.
$elements['#cache']['contexts'][] = 'user.roles';
if ($this->currentUser->hasPermission('post comments')) {
// All users in the "anonymous" role can use the same form: it is fine
// for this form to be stored in the render cache.
if ($this->currentUser->isAnonymous()) {
$comment = $this->storage->create(array(
'entity_type' => $entity->getEntityTypeId(),
'entity_id' => $entity->id(),
'field_name' => $field_name,
'comment_type' => $this->getFieldSetting('comment_type'),
'pid' => NULL,
));
$output['comment_form'] = $this->entityFormBuilder->getForm($comment);
}
// All other users need a user-specific form, which would break the
// render cache: hence use a #lazy_builder callback.
else {
$output['comment_form'] = [
'#lazy_builder' => ['comment.lazy_builders:renderForm', [
$entity->getEntityTypeId(),
$entity->id(),
$field_name,
$this->getFieldSetting('comment_type'),
]],
'#create_placeholder' => TRUE,
];
$output['comment_form'] = [
'#lazy_builder' => ['comment.lazy_builders:renderForm', [
$entity->getEntityTypeId(),
$entity->id(),
$field_name,
$this->getFieldSetting('comment_type'),
]],
];
// @todo Remove this in https://www.drupal.org/node/2543334. Until
// then, \Drupal\Core\Render\Renderer::hasPoorCacheability() isn't
// integrated with cache context bubbling, so this duplicates the
// contexts added by \Drupal\comment\CommentForm::form().
$output['comment_form']['#cache']['contexts'][] = 'user.permissions';
$output['comment_form']['#cache']['contexts'][] = 'user.roles:authenticated';
if ($this->currentUser->isAuthenticated()) {
$output['comment_form']['#cache']['contexts'][] = 'user';
}
}
}

View File

@ -70,8 +70,7 @@ class CommentDefaultFormatterCacheTagsTest extends EntityUnitTestBase {
->getViewBuilder('entity_test')
->view($commented_entity);
$renderer->renderRoot($build);
$cache_context_tags = \Drupal::service('cache_contexts_manager')->convertTokensToKeys($build['#cache']['contexts'])->getCacheTags();
$expected_cache_tags = Cache::mergeTags($cache_context_tags, [
$expected_cache_tags = [
'entity_test_view',
'entity_test:' . $commented_entity->id(),
'comment_list',
@ -80,7 +79,7 @@ class CommentDefaultFormatterCacheTagsTest extends EntityUnitTestBase {
'config:field.field.entity_test.entity_test.comment',
'config:field.storage.comment.comment_body',
'config:user.settings',
]);
];
sort($expected_cache_tags);
$this->assertEqual($build['#cache']['tags'], $expected_cache_tags);
@ -113,8 +112,7 @@ class CommentDefaultFormatterCacheTagsTest extends EntityUnitTestBase {
->getViewBuilder('entity_test')
->view($commented_entity);
$renderer->renderRoot($build);
$cache_context_tags = \Drupal::service('cache_contexts_manager')->convertTokensToKeys($build['#cache']['contexts'])->getCacheTags();
$expected_cache_tags = Cache::mergeTags($cache_context_tags, [
$expected_cache_tags = [
'entity_test_view',
'entity_test:' . $commented_entity->id(),
'comment_list',
@ -128,7 +126,7 @@ class CommentDefaultFormatterCacheTagsTest extends EntityUnitTestBase {
'config:field.field.entity_test.entity_test.comment',
'config:field.storage.comment.comment_body',
'config:user.settings',
]);
];
sort($expected_cache_tags);
$this->assertEqual($build['#cache']['tags'], $expected_cache_tags);
}

View File

@ -38,10 +38,9 @@ class CommentTranslationUITest extends ContentTranslationUITestBase {
protected $defaultCacheContexts = [
'languages:language_interface',
'theme',
'user.permissions',
'timezone',
'url.query_args.pagers:0',
'user.roles'
'user'
];
/**

View File

@ -27,13 +27,12 @@ class NodeTranslationUITest extends ContentTranslationUITestBase {
protected $defaultCacheContexts = [
'languages:language_interface',
'theme',
'user.permissions',
'route.menu_active_trails:account',
'route.menu_active_trails:footer',
'route.menu_active_trails:main',
'route.menu_active_trails:tools',
'timezone',
'user.roles'
'user'
];
/**

View File

@ -37,13 +37,24 @@ class RendererPlaceholdersTest extends RendererTestBase {
* Also, different types:
* - A) automatically generated placeholder
* - 1) manually triggered (#create_placeholder = TRUE)
* - 2) automatically triggered (based on max-age = 0 in its subtree)
* - 2) automatically triggered (based on max-age = 0 at the top level)
* - 3) automatically triggered (based on high cardinality cache contexts at
* the top level)
* - 4) automatically triggered (based on high-invalidation frequency cache
* tags at the top level)
* - 5) automatically triggered (based on max-age = 0 in its subtree, i.e.
* via bubbling)
* - 6) automatically triggered (based on high cardinality cache contexts in
* its subtree, i.e. via bubbling)
* - 7) automatically triggered (based on high-invalidation frequency cache
* tags in its subtree, i.e. via bubbling)
* - B) manually generated placeholder
*
* So, in total 2*3 = 6 permutations.
* So, in total 2*5 = 10 permutations.
*
* @todo Case A2 is not yet supported by core. So that makes for only 4
* permutations currently.
* @todo Cases A5, A6 and A7 are not yet supported by core. So that makes for
* only 10 permutations currently, instead of 16. That will be done in
* https://www.drupal.org/node/2543334
*
* @return array
*/
@ -52,15 +63,11 @@ class RendererPlaceholdersTest extends RendererTestBase {
$generate_placeholder_markup = function($cache_keys = NULL) use ($args) {
$token_render_array = [
'#cache' => [],
'#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args],
];
if (is_array($cache_keys)) {
$token_render_array['#cache']['keys'] = $cache_keys;
}
else {
unset($token_render_array['#cache']);
}
$token = hash('sha1', serialize($token_render_array));
return SafeMarkup::format('<drupal-render-placeholder callback="@callback" arguments="@arguments" token="@token"></drupal-render-placeholder>', [
'@callback' => 'Drupal\Tests\Core\Render\PlaceholdersTest::callback',
@ -69,6 +76,11 @@ class RendererPlaceholdersTest extends RendererTestBase {
]);
};
$extract_placeholder_render_array = function ($placeholder_render_array) {
return array_intersect_key($placeholder_render_array, ['#lazy_builder' => TRUE, '#cache' => TRUE]);
};
// Note the presence of '#create_placeholder'.
$base_element_a1 = [
'#attached' => [
'drupalSettings' => [
@ -83,9 +95,55 @@ class RendererPlaceholdersTest extends RendererTestBase {
'#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args],
],
];
// Note the absence of '#create_placeholder', presence of max-age=0 at the
// top level.
$base_element_a2 = [
// @todo, see docblock
'#attached' => [
'drupalSettings' => [
'foo' => 'bar',
],
],
'placeholder' => [
'#cache' => [
'contexts' => [],
'max-age' => 0,
],
'#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args],
],
];
// Note the absence of '#create_placeholder', presence of high cardinality
// cache context at the top level.
$base_element_a3 = [
'#attached' => [
'drupalSettings' => [
'foo' => 'bar',
],
],
'placeholder' => [
'#cache' => [
'contexts' => ['user'],
],
'#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args],
],
];
// Note the absence of '#create_placeholder', presence of high-invalidation
// frequency cache tag at the top level.
$base_element_a4 = [
'#attached' => [
'drupalSettings' => [
'foo' => 'bar',
],
],
'placeholder' => [
'#cache' => [
'contexts' => [],
'tags' => ['current-temperature'],
],
'#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args],
],
];
// Note the absence of '#create_placeholder', but the presence of
// '#attached[placeholders]'.
$base_element_b = [
'#markup' => $generate_placeholder_markup(),
'#attached' => [
@ -94,7 +152,6 @@ class RendererPlaceholdersTest extends RendererTestBase {
],
'placeholders' => [
$generate_placeholder_markup() => [
'#cache' => [],
'#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args],
],
],
@ -109,9 +166,11 @@ class RendererPlaceholdersTest extends RendererTestBase {
// - automatically created, but manually triggered (#create_placeholder = TRUE)
// - uncacheable
$element_without_cache_keys = $base_element_a1;
$expected_placeholder_render_array = $extract_placeholder_render_array($base_element_a1['placeholder']);
$cases[] = [
$element_without_cache_keys,
$args,
$expected_placeholder_render_array,
FALSE,
[],
];
@ -121,9 +180,11 @@ class RendererPlaceholdersTest extends RendererTestBase {
// - cacheable
$element_with_cache_keys = $base_element_a1;
$element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
$expected_placeholder_render_array['#cache']['keys'] = $keys;
$cases[] = [
$element_with_cache_keys,
$args,
$expected_placeholder_render_array,
$keys,
[
'#markup' => '<p>This is a rendered placeholder!</p>',
@ -141,31 +202,148 @@ class RendererPlaceholdersTest extends RendererTestBase {
];
// Case three: render array that has a placeholder that is:
// - manually created
// - automatically created, and automatically triggered due to max-age=0
// - uncacheable
$x = $base_element_b;
unset($x['#attached']['placeholders'][$generate_placeholder_markup()]['#cache']);
$element_without_cache_keys = $base_element_a2;
$expected_placeholder_render_array = $extract_placeholder_render_array($base_element_a2['placeholder']);
$cases[] = [
$x,
$element_without_cache_keys,
$args,
$expected_placeholder_render_array,
FALSE,
[],
];
// Case four: render array that has a placeholder that is:
// - automatically created, but automatically triggered due to max-age=0
// - cacheable
$element_with_cache_keys = $base_element_a2;
$element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
$expected_placeholder_render_array['#cache']['keys'] = $keys;
$cases[] = [
$element_with_cache_keys,
$args,
$expected_placeholder_render_array,
FALSE,
[]
];
// Case five: render array that has a placeholder that is:
// - automatically created, and automatically triggered due to high
// cardinality cache contexts
// - uncacheable
$element_without_cache_keys = $base_element_a3;
$expected_placeholder_render_array = $extract_placeholder_render_array($base_element_a3['placeholder']);
$cases[] = [
$element_without_cache_keys,
$args,
$expected_placeholder_render_array,
FALSE,
[],
];
// Case six: render array that has a placeholder that is:
// - automatically created, and automatically triggered due to high
// cardinality cache contexts
// - cacheable
$element_with_cache_keys = $base_element_a3;
$element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
$expected_placeholder_render_array['#cache']['keys'] = $keys;
// The CID parts here consist of the cache keys plus the 'user' cache
// context, which in this unit test is simply the given cache context token,
// see \Drupal\Tests\Core\Render\RendererTestBase::setUp().
$cid_parts = array_merge($keys, ['user']);
$cases[] = [
$element_with_cache_keys,
$args,
$expected_placeholder_render_array,
$cid_parts,
[
'#markup' => '<p>This is a rendered placeholder!</p>',
'#attached' => [
'drupalSettings' => [
'dynamic_animal' => $args[0],
],
],
'#cache' => [
'contexts' => ['user'],
'tags' => [],
'max-age' => Cache::PERMANENT,
],
],
];
// Case seven: render array that has a placeholder that is:
// - automatically created, and automatically triggered due to high
// invalidation frequency cache tags
// - uncacheable
$element_without_cache_keys = $base_element_a4;
$expected_placeholder_render_array = $extract_placeholder_render_array($base_element_a4['placeholder']);
$cases[] = [
$element_without_cache_keys,
$args,
$expected_placeholder_render_array,
FALSE,
[],
];
// Case eight: render array that has a placeholder that is:
// - automatically created, and automatically triggered due to high
// invalidation frequency cache tags
// - cacheable
$element_with_cache_keys = $base_element_a4;
$element_with_cache_keys['placeholder']['#cache']['keys'] = $keys;
$expected_placeholder_render_array['#cache']['keys'] = $keys;
$cases[] = [
$element_with_cache_keys,
$args,
$expected_placeholder_render_array,
$keys,
[
'#markup' => '<p>This is a rendered placeholder!</p>',
'#attached' => [
'drupalSettings' => [
'dynamic_animal' => $args[0],
],
],
'#cache' => [
'contexts' => [],
'tags' => ['current-temperature'],
'max-age' => Cache::PERMANENT,
],
],
];
// Case nine: render array that has a placeholder that is:
// - manually created
// - uncacheable
$x = $base_element_b;
$expected_placeholder_render_array = $x['#attached']['placeholders'][$generate_placeholder_markup()];
unset($x['#attached']['placeholders'][$generate_placeholder_markup()]['#cache']);
$cases[] = [
$x,
$args,
$expected_placeholder_render_array,
FALSE,
[],
];
// Case ten: render array that has a placeholder that is:
// - manually created
// - cacheable
$x = $base_element_b;
$x['#markup'] = $generate_placeholder_markup($keys);
$x['#markup'] = $placeholder_markup = $generate_placeholder_markup($keys);
$x['#attached']['placeholders'] = [
$generate_placeholder_markup($keys) => [
$placeholder_markup => [
'#lazy_builder' => ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args],
'#cache' => ['keys' => $keys],
],
];
$expected_placeholder_render_array = $x['#attached']['placeholders'][$placeholder_markup];
$cases[] = [
$x,
$args,
$expected_placeholder_render_array,
$keys,
[
'#markup' => '<p>This is a rendered placeholder!</p>',
@ -224,8 +402,8 @@ class RendererPlaceholdersTest extends RendererTestBase {
*
* @dataProvider providerPlaceholders
*/
public function testUncacheableParent($element, $args, $placeholder_cid_keys, array $placeholder_expected_render_cache_array) {
if ($placeholder_cid_keys) {
public function testUncacheableParent($element, $args, array $expected_placeholder_render_array, $placeholder_cid_parts, array $placeholder_expected_render_cache_array) {
if ($placeholder_cid_parts) {
$this->setupMemoryCache();
}
else {
@ -244,7 +422,7 @@ class RendererPlaceholdersTest extends RendererTestBase {
'dynamic_animal' => $args[0],
];
$this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.');
$this->assertPlaceholderRenderCache($placeholder_cid_keys, $placeholder_expected_render_cache_array);
$this->assertPlaceholderRenderCache($placeholder_cid_parts, $placeholder_expected_render_cache_array);
}
/**
@ -256,25 +434,12 @@ class RendererPlaceholdersTest extends RendererTestBase {
*
* @dataProvider providerPlaceholders
*/
public function testCacheableParent($test_element, $args, $placeholder_cid_keys, array $placeholder_expected_render_cache_array) {
public function testCacheableParent($test_element, $args, array $expected_placeholder_render_array, $placeholder_cid_parts, array $placeholder_expected_render_cache_array) {
$element = $test_element;
$this->setupMemoryCache();
$this->setUpRequest('GET');
// Generate the expected placeholder render array, so that we can generate
// the expected placeholder markup.
$expected_placeholder_render_array = [];
// When there was a child element that created a placeholder, the Renderer
// automatically initializes #cache[contexts].
if (Element::children($test_element)) {
$expected_placeholder_render_array['#cache']['contexts'] = [];
}
// When the placeholder itself is cacheable, its cache keys are present.
if ($placeholder_cid_keys) {
$expected_placeholder_render_array['#cache']['keys'] = $placeholder_cid_keys;
}
$expected_placeholder_render_array['#lazy_builder'] = ['Drupal\Tests\Core\Render\PlaceholdersTest::callback', $args];
$token = hash('sha1', serialize($expected_placeholder_render_array));
$expected_placeholder_markup = '<drupal-render-placeholder callback="Drupal\Tests\Core\Render\PlaceholdersTest::callback" arguments="0=' . $args[0] . '" token="' . $token . '"></drupal-render-placeholder>';
$this->assertSame($expected_placeholder_markup, Html::normalize($expected_placeholder_markup), 'Placeholder unaltered by Html::normalize() which is used by FilterHtmlCorrector.');
@ -291,7 +456,7 @@ class RendererPlaceholdersTest extends RendererTestBase {
'dynamic_animal' => $args[0],
];
$this->assertSame($element['#attached']['drupalSettings'], $expected_js_settings, '#attached is modified; both the original JavaScript setting and the one added by the placeholder #lazy_builder callback exist.');
$this->assertPlaceholderRenderCache($placeholder_cid_keys, $placeholder_expected_render_cache_array);
$this->assertPlaceholderRenderCache($placeholder_cid_parts, $placeholder_expected_render_cache_array);
// GET request: validate cached data.
$cached_element = $this->memoryCache->get('placeholder_test_GET')->data;

View File

@ -89,6 +89,11 @@ class RendererTestBase extends UnitTestCase {
'languages:language_interface',
'theme',
],
'auto_placeholder_conditions' => [
'max-age' => 0,
'contexts' => ['session', 'user'],
'tags' => ['current-temperature'],
],
];
/**

View File

@ -84,6 +84,37 @@ parameters:
#
# @default ['languages:language_interface', 'theme', 'user.permissions']
required_cache_contexts: ['languages:language_interface', 'theme', 'user.permissions']
# Renderer automatic placeholdering conditions:
#
# Drupal allows portions of the page to be automatically deferred when
# rendering to improve cache performance. That is especially helpful for
# cache contexts that vary widely, such as the active user. On some sites
# those may be different, however, such as sites with only a handful of
# users. If you know what the high-cardinality cache contexts are for your
# site, specify those here. If you're not sure, the defaults are fairly safe
# in general.
#
# For more information about rendering optimizations see
# https://www.drupal.org/developing/api/8/render/arrays/cacheability#optimizing
auto_placeholder_conditions:
# Max-age at or below which caching is not considered worthwhile.
#
# Disable by setting to -1.
#
# @default 0
max-age: 0
# Cache contexts with a high cardinality.
#
# Disable by setting to [].
#
# @default ['session', 'user']
contexts: ['session', 'user']
# Tags with a high invalidation frequency.
#
# Disable by setting to [].
#
# @default []
tags: []
factory.keyvalue:
{}
# Default key/value storage service to use.