Using Lazy Builders and Auto Placeholders in Drupal 8
Introduction
I’ve been working on a site that features a lot of user-specific customization and output. The site offers workshops (courses) using the Opigno LMS for Drupal 8. The workshops are rendered in a few different ways throughout the site. For the most part, the rendered workshops appear the same for all users. Here’s an example of a “card” view mode for a workshop:
If a user has successfully completed a workshop, a special badge will appear on the card view mode. Also, the card will be highlighted:
There are two parts of the workshop card template that have to change based on the user viewing the card:
- “Completed” badge appears if user completed the workshop
- “completed” CSS class added to the wrapper if the user completed the workshop
Potential Solutions
I was faced with the challenge of maintaining performance while showing these customizations per-user.
Some solutions (and notes about each) that were on the table:
- Add a user or session cache context for this entity type + view mode combination
- This would work but would result in a lot of repeated data in the database cache tables
- Don’t cache this entity type + view mode combination
- Obviously there would be a significant performance hit here
- Use default cache configuration and implement the badge/css class via Javascript
- There wasn’t much time to spend coding something like this
- You’d see things flicker while the page was loading (classes changing, badges appearing)
- Use Lazy Builders and Auto Placeholders to render these dynamic pieces
- This seemed like the best solution; 95% of the template is stored in memory, 5% is rendered on-the-fly
- If the dynamic pieces were hungry (meaning they take a while to run, or they overuse resources) this might not be as good as the user cache context solution above)
The Solution
Keeping with my general approach (hopefully self-explanatory code) here’s what I came up with.
Step 1: Identified which template/theme function I needed to modify
This was simple. I enabled twig debug settings in my development.services.yml file. See here.
Workshop entities are group entities in Opigno LMS. The theme override I could use was hook_preprocess_group , and the template was group--opigno-course--card-view-explore.html.twig .
Step 2: Implemented hook_preprocess_group()
I could’ve done this in the theme’s .theme file, but I chose to put it in a .module file (I want the placeholders to be available no matter which theme we’re using).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
/** * Implements hook_preprocess_group(). */ function mysite_preprocess_group(&$vars) { $group = $vars['elements']['#group']; $bundle = $group->bundle(); if ($bundle !== 'opigno_course' && $bundle !== 'learning_path') { return; } if ($bundle == 'learning_path' && $vars['elements']['#view_mode'] == 'full') { return; } $vars['progress_badge'] = [ '#create_placeholder' => TRUE, '#lazy_builder' => [ 'mysite_group_lazybuilder_progress', [$group->id(), 'badge'], ], ]; $vars['progress_class'] = [ '#create_placeholder' => TRUE, '#lazy_builder' => [ 'mysite_group_lazybuilder_progress', [$group->id(), 'classname'], ], ]; } |
There are a few important things to note here:
- We’re using two separate placeholder variables but they both call the same function. The expensive-to-perform logic within that callback is only executed once. The results are altered based on the second passed parameter (“badge” vs “classname”).
- In most cases you’d have a 1:1 pairing (single callback for a single placeholder)
- The params you pass to a lazy builder have to be scalar (int, float, string, boolean) or NULL.
- On my site I needed the badge/css class on several view modes and group types, hence the conditions at the top of this function.
Step 3: Implemented the callback function
You can put this in a .module function, a method in a class, a service, etc. For simplicity I’ve defined the callback directly below my mysite_preprocess_group function in mysite.module.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
/** * Provides a lazy builder callback for group progress badges and classes. * * @param int $group_id * Group ID. * @param string $return_type * Either "badge" (returns a div) or "classname" (returns a css class name). * * @return array * Render array. * * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException * @throws \Drupal\Component\Plugin\Exception\PluginException * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException */ function mysite_group_lazybuilder_progress($group_id, $return_type) { $data = &drupal_static(__FUNCTION__); if (empty($data[$group_id])) { $group = Group::load($group_id); $data[$group_id] = []; $data[$group_id]['bundle'] = $group->bundle(); $account = \Drupal::currentUser(); $uid = $account->id(); // TODO: THIS LINE IS FOR DEBUGGING. error_log('QUERY: User=' . $uid . ' Group=' . $group_id); $data[$group_id]['progress'] = opigno_learning_path_progress($group_id, $uid); $data[$group_id]['progress'] = round($data[$group_id]['progress'] * 100); } // TODO: THESE THREE LINES ARE FOR DEBUGGING. $account = \Drupal::currentUser(); $uid = $account->id(); error_log('RETURN: User=' . $uid . ' ReturnType=' . $return_type . ' Group=' . $group_id); if ($data[$group_id]['progress'] == 100) { if ($return_type == 'badge') { return [ '#markup' => '<div class="completed">' . t('Completed') . '</div>', ]; } elseif ($return_type == 'classname') { return [ '#plain_text' => 'completed', ]; } } return []; } |
There are a few important things to note here:
- I’ve simplified this function a bit for the purposes of this blog post; it’s still pretty ugly and should be broken apart a bit.
- Because we are executing this twice (for both placeholders), I leverage drupal_static to store the results of expensive operations (everything in the if (empty($data[$group_id])) { condition). This $data variable will persist through subsequent calls in the same request.
- The error_log trick is helpful while developing; it lets you see when Drupal is running the expensive calls (before static variable is populated), and when it’s able to just pull data from the static variable. Xdebug is amazing and I use it every day, but it’s nice (in this case) to throw this info into the error log and tail it (e.g., lando logs -t -f -s appserver ).
Step 4: Updated twig template
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
<div{{ attributes.addclass('group-opigno-course') }}> <div id="group-content" class="card-display__col"> <div class="card-display__inner"> <div class="card-display__status"> {% if logged_in %} {{ progress_badge }} {% endif %} </div> <div class="card-display__main {{ progress_class }}"> <div class="workshop-series-progress card-display__content"> <h3>{{ content.label }}</h3> <div class="card-display__topic"> {{ card_type }} </div> </div> <div class="card-display__image"> {% if content.field_course_media_image[0] %} {{ content.field_course_media_image }} {% else %} {{ opigno_catalog_get_default_image('learning_path') }} {% endif %} </div> </div> </div> </div> </div> |
Step 5: Tested
I cleared all of my caches, made sure all caching was at production levels, then started watching my logs for my debug messages (see note about error_log usage above).
Then, I visited a listing page that had a few of these workshop cards. Here’s what I saw:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
QUERY: User=1 Group=18 RETURN: User=1 ReturnType=badge Group=18 RETURN: User=1 ReturnType=classname Group=18 QUERY: User=1 Group=166 RETURN: User=1 ReturnType=badge Group=166 RETURN: User=1 ReturnType=classname Group=166 QUERY: User=1 Group=36 RETURN: User=1 ReturnType=badge Group=36 RETURN: User=1 ReturnType=classname Group=36 QUERY: User=1 Group=45 RETURN: User=1 ReturnType=badge Group=45 RETURN: User=1 ReturnType=classname Group=45 QUERY: User=1 Group=61 RETURN: User=1 ReturnType=badge Group=61 RETURN: User=1 ReturnType=classname Group=61 |
I could see here that the expensive “query” logic only happened once.
The page was rendering cards correctly.
Step 6: Kept testing
I tested over and over and over again. I worked through many workshops with many users to make sure user X wasn’t seeing badges from user Y. All the while, I monitored the logs to make sure each user was getting fresh (their own) badge/class info.
Step 7: Implemented the same placeholders in several other template files
Then I continued to test.
Additional Information about Placeholders
If you’re curious what is stored in the database, have a look at the cache_render table.
I picked one of my groups to focus on. Here’s what shows in the cache_render table:
The cache contexts (which I inspected via xdebug in mysite_preprocess_group()) show a context of user.permissions. This is why we see two entries (one as I browsed anonymously, and one as I browsed as an authenticated user).
You can see the placeholders in the data field of the rendered entity cached rows. Here’s what the authenticated version’s data looks like:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
a:3:{s:7:"#markup";O:25:"Drupal\Core\Render\Markup":1:{s:9:"*string";s:1770:" <div data-quickedit-entity-id="group/171" class="contextual-region group-opigno-course"> <div id="group-content" class="card-display__col"> <div class="card-display__inner"> <div class="card-display__status"> <drupal-render-placeholder callback="mysite_common_group_lazybuilder_progress" arguments="0=171&1=badge" token="wH06lOmRuemBOgl8aSERGEPmKmE8Are_6f_Ns-8lFg0"></drupal-render-placeholder> </div> <div class="card-display__main <drupal-render-placeholder callback="mysite_common_group_lazybuilder_progress" arguments="0=171&1=classname" token="tYCa1h4pZIyl8O94ICT6JJOMKVByIIvWEpnBQN4upjc"></drupal-render-placeholder>"> <div class="workshop-series-progress card-display__content"> <h3> <div data-quickedit-field-id="group/171/label/en/card_view_explore" class="field field--name-label field--type-string field--label-hidden field__item"><a href="/workshops/listen-with-talkback-series/angle-gestures">Angle Gestures</a></div> </h3> <div class="card-display__topic"> Workshop </div> </div> <div class="card-display__image"> <div data-quickedit-field-id="group/171/field_course_media_image/en/card_view_explore" class="field field--name-field-course-media-image field--type-entity-reference field--label-hidden field__item"> <img src="/sites/default/files/styles/mysite_explore/public/mysite_images_entity_browser/2020-03/angle_gestures_card.jpg?h=85a98894&itok=Z5itzt_5" width="520" height="390" alt="" typeof="foaf:Image" class="image-style-mysite-explore" /> </div> </div> </div> </div> </div> </div> ";}s:9:"#attached";a:1:{s:12:"placeholders";a:2:{s:193:"<drupal-render-placeholder callback="mysite_common_group_lazybuilder_progress" arguments="0=171&1=classname" token="tYCa1h4pZIyl8O94ICT6JJOMKVByIIvWEpnBQN4upjc"></drupal-render-placeholder>";a:1:{s:13:"#lazy_builder";a:2:{i:0;s:40:"mysite_common_group_lazybuilder_progress";i:1;a:2:{i:0;s:3:"171";i:1;s:9:"classname";}}}s:189:"<drupal-render-placeholder callback="mysite_common_group_lazybuilder_progress" arguments="0=171&1=badge" token="wH06lOmRuemBOgl8aSERGEPmKmE8Are_6f_Ns-8lFg0"></drupal-render-placeholder>";a:1:{s:13:"#lazy_builder";a:2:{i:0;s:40:"mysite_common_group_lazybuilder_progress";i:1;a:2:{i:0;s:3:"171";i:1;s:5:"badge";}}}}}s:6:"#cache";a:3:{s:8:"contexts";a:3:{i:0;s:28:"languages:language_interface";i:1;s:5:"theme";i:2;s:16:"user.permissions";}s:4:"tags";a:3:{i:0;s:9:"group:171";i:1;s:10:"group_view";i:2;s:9:"media:238";}s:7:"max-age";i:-1;}} |
One Comment
Abraham
Did you have any issues with the lazy builder rendering the class?
I followed this guide but ran into issues with the twig inside the class attribute. It renders the placeholder markup when the twig is placed inside the class attribute. When I place it outside the attribute and div, the class renders fine.
E.g. using your example
TWIG
would output something similar to:
<div class="card-display__main “>
I think the problem might be that Drupal.behaviors might not be able to pick up the drupal-render-placeholder element to replace it with the placeholder data (not valid markup since it’s in the attribute?) Hence, leaving this mess inside the class attribute. I was wondering what your findings were and maybe what I might be missing?