Skip to content

Instantly share code, notes, and snippets.

@bradjones1
Last active June 15, 2020 13:45
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bradjones1/af000317045256656071ffd8440bce24 to your computer and use it in GitHub Desktop.
Save bradjones1/af000317045256656071ffd8440bce24 to your computer and use it in GitHub Desktop.
BigPipe for field items

Some fields can have lots (read: hundreds?) of values, and on a cold cache these can block loading an otherwise optimized page. Could we use (abuse?) BigPipe to send these field items in chunks?

Or, is this just crazy and this should be optimized somehow else?

Notes: Obviously this impacts your theme layer as well - field items may be rendered in the "chunks" in such a way that is incompatible with your handling of field items, currently (e.g., multiple values would appear under a single field__item classed div, maybe.)

Prior art:

<?php declare(strict_types=1);
namespace Drupal\bigpipe_field;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Security\TrustedCallbackInterface;
use Drupal\Core\TypedData\TranslatableInterface;
use Drupal\Core\Url;
use Drupal\file\Entity\File;
/**
* Lazy builders.
*/
class BigPipeFieldLazyBuilders implements TrustedCallbackInterface {
/**
* The image style entity storage.
*
* @var \Drupal\image\ImageStyleStorageInterface
*/
protected $imageStyleStorage;
/**
* Entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* {@inheritDoc}
*/
public static function trustedCallbacks() {
return ['renderReallyLazyImageChunk'];
}
/**
* Constructor.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
* Entity type manager.
*/
public function __construct(EntityTypeManagerInterface $entityTypeManager) {
$this->entityTypeManager = $entityTypeManager;
$this->imageStyleStorage = $entityTypeManager->getStorage('image_style');
}
/**
* Lazy builder for a chunk of images.
*
* @param string $entityType
* Entity type.
* @param mixed $entityId
* Entity ID.
* @param string $fieldName
* Field name.
* @param string $deltas
* Comma-delimited list of deltas to render for the field.
* @param string $langcode
* Language code.
* @param string $jsonSettings
* JSON-encoded Widget settings.
*
* @return array
* Render array.
*/
public function renderReallyLazyImageChunk(string $entityType, $entityId, string $fieldName, string $deltas, string $langcode, string $jsonSettings): array {
$elements = [];
$deltas = explode(',', $deltas);
$settings = json_decode($jsonSettings, TRUE);
/** @var \Drupal\Core\Entity\FieldableEntityInterface $entity */
$entity = $this->entityTypeManager
->getStorage($entityType)->load($entityId);
$files = $this
->getEntitiesToView($entity->get($fieldName), $deltas, $langcode);
$url = NULL;
$image_link_setting = $settings['image_link'];
// Check if the formatter involves a link.
if ($image_link_setting == 'content') {
if (!$entity->isNew()) {
$url = $entity->toUrl();
}
}
elseif ($image_link_setting == 'file') {
$link_file = TRUE;
}
$image_style_setting = $settings['image_style'];
// Collect cache tags to be added for each item in the field.
$base_cache_tags = [];
if (!empty($image_style_setting)) {
$image_style = $this->imageStyleStorage->load($image_style_setting);
$base_cache_tags = $image_style->getCacheTags();
}
foreach ($files as $delta => $file) {
$cache_contexts = [];
if (isset($link_file)) {
$image_uri = $file->getFileUri();
// @todo Wrap in file_url_transform_relative(). This is currently
// impossible. As a work-around, we currently add the 'url.site' cache
// context to ensure different file URLs are generated for different
// sites in a multisite setup, including HTTP and HTTPS versions of the
// same site. Fix in https://www.drupal.org/node/2646744.
$url = Url::fromUri(file_create_url($image_uri));
$cache_contexts[] = 'url.site';
}
$cache_tags = Cache::mergeTags($base_cache_tags, $file->getCacheTags());
// Extract field item attributes for the theme function, and unset them
// from the $item so that the field template does not re-render them.
$item = $file->_referringItem;
$elements[$delta] = [
'#type' => 'container',
'#attributes' => ['class' => ['field__item', 'lazy-built-glider-item']],
'image' => [
'#theme' => 'image_formatter',
'#item' => $item,
// Can't use the lazy UI widget because it doesn't work with lazy builders!
'#item_attributes' => ['data-lazy' => TRUE],
'#image_style' => $image_style_setting,
'#url' => $url,
'#cache' => [
'tags' => $cache_tags,
'contexts' => $cache_contexts,
],
],
];
}
return $elements;
}
/**
* Returns the referenced entities for display.
*
* The method takes care of:
* - checking entity access,
* - placing the entities in the language expected for display.
*
* @param \Drupal\Core\Field\FieldItemListInterface
* Field item list.
* @param integer[]
* Deltas to load.
* @param string $langcode
* The language code of the referenced entities to display.
*
* @return \Drupal\Core\Entity\EntityInterface[]
* The array of referenced entities to display, keyed by delta.
*
* @see \Drupal\image\Plugin\Field\FieldFormatter\ImageFormatterBase::getEntitiesToView()
*/
protected function getEntitiesToView(FieldItemListInterface $fieldItemList, array $deltas, string $langcode) {
$entities = [];
foreach ($deltas as $delta) {
$item = $fieldItemList->get($delta);
$entity = File::load($item->target_id);
if (!empty($entity)) {
// Set the entity in the correct language for display.
if ($entity instanceof TranslatableInterface) {
$entity = \Drupal::service('entity.repository')->getTranslationFromContext($entity, $langcode);
}
$access = $this->checkAccess($entity);
// Add the access result's cacheability, ::view() needs it.
$item->_accessCacheability = CacheableMetadata::createFromObject($access);
if ($access->isAllowed()) {
// Add the referring item, in case the formatter needs it.
$entity->_referringItem = $item;
$entities[$delta] = $entity;
}
}
}
return $entities;
}
/**
* Checks access to the given entity.
*
* @see \Drupal\Core\Field\Plugin\Field\FieldFormatter\EntityReferenceFormatterBase
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to check.
*
* @return \Drupal\Core\Access\AccessResultInterface
* A cacheable access result.
*/
protected function checkAccess(EntityInterface $entity): AccessResultInterface {
// Only check access if the current file access control handler explicitly
// opts in by implementing FileAccessFormatterControlHandlerInterface.
$access_handler_class = $entity->getEntityType()->getHandlerClass('access');
if (is_subclass_of($access_handler_class, '\Drupal\file\FileAccessFormatterControlHandlerInterface')) {
return $entity->access('view', NULL, TRUE);
}
else {
return AccessResult::allowed();
}
}
}
<?php declare(strict_types=1);
namespace Drupal\bigpipe_field\Plugin\Field\FieldFormatter;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\image\Plugin\Field\FieldFormatter\ImageFormatter;
use Drupal\Core\Field\Annotation\FieldFormatter;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Plugin implementation of the 'really_lazy_image' formatter.
*
* @FieldFormatter(
* id = "really_lazy_image",
* label = @Translation("Image - Really Lazy"),
* field_types = {
* "image"
* },
* )
*/
class ReallyLazyImageFormatter extends ImageFormatter {
/**
* Minimum number of items to care about.
*/
const LAZINESS = 10;
/**
* Lazy-built chunk size. This might always be even since access checking is
* performed in ::getEntitiesToView(), but we are lazy.
*/
const CHUNK_SIZE = 10;
/**
* Renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* {@inheritDoc}
*/
public function __construct(
$plugin_id,
$plugin_definition,
FieldDefinitionInterface $field_definition,
array $settings,
$label,
$view_mode,
array $third_party_settings,
AccountInterface $current_user,
EntityStorageInterface $image_style_storage,
RendererInterface $renderer
) {
parent::__construct(
$plugin_id,
$plugin_definition,
$field_definition,
$settings,
$label,
$view_mode,
$third_party_settings,
$current_user,
$image_style_storage
);
$this->renderer = $renderer;
}
/**
* {@inheritDoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$plugin_id,
$plugin_definition,
$configuration['field_definition'],
$configuration['settings'],
$configuration['label'],
$configuration['view_mode'],
$configuration['third_party_settings'],
$container->get('current_user'),
$container->get('entity_type.manager')->getStorage('image_style'),
$container->get('renderer')
);
}
/**
* {@inheritDoc}
*/
public function viewElements(FieldItemListInterface $items, $langcode) {
if ($items->count() < self::LAZINESS) {
return parent::viewElements($items, $langcode);
}
$elements = [];
foreach (array_chunk(array_keys($items->getValue()), self::CHUNK_SIZE) as $chunk) {
$element = [
'#create_placeholder' => TRUE,
'#lazy_builder' => [
'bigpipe_field.lazy_builders:renderReallyLazyImageChunk',
[
$items->getEntity()->getEntityTypeId(),
$items->getEntity()->id(),
$items->getFieldDefinition()->getName(),
implode(',', $chunk),
$langcode,
json_encode($this->getSettings()),
]
],
'#cache' => [
// Cache this since it is rendered on its own.
'keys' => [
__METHOD__,
$items->getEntity()->getEntityTypeId(),
$items->getEntity()->id(),
$items->getFieldDefinition()->getName(),
implode(',', $chunk),
$langcode,
],
]
];
$this->renderer->addCacheableDependency($element, $items->getEntity());
$elements[] = $element;
}
return $elements;
}
}
@bradjones1
Copy link
Author

bradjones1 commented Jun 15, 2020

Did I just spend a bunch of time re-creating https://www.drupal.org/project/big_pipe_sessionless ?

Edit: No, different problem spaces, but similar motivations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment