You are here

class RendererBubblingTest in Zircon Profile 8.0

Same name and namespace in other branches
  1. 8 core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php \Drupal\Tests\Core\Render\RendererBubblingTest

@coversDefaultClass \Drupal\Core\Render\Renderer @group Render

Hierarchy

Expanded class hierarchy of RendererBubblingTest

File

core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php, line 20
Contains \Drupal\Tests\Core\Render\RendererBubblingTest.

Namespace

Drupal\Tests\Core\Render
View source
class RendererBubblingTest extends RendererTestBase {

  /**
   * {@inheritdoc}
   */
  protected function setUp() {

    // Disable the required cache contexts, so that this test can test just the
    // bubbling behavior.
    $this->rendererConfig['required_cache_contexts'] = [];
    parent::setUp();
  }

  /**
   * Tests bubbling of assets when NOT using #pre_render callbacks.
   */
  public function testBubblingWithoutPreRender() {
    $this
      ->setUpRequest();
    $this
      ->setupMemoryCache();
    $this->cacheContextsManager
      ->expects($this
      ->any())
      ->method('convertTokensToKeys')
      ->willReturnArgument(0);

    // Create an element with a child and subchild. Each element loads a
    // different library using #attached.
    $element = [
      '#type' => 'container',
      '#cache' => [
        'keys' => [
          'simpletest',
          'drupal_render',
          'children_attached',
        ],
      ],
      '#attached' => [
        'library' => [
          'test/parent',
        ],
      ],
      '#title' => 'Parent',
    ];
    $element['child'] = [
      '#type' => 'container',
      '#attached' => [
        'library' => [
          'test/child',
        ],
      ],
      '#title' => 'Child',
    ];
    $element['child']['subchild'] = [
      '#attached' => [
        'library' => [
          'test/subchild',
        ],
      ],
      '#markup' => 'Subchild',
    ];

    // Render the element and verify the presence of #attached JavaScript.
    $this->renderer
      ->renderRoot($element);
    $expected_libraries = [
      'test/parent',
      'test/child',
      'test/subchild',
    ];
    $this
      ->assertEquals($element['#attached']['library'], $expected_libraries, 'The element, child and subchild #attached libraries are included.');

    // Load the element from cache and verify the presence of the #attached
    // JavaScript.
    $element = [
      '#cache' => [
        'keys' => [
          'simpletest',
          'drupal_render',
          'children_attached',
        ],
      ],
    ];
    $this
      ->assertTrue(strlen($this->renderer
      ->renderRoot($element)) > 0, 'The element was retrieved from cache.');
    $this
      ->assertEquals($element['#attached']['library'], $expected_libraries, 'The element, child and subchild #attached libraries are included.');
  }

  /**
   * Tests cache context bubbling with a custom cache bin.
   */
  public function testContextBubblingCustomCacheBin() {
    $bin = $this
      ->randomMachineName();
    $this
      ->setUpRequest();
    $this->memoryCache = new MemoryBackend('render');
    $custom_cache = new MemoryBackend($bin);
    $this->cacheFactory
      ->expects($this
      ->atLeastOnce())
      ->method('get')
      ->with($bin)
      ->willReturnCallback(function ($requested_bin) use ($bin, $custom_cache) {
      if ($requested_bin === $bin) {
        return $custom_cache;
      }
      else {
        throw new \Exception();
      }
    });
    $this->cacheContextsManager
      ->expects($this
      ->any())
      ->method('convertTokensToKeys')
      ->willReturnArgument(0);
    $build = [
      '#cache' => [
        'keys' => [
          'parent',
        ],
        'contexts' => [
          'foo',
        ],
        'bin' => $bin,
      ],
      '#markup' => 'parent',
      'child' => [
        '#cache' => [
          'contexts' => [
            'bar',
          ],
          'max-age' => 3600,
        ],
      ],
    ];
    $this->renderer
      ->renderRoot($build);
    $this
      ->assertRenderCacheItem('parent:foo', [
      '#cache_redirect' => TRUE,
      '#cache' => [
        'keys' => [
          'parent',
        ],
        'contexts' => [
          'bar',
          'foo',
        ],
        'tags' => [],
        'bin' => $bin,
        'max-age' => 3600,
      ],
    ], $bin);
  }

  /**
   * Tests cache context bubbling in edge cases, because it affects the CID.
   *
   * ::testBubblingWithPrerender() already tests the common case.
   *
   * @dataProvider providerTestContextBubblingEdgeCases
   */
  public function testContextBubblingEdgeCases(array $element, array $expected_top_level_contexts, array $expected_cache_items) {
    $this
      ->setUpRequest();
    $this
      ->setupMemoryCache();
    $this->cacheContextsManager
      ->expects($this
      ->any())
      ->method('convertTokensToKeys')
      ->willReturnArgument(0);
    $this->renderer
      ->renderRoot($element);
    $this
      ->assertEquals($expected_top_level_contexts, $element['#cache']['contexts'], 'Expected cache contexts found.');
    foreach ($expected_cache_items as $cid => $expected_cache_item) {
      $this
        ->assertRenderCacheItem($cid, $expected_cache_item);
    }
  }
  public function providerTestContextBubblingEdgeCases() {
    $data = [];

    // Cache contexts of inaccessible children aren't bubbled (because those
    // children are not rendered at all).
    $test_element = [
      '#cache' => [
        'keys' => [
          'parent',
        ],
        'contexts' => [],
      ],
      '#markup' => 'parent',
      'child' => [
        '#access' => FALSE,
        '#cache' => [
          'contexts' => [
            'foo',
          ],
        ],
      ],
    ];
    $expected_cache_items = [
      'parent' => [
        '#attached' => [],
        '#cache' => [
          'contexts' => [],
          'tags' => [],
          'max-age' => Cache::PERMANENT,
        ],
        '#markup' => 'parent',
      ],
    ];
    $data[] = [
      $test_element,
      [],
      $expected_cache_items,
    ];

    // Assert cache contexts are sorted when they are used to generate a CID.
    // (Necessary to ensure that different render arrays where the same keys +
    // set of contexts are present point to the same cache item. Regardless of
    // the contexts' order. A sad necessity because PHP doesn't have sets.)
    $test_element = [
      '#cache' => [
        'keys' => [
          'set_test',
        ],
        'contexts' => [],
      ],
    ];
    $expected_cache_items = [
      'set_test:bar:baz:foo' => [
        '#attached' => [],
        '#cache' => [
          'contexts' => [],
          'tags' => [],
          'max-age' => Cache::PERMANENT,
        ],
        '#markup' => '',
      ],
    ];
    $context_orders = [
      [
        'foo',
        'bar',
        'baz',
      ],
      [
        'foo',
        'baz',
        'bar',
      ],
      [
        'bar',
        'foo',
        'baz',
      ],
      [
        'bar',
        'baz',
        'foo',
      ],
      [
        'baz',
        'foo',
        'bar',
      ],
      [
        'baz',
        'bar',
        'foo',
      ],
    ];
    foreach ($context_orders as $context_order) {
      $test_element['#cache']['contexts'] = $context_order;
      sort($context_order);
      $expected_cache_items['set_test:bar:baz:foo']['#cache']['contexts'] = $context_order;
      $data[] = [
        $test_element,
        $context_order,
        $expected_cache_items,
      ];
    }

    // A parent with a certain set of cache contexts is unaffected by a child
    // that has a subset of those contexts.
    $test_element = [
      '#cache' => [
        'keys' => [
          'parent',
        ],
        'contexts' => [
          'foo',
          'bar',
          'baz',
        ],
      ],
      '#markup' => 'parent',
      'child' => [
        '#cache' => [
          'contexts' => [
            'foo',
            'baz',
          ],
          'max-age' => 3600,
        ],
      ],
    ];
    $expected_cache_items = [
      'parent:bar:baz:foo' => [
        '#attached' => [],
        '#cache' => [
          'contexts' => [
            'bar',
            'baz',
            'foo',
          ],
          'tags' => [],
          'max-age' => 3600,
        ],
        '#markup' => 'parent',
      ],
    ];
    $data[] = [
      $test_element,
      [
        'bar',
        'baz',
        'foo',
      ],
      $expected_cache_items,
    ];

    // A parent with a certain set of cache contexts that is a subset of the
    // cache contexts of a child gets a redirecting cache item for the cache ID
    // created pre-bubbling (without the child's additional cache contexts). It
    // points to a cache item with a post-bubbling cache ID (i.e. with the
    // child's additional cache contexts).
    // Furthermore, the redirecting cache item also includes the children's
    // cache tags, since changes in the children may cause those children to get
    // different cache contexts and therefore cause different cache contexts to
    // be stored in the redirecting cache item.
    $test_element = [
      '#cache' => [
        'keys' => [
          'parent',
        ],
        'contexts' => [
          'foo',
        ],
        'tags' => [
          'yar',
          'har',
        ],
      ],
      '#markup' => 'parent',
      'child' => [
        '#cache' => [
          'contexts' => [
            'bar',
          ],
          'tags' => [
            'fiddle',
            'dee',
          ],
        ],
        '#markup' => '',
      ],
    ];
    $expected_cache_items = [
      'parent:foo' => [
        '#cache_redirect' => TRUE,
        '#cache' => [
          // The keys + contexts this redirects to.
          'keys' => [
            'parent',
          ],
          'contexts' => [
            'bar',
            'foo',
          ],
          'tags' => [
            'dee',
            'fiddle',
            'har',
            'yar',
          ],
          'bin' => 'render',
          'max-age' => Cache::PERMANENT,
        ],
      ],
      'parent:bar:foo' => [
        '#attached' => [],
        '#cache' => [
          'contexts' => [
            'bar',
            'foo',
          ],
          'tags' => [
            'dee',
            'fiddle',
            'har',
            'yar',
          ],
          'max-age' => Cache::PERMANENT,
        ],
        '#markup' => 'parent',
      ],
    ];
    $data[] = [
      $test_element,
      [
        'bar',
        'foo',
      ],
      $expected_cache_items,
    ];
    return $data;
  }

  /**
   * Tests the self-healing of the redirect with conditional cache contexts.
   */
  public function testConditionalCacheContextBubblingSelfHealing() {
    $current_user_role =& $this->currentUserRole;
    $this
      ->setUpRequest();
    $this
      ->setupMemoryCache();
    $test_element = [
      '#cache' => [
        'keys' => [
          'parent',
        ],
        'tags' => [
          'a',
        ],
      ],
      '#markup' => 'parent',
      'child' => [
        '#cache' => [
          'contexts' => [
            'user.roles',
          ],
          'tags' => [
            'b',
          ],
        ],
        'grandchild' => [
          '#access_callback' => function () use (&$current_user_role) {

            // Only role A cannot access this subtree.
            return $current_user_role !== 'A';
          },
          '#cache' => [
            'contexts' => [
              'foo',
            ],
            'tags' => [
              'c',
            ],
            // A lower max-age; the redirecting cache item should be updated.
            'max-age' => 1800,
          ],
          'grandgrandchild' => [
            '#access_callback' => function () use (&$current_user_role) {

              // Only role C can access this subtree.
              return $current_user_role === 'C';
            },
            '#cache' => [
              'contexts' => [
                'bar',
              ],
              'tags' => [
                'd',
              ],
              // A lower max-age; the redirecting cache item should be updated.
              'max-age' => 300,
            ],
          ],
        ],
      ],
    ];

    // Request 1: role A, the grandchild isn't accessible => bubbled cache
    // contexts: user.roles.
    $element = $test_element;
    $current_user_role = 'A';
    $this->renderer
      ->renderRoot($element);
    $this
      ->assertRenderCacheItem('parent', [
      '#cache_redirect' => TRUE,
      '#cache' => [
        'keys' => [
          'parent',
        ],
        'contexts' => [
          'user.roles',
        ],
        'tags' => [
          'a',
          'b',
        ],
        'bin' => 'render',
        'max-age' => Cache::PERMANENT,
      ],
    ]);
    $this
      ->assertRenderCacheItem('parent:r.A', [
      '#attached' => [],
      '#cache' => [
        'contexts' => [
          'user.roles',
        ],
        'tags' => [
          'a',
          'b',
        ],
        'max-age' => Cache::PERMANENT,
      ],
      '#markup' => 'parent',
    ]);

    // Request 2: role B, the grandchild is accessible => bubbled cache
    // contexts: foo, user.roles + merged max-age: 1800.
    $element = $test_element;
    $current_user_role = 'B';
    $this->renderer
      ->renderRoot($element);
    $this
      ->assertRenderCacheItem('parent', [
      '#cache_redirect' => TRUE,
      '#cache' => [
        'keys' => [
          'parent',
        ],
        'contexts' => [
          'foo',
          'user.roles',
        ],
        'tags' => [
          'a',
          'b',
          'c',
        ],
        'bin' => 'render',
        'max-age' => 1800,
      ],
    ]);
    $this
      ->assertRenderCacheItem('parent:foo:r.B', [
      '#attached' => [],
      '#cache' => [
        'contexts' => [
          'foo',
          'user.roles',
        ],
        'tags' => [
          'a',
          'b',
          'c',
        ],
        'max-age' => 1800,
      ],
      '#markup' => 'parent',
    ]);

    // Request 3: role A again, the grandchild is inaccessible again => bubbled
    // cache contexts: user.roles; but that's a subset of the already-bubbled
    // cache contexts, so nothing is actually changed in the redirecting cache
    // item. However, the cache item we were looking for in request 1 is
    // technically the same one we're looking for now (it's the exact same
    // request), but with one additional cache context. This is necessary to
    // avoid "cache ping-pong". (Requests 1 and 3 are identical, but without the
    // right merging logic to handle request 2, the redirecting cache item would
    // toggle between only the 'user.roles' cache context and both the 'foo'
    // and 'user.roles' cache contexts, resulting in a cache miss every time.)
    $element = $test_element;
    $current_user_role = 'A';
    $this->renderer
      ->renderRoot($element);
    $this
      ->assertRenderCacheItem('parent', [
      '#cache_redirect' => TRUE,
      '#cache' => [
        'keys' => [
          'parent',
        ],
        'contexts' => [
          'foo',
          'user.roles',
        ],
        'tags' => [
          'a',
          'b',
          'c',
        ],
        'bin' => 'render',
        'max-age' => 1800,
      ],
    ]);
    $this
      ->assertRenderCacheItem('parent:foo:r.A', [
      '#attached' => [],
      '#cache' => [
        'contexts' => [
          'foo',
          'user.roles',
        ],
        'tags' => [
          'a',
          'b',
        ],
        // Note that the max-age here is unaffected. When role A, the grandchild
        // is never rendered, so neither is its max-age of 1800 present here,
        // despite 1800 being the max-age of the redirecting cache item.
        'max-age' => Cache::PERMANENT,
      ],
      '#markup' => 'parent',
    ]);

    // Request 4: role C, both the grandchild and the grandgrandchild are
    // accessible => bubbled cache contexts: foo, bar, user.roles + merged
    // max-age: 300.
    $element = $test_element;
    $current_user_role = 'C';
    $this->renderer
      ->renderRoot($element);
    $final_parent_cache_item = [
      '#cache_redirect' => TRUE,
      '#cache' => [
        'keys' => [
          'parent',
        ],
        'contexts' => [
          'bar',
          'foo',
          'user.roles',
        ],
        'tags' => [
          'a',
          'b',
          'c',
          'd',
        ],
        'bin' => 'render',
        'max-age' => 300,
      ],
    ];
    $this
      ->assertRenderCacheItem('parent', $final_parent_cache_item);
    $this
      ->assertRenderCacheItem('parent:bar:foo:r.C', [
      '#attached' => [],
      '#cache' => [
        'contexts' => [
          'bar',
          'foo',
          'user.roles',
        ],
        'tags' => [
          'a',
          'b',
          'c',
          'd',
        ],
        'max-age' => 300,
      ],
      '#markup' => 'parent',
    ]);

    // Request 5: role A again, verifying the merging like we did for request 3.
    $element = $test_element;
    $current_user_role = 'A';
    $this->renderer
      ->renderRoot($element);
    $this
      ->assertRenderCacheItem('parent', $final_parent_cache_item);
    $this
      ->assertRenderCacheItem('parent:bar:foo:r.A', [
      '#attached' => [],
      '#cache' => [
        'contexts' => [
          'bar',
          'foo',
          'user.roles',
        ],
        'tags' => [
          'a',
          'b',
        ],
        // Note that the max-age here is unaffected. When role A, the grandchild
        // is never rendered, so neither is its max-age of 1800 present here,
        // nor the grandgrandchild's max-age of 300, despite 300 being the
        // max-age of the redirecting cache item.
        'max-age' => Cache::PERMANENT,
      ],
      '#markup' => 'parent',
    ]);

    // Request 6: role B again, verifying the merging like we did for request 3.
    $element = $test_element;
    $current_user_role = 'B';
    $this->renderer
      ->renderRoot($element);
    $this
      ->assertRenderCacheItem('parent', $final_parent_cache_item);
    $this
      ->assertRenderCacheItem('parent:bar:foo:r.B', [
      '#attached' => [],
      '#cache' => [
        'contexts' => [
          'bar',
          'foo',
          'user.roles',
        ],
        'tags' => [
          'a',
          'b',
          'c',
        ],
        // Note that the max-age here is unaffected. When role B, the
        // grandgrandchild is never rendered, so neither is its max-age of 300
        // present here, despite 300 being the max-age of the redirecting cache
        // item.
        'max-age' => 1800,
      ],
      '#markup' => 'parent',
    ]);
  }

