Render the navigation toolbar in a placeholder

Created on 13 December 2024, 6 days ago

Problem/Motivation

Spin-off from 📌 Add render caching for the navigation render array Active .

We could render the entire navigation menu via a placeholder, this would have the following benefits:

1. With dynamic_page_cache, we'd only cache the placeholder HTML in the cache item, reducing overall cache bin sizes by quite a lot.

2. With big pipe, we'd be able to send HTML and the page header, and start rendering them after. On some sites this will significantly speed up Largest Contentful Paint.

3. With big pipe, navigation-specific libraries will be added via Big Pipe in isolation from other libraries on the page, this should improve CSS and JavaScript aggregation efficiency and cache hits rates, because they won't end up bundled with other aggregates that may or may not be loaded by different front and admin-facing pages.

However there is one potential drawback:

The navigation bar is consistently in the same spot on every page and it currently loads fairly quickly. Placeholdering it may make it load later relative to other elements in the main viewport and could increase Content Layout Shift / (jank) and potentially make Largest Contentful Paint worse (contrary to the pro above) if it moves elements around that would otherwise have finished rendering.

We should be able to get an idea on the last point via manual testing between Standard, Umami and Drupal CMS.

Steps to reproduce

Proposed resolution

Remaining tasks

User interface changes

Introduced terminology

API changes

Data model changes

Release notes snippet

📌 Task
Status

Active

Version

11.0 🔥

Component

navigation.module

Created by

🇬🇧United Kingdom catch

Live updates comments and jobs are added and updated live.
  • Performance

    It affects performance. It is often combined with the Needs profiling tag.

Sign in to follow issues

Comments & Activities

  • Issue created by @catch
  • 🇬🇧United Kingdom catch
  • 🇬🇧United Kingdom catch
  • 🇬🇧United Kingdom catch
  • 🇬🇧United Kingdom catch

    Thought about this more. Because this should have a high cache hit rate and be on every page, it's more part of 'site chrome' than most things. So instead of trying this now, I think we should get 📌 Refactor system/base library Needs work and similar issues done first - which would result in more other js/css added via placeholders. And 📌 Implement a caching strategy for the menu links Active which would reduce the amount of HTML stored by the dynamic_page_cache.

  • 🇬🇧United Kingdom catch

    Another thought.

    For dynamic_page_cache efficiency, we would want this in a placeholder.

    But to avoid cumulative layout shift, we might not want it to be rendered via BigPipe.

    BigPipe already has non-js placeholders, for placeholders within attributes etc. which are blocking and sent with the main response. Maybe we can add some kind of additional hint for the placeholder strategy so that the navigation placeholder always gets rendered via a nojs placeholder (i.e. inline with the main response) - this would address both caching efficiency and layout shift then.

  • 🇬🇧United Kingdom catch

    Talking to Fabianx:

    CachedPlaceholderStrategy
    c) BigPipe in particular would be combined with a CachedPlaceholderStrategy. Compared to single flush, that one could make use of $renderCache->getMultiple() (as soon as that exists), because again something that is cached already does not need to be BigPipe’d.

    placeholder_strategy.cache:
        class: Drupal\Core\Render\Placeholder\CachedPlaceholderStrategy
        tags:
          - { name: placeholder_strategy, priority: -900 }
    

    With the code to first generate the CIDs, then do a $cache->getMultiple() and return the HTML for those placeholders that had a cache hit.

  • 🇩🇪Germany Fabianx

    To comment myself:

    The reason we have a ChainedPlaceholderStrategy in core by default is not only to support big_pipe, but because I envisioned that a lot of things that are not bound by URL would become placeholders in the first place.

    As can be seen from the linked issue in #7 I had this whole idea of making it super easy to put e.g. a shopping cart placeholder on the page and replace it via JS, AJAX, etc.

    And therefore the natural way to go about this is to use the chain:

    - CachedPlaceholderStrategy
    - BigPipePlaceholderStrategy
    - SingleFlushPlaceholderStrategy

    The reason is that the cached placeholder strategy can do a cache->getMultiple() [for anything that can be cached] while the SingleFlushPlaceholderStrategy is just rendering them one by one.

    So even for just core this can save performance.

  • 🇩🇪Germany Fabianx

    Whoever works on CachedPlaceholderStrategy here is how the raw architecture should look like (untested code):

    
    
    // \Drupal::cache('render')
    protected $cache;
    
    // \Drupal::service('placeholder_strategy')
    protected $placeholderStrategy;
    
    // \Drupal::service('render_cache')
    protected $renderCache;
    
    function processPlaceholders($placeholders) {
      $cids = [];
      $new_placeholders = []; 
    
      foreach ($placeholders as $placeholder => $elements) {
        $cid = $this->renderCache->getCacheID($elements);
        if ($cid) {
          $cids[$cid] = $placeholder;
        }
      }
    
      $cached_items = $this->cache->getMultiple(array_keys($cids));
    
      foreach ($cached_items as $cid => $cache) {
        $elements = $cache->data;
    
        // Recursively process placeholders
        if (!empty($elements['#attached']['placeholders']) {
          $elements['#attached']['placeholders'] = $this->placeholderStrategy->processPlaceholders($elements['#attached']['placeholders']);
        }
        
        $placeholder = $cids[$cid];
    
        $new_placeholders[$placeholder] = $elements;
      }
    
      return $new_placeholders;
    }
    

    The reason for the recursion is to support the following scenario (part of ORIGINAL 2014 big pipe demo):

    - There is a sleep(1) on the page in a cacheable block content, which sets max-age=0
    - Then this makes the whole block uncacheable.
    - As soon as you create a placeholder within the block for the current_time, then the block becomes cacheable again. You still want to create a placeholder for the block as it contains other content that is independent of the page.

    With the CachedPlaceholderStrategy with above implementation:

    - If the block is uncached then the whole block is streamed via big_pipe.
    - If the blocked is cached then just the placeholder is streamed via big_pipe.

    Without the recursive calling of the strategy, this would fail and the `current_time` would again make the whole page slow.

  • 🇬🇧United Kingdom catch

    So to make this work:

    Add a CachedPlaceholderStrategy - this does a multiple get on render cache items, replaces the placeholder directly with the cached HTML.

    This runs all the time, before big pipe's own strategy (if big pipe is enabled). Big pipe then will only deal with placeholders which aren't already render cache hits.

    When you have several placeholders render cached for stuff within the viewport, this will significantly reduce content layout shift and maybe largest contentful paint too, because all the HTML gets sent together until it can't be.

    It will also work very nicely in tandem with 🌱 Adopt the Revolt event loop for async task orchestration Active because then anything expensive in the uncached placeholders that can be done async will be.

  • 🇩🇪Germany Fabianx

    While this is untested, here is the code that Grok created with some direction for VariationCache::getMultiple():

    /**
     * Retrieves multiple cached items, handling redirects for each key set.
     *
     * @param array $items
     *   An array where each element is an array containing:
     *   - keys (array): An array of cache keys to retrieve entries for.
     *   - cacheability (CacheableDependencyInterface): Cache metadata for these keys.
     *
     * @return array
     *   An array where keys are the cache IDs, and values are the final cache items 
     *   or NULL if not found.
     */
    public function getMultiple(array $items): array {
      $results = [];
      $cids_to_process = [];
    
      // Generate all cache ids for $items
      foreach ($items as $item) {
        [$keys, $cacheability] = $item;
        $cid = $this->createCacheIdFast($keys, $cacheability);
        $cids_to_process[$cid] = $keys;
      }
    
      // While there are still cache ids to process:
      while (!empty($cids_to_process)) {
        $current_cids = array_keys($cids_to_process);
        $fetched_items = $this->cacheBackend->getMultiple($current_cids);
    
        $new_cids_to_process = [];
    
        // Generate new list of cache ids to process further
        foreach ($cids_to_process as $cid => $keys) {
          $result = $fetched_items[$cid] ?? NULL;
    
          if ($result && $result->data instanceof CacheRedirect) {
            // If there's a redirect, generate a new CID to follow the redirect
            $new_cid = $this->createCacheIdFast($keys, $result->data);
            $new_cids_to_process[$new_cid] = $keys;
          } else {
            // If not redirected or no result, store the result directly
            $results[$cid] = $result;
          }
        }
    
        // Update $cids_to_process with new CIDs for the next iteration
        $cids_to_process = $new_cids_to_process;
      }
    
      return $results;
    }
    
  • 🇩🇪Germany Fabianx

    With that in place, we can simplify our strategy significantly:

    Let's just add a getMultiple() to RenderCache as well (help from ChatGPT).

    
    /**
     * Retrieves multiple cached render arrays at once, following redirects until resolution.
     *
     * Steps:
     * - For each render element, compute a cache ID (CID) based on its keys and cacheability.
     * - Group elements by their cache bin.
     * - For each bin, use getMultiple() to fetch all items in one go.
     * - Follow any redirects discovered, updating CIDs and refetching until resolved.
     * - If the current HTTP request method is not cacheable, skip items tagged
     *   with 'CACHE_MISS_IF_UNCACHEABLE_HTTP_METHOD:'.
     *
     * Only items that resolve to a final cached value are returned. Misses or 
     * disallowed items (due to request method constraints) are omitted entirely.
     *
     * @param array $multiple_elements
     *   An associative array keyed by arbitrary identifiers. Each value should be
     *   a render array that includes '#cache' with 'keys' and optionally 'bin', e.g.:
     *   $multiple_elements = [
     *     'item_a' => [
     *       '#cache' => ['keys' => ['key1', 'key2'], 'bin' => 'render'],
     *       // ... additional render array data ...
     *     ],
     *     'item_b' => [
     *       '#cache' => ['keys' => ['another_key']], // defaults to 'render' bin
     *       // ... additional render array data ...
     *     ],
     *   ];
     *
     * @return array
     *   An associative array keyed by the same keys as $multiple_elements for items that
     *   are found and allowed. Items that never resolve or violate request conditions 
     *   are not returned.
     */
    public function RenderCacheGetMultiple(array $multiple_elements): array {
      $bin_map = [];
      foreach ($multiple_elements as $item_key => $elements) {
        if (!$this->isElementCacheable($elements)) {
          continue;
        }
    
        $bin = $elements['#cache']['bin'] ?? 'render';
        $keys = $elements['#cache']['keys'];
        $cacheability = CacheableMetadata::createFromRenderArray($elements);
    
        $bin_map[$bin][$item_key] = [$keys, $cacheability];
      }
    
      $results = [];
      $is_method_cacheable = $this->requestStack->getCurrentRequest()->isMethodCacheable();
    
      foreach ($bin_map as $bin => $items) {
        $cache_bin = $this->cacheFactory->get($bin);
        if (!$cache_bin) {
          continue;
        }
    
        $fetched_items = $cache_bin->getMultiple($items);
    
        foreach ($fetched_items as $item_key => $cache) {
          if (
            !$is_method_cacheable &&
            !empty(array_filter($cache->tags, fn (string $tag) => str_starts_with($tag, 'CACHE_MISS_IF_UNCACHEABLE_HTTP_METHOD:')))
          ) {
            continue;
          }
          $results[$item_key] = $cache->data;
        }
      }
    
      return $results;
    }
    
    

    Then our CachedStrategy looks like:

    
    // \Drupal::service('placeholder_strategy')
    protected $placeholderStrategy;
    
    // \Drupal::service('render_cache')
    protected $renderCache;
    
    function processPlaceholders($placeholders) {
      $new_placeholders = []; 
    
      $cached_items = $this->renderCache->getMultiple($placeholders);
    
      foreach ($cached_items as $placeholder => $cache) {
        $elements = $cache->data;
    
        // Recursively process placeholders
        if (!empty($elements['#attached']['placeholders']) {
          $elements['#attached']['placeholders'] = $this->placeholderStrategy->processPlaceholders($elements['#attached']['placeholders']);
        }
        
        $new_placeholders[$placeholder] = $elements;
      }
    
      return $new_placeholders;
    }
    
    
Production build 0.71.5 2024