  /**
   * Tests bubbling of bubbleable metadata added by #pre_render callbacks.
   *
   * @dataProvider providerTestBubblingWithPrerender
   */
  public function testBubblingWithPrerender($test_element) {
    $this
      ->setUpRequest();
    $this
      ->setupMemoryCache();

    // Mock the State service.
    $memory_state = new State(new KeyValueMemoryFactory());
    \Drupal::getContainer()
      ->set('state', $memory_state);
    $this->controllerResolver
      ->expects($this
      ->any())
      ->method('getControllerFromDefinition')
      ->willReturnArgument(0);

    // Simulate the theme system/Twig: a recursive call to Renderer::render(),
    // just like the theme system or a Twig template would have done.
    $this->themeManager
      ->expects($this
      ->any())
      ->method('render')
      ->willReturnCallback(function ($hook, $vars) {
      return $this->renderer
        ->render($vars['foo']);
    });

    // ::bubblingPreRender() verifies that a #pre_render callback for a render
    // array that is cacheable and …
    // - … is cached does NOT get called. (Also mock a render cache item.)
    // - … is not cached DOES get called.
    \Drupal::state()
      ->set('bubbling_nested_pre_render_cached', FALSE);
    \Drupal::state()
      ->set('bubbling_nested_pre_render_uncached', FALSE);
    $this->memoryCache
      ->set('cached_nested', [
      '#markup' => 'Cached nested!',
      '#attached' => [],
      '#cache' => [
        'contexts' => [],
        'tags' => [],
      ],
    ]);

    // Simulate the rendering of an entire response (i.e. a root call).
    $output = $this->renderer
      ->renderRoot($test_element);

    // First, assert the render array is of the expected form.
    $this
      ->assertEquals('Cache context!Cache tag!Asset!Placeholder!barquxNested!Cached nested!', trim($output), 'Expected HTML generated.');
    $this
      ->assertEquals([
      'child.cache_context',
    ], $test_element['#cache']['contexts'], 'Expected cache contexts found.');
    $this
      ->assertEquals([
      'child:cache_tag',
    ], $test_element['#cache']['tags'], 'Expected cache tags found.');
    $expected_attached = [
      'drupalSettings' => [
        'foo' => 'bar',
      ],
      'placeholders' => [],
    ];
    $this
      ->assertEquals($expected_attached, $test_element['#attached'], 'Expected attachments found.');

    // Second, assert that #pre_render callbacks are only executed if they don't
    // have a render cache hit (and hence a #pre_render callback for a render
    // cached item cannot bubble more metadata).
    $this
      ->assertTrue(\Drupal::state()
      ->get('bubbling_nested_pre_render_uncached'));
    $this
      ->assertFalse(\Drupal::state()
      ->get('bubbling_nested_pre_render_cached'));
  }

  /**
   * Provides two test elements: one without, and one with the theme system.
   *
   * @return array
   */
  public function providerTestBubblingWithPrerender() {
    $data = [];

    // Test element without theme.
    $data[] = [
      [
        'foo' => [
          '#pre_render' => [
            __NAMESPACE__ . '\\BubblingTest::bubblingPreRender',
          ],
        ],
      ],
    ];

    // Test element with theme.
    $data[] = [
      [
        '#theme' => 'common_test_render_element',
        'foo' => [
          '#pre_render' => [
            __NAMESPACE__ . '\\BubblingTest::bubblingPreRender',
          ],
        ],
      ],
    ];
    return $data;
  }

  /**
   * Tests that an element's cache keys cannot be changed during its rendering.
   *
   * @expectedException \LogicException
   * @expectedExceptionMessage Cache keys may not be changed after initial setup. Use the contexts property instead to bubble additional metadata.
   */
  public function testOverWriteCacheKeys() {
    $this
      ->setUpRequest();
    $this
      ->setupMemoryCache();

    // Ensure a logic exception
    $data = [
      '#cache' => [
        'keys' => [
          'llama',
          'bar',
        ],
      ],
      '#pre_render' => [
        __NAMESPACE__ . '\\BubblingTest::bubblingCacheOverwritePrerender',
      ],
    ];
    $this->renderer
      ->renderRoot($data);
  }

}

Members

Namesort descending Modifiers Type Description Overrides
RendererBubblingTest::providerTestBubblingWithPrerender public function Provides two test elements: one without, and one with the theme system.
RendererBubblingTest::providerTestContextBubblingEdgeCases public function
RendererBubblingTest::setUp protected function Overrides RendererTestBase::setUp
RendererBubblingTest::testBubblingWithoutPreRender public function Tests bubbling of assets when NOT using #pre_render callbacks.
RendererBubblingTest::testBubblingWithPrerender public function Tests bubbling of bubbleable metadata added by #pre_render callbacks.
RendererBubblingTest::testConditionalCacheContextBubblingSelfHealing public function Tests the self-healing of the redirect with conditional cache contexts.
RendererBubblingTest::testContextBubblingCustomCacheBin public function Tests cache context bubbling with a custom cache bin.
RendererBubblingTest::testContextBubblingEdgeCases public function Tests cache context bubbling in edge cases, because it affects the CID.
RendererBubblingTest::testOverWriteCacheKeys public function Tests that an element's cache keys cannot be changed during its rendering.
RendererTestBase::$cacheContexts protected property
RendererTestBase::$cacheFactory protected property
RendererTestBase::$controllerResolver protected property The mocked controller resolver.
RendererTestBase::$currentUserRole protected property The simulated "current" user role, for use in tests with cache contexts.
RendererTestBase::$elementInfo protected property The mocked element info.
RendererTestBase::$memoryCache protected property
RendererTestBase::$placeholderGenerator protected property The tested placeholder generator. 1
RendererTestBase::$renderCache protected property The tested render cache.
RendererTestBase::$renderer protected property The tested renderer.
RendererTestBase::$rendererConfig protected property The mocked renderer configuration.
RendererTestBase::$requestStack protected property
RendererTestBase::$themeManager protected property The mocked theme manager.
RendererTestBase::assertRenderCacheItem protected function Asserts a render cache item.
RendererTestBase::randomContextValue protected function Generates a random context value for the placeholder tests.
RendererTestBase::setupMemoryCache protected function Sets up a memory-based render cache back-end.
RendererTestBase::setUpRequest protected function Sets up a request object on the request stack.
RendererTestBase::setUpUnusedCache protected function Sets up a render cache back-end that is asserted to be never used.
UnitTestCase::$randomGenerator protected property The random generator.
UnitTestCase::$root protected property The app root.
UnitTestCase::assertArrayEquals protected function Asserts if two arrays are equal by sorting them first.
UnitTestCase::getBlockMockWithMachineName protected function Mocks a block with a block plugin.
UnitTestCase::getClassResolverStub protected function Returns a stub class resolver.
UnitTestCase::getConfigFactoryStub public function Returns a stub config factory that behaves according to the passed in array.
UnitTestCase::getConfigStorageStub public function Returns a stub config storage that returns the supplied configuration.
UnitTestCase::getContainerWithCacheTagsInvalidator protected function Sets up a container with a cache tags invalidator.
UnitTestCase::getRandomGenerator protected function Gets the random generator for the utility methods.
UnitTestCase::getStringTranslationStub public function Returns a stub translation manager that just returns the passed string.
UnitTestCase::randomMachineName public function Generates a unique random string containing letters and numbers.