core/lib/Drupal/Core/Render/Renderer.php | 64 +++- .../Tests/Core/Render/BubbleableMetadataTest.php | 10 +- .../Tests/Core/Render/RendererBubblingTest.php | 323 ++++++++++++++++++++- .../Drupal/Tests/Core/Render/RendererTestBase.php | 16 + 4 files changed, 399 insertions(+), 14 deletions(-) diff --git a/core/lib/Drupal/Core/Render/Renderer.php b/core/lib/Drupal/Core/Render/Renderer.php index 91ed877..3521cb5 100644 --- a/core/lib/Drupal/Core/Render/Renderer.php +++ b/core/lib/Drupal/Core/Render/Renderer.php @@ -548,20 +548,67 @@ protected function cacheSet(array &$elements) { $bin = isset($elements['#cache']['bin']) ? $elements['#cache']['bin'] : 'render'; $expire = isset($elements['#cache']['expire']) ? $elements['#cache']['expire'] : Cache::PERMANENT; - $tags = $data['#cache']['tags']; $cache = $this->cacheFactory->get($bin); - // Two-tier caching: detect different CID post-bubbling, create redirect. + // Two-tier caching: detect different CID post-bubbling, create redirect, + // update redirect if different set of cache contexts. // @see ::doRender() // @see ::cacheGet() if (isset($elements['#cid_pre_bubbling']) && $elements['#cid_pre_bubbling'] !== $cid) { - $redirect_data = [ - '#cache_redirect' => TRUE, - '#cache' => $elements['#cache'], - ]; - $cache->set($elements['#cid_pre_bubbling'], $redirect_data, $expire, $tags); + // The set of cache contexts for this element, including the bubbled ones, + // for which we are handling a cache miss. + $cache_contexts = $data['#cache']['contexts']; + + // Get the contexts by which this element should be varied according to + // the current redirecting cache item, if any. + $stored_cache_contexts = []; + $stored_cache_tags = []; + if ($stored_cache_redirect = $cache->get($elements['#cid_pre_bubbling'])) { + $stored_cache_contexts = $stored_cache_redirect->data['#cache']['contexts']; + $stored_cache_tags = $stored_cache_redirect->data['#cache']['tags']; + } + + // Calculate the union of the cache contexts for this request and the + // stored cache contexts. + $merged_cache_contexts = Cache::mergeContexts($stored_cache_contexts, $cache_contexts); + + // Stored cache contexts incomplete: this request causes cache contexts to + // be added to the redirecting cache item. + if (count(array_diff($merged_cache_contexts, $stored_cache_contexts)) > 0) { + $redirect_data = [ + '#cache_redirect' => TRUE, + '#cache' => [ + // The cache keys of the current element; this remains the same + // across requests. + 'keys' => $elements['#cache']['keys'], + // The union of the current element's and stored cache contexts. + 'contexts' => $merged_cache_contexts, + // The union of the current element's and stored cache tags. + 'tags' => Cache::mergeTags($stored_cache_tags, $data['#cache']['tags']), + ], + ]; + $cache->set($elements['#cid_pre_bubbling'], $redirect_data, $expire, $redirect_data['#cache']['tags']); + } + + // Current cache contexts incomplete: this request only uses a subset of + // the cache contexts stored in the redirecting cache item. Vary by these + // additional (conditional) cache contexts as well, otherwise the + // redirecting cache item would be pointing to a cache item that can never + // exist. + if (count(array_diff($merged_cache_contexts, $cache_contexts)) > 0) { + // Recalculate the cache ID. + $recalculated_cid_pseudo_element = [ + '#cache' => [ + 'keys' => $elements['#cache']['keys'], + 'contexts' => $merged_cache_contexts, + ] + ]; + $cid = $this->createCacheID($recalculated_cid_pseudo_element); + // Ensure the about-to-be-cached data uses the merged cache contexts. + $data['#cache']['contexts'] = $merged_cache_contexts; + } } - $cache->set($cid, $data, $expire, $tags); + $cache->set($cid, $data, $expire, $data['#cache']['tags']); } /** @@ -584,6 +631,7 @@ protected function createCacheID(array $elements) { elseif (isset($elements['#cache']['keys'])) { $cid_parts = $elements['#cache']['keys']; if (isset($elements['#cache']['contexts'])) { + sort($elements['#cache']['contexts']); $contexts = $this->cacheContexts->convertTokensToKeys($elements['#cache']['contexts']); $cid_parts = array_merge($cid_parts, $contexts); } diff --git a/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php b/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php index 23168d3..88999e9 100644 --- a/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php +++ b/core/tests/Drupal/Tests/Core/Render/BubbleableMetadataTest.php @@ -35,13 +35,13 @@ public function providerTestApplyTo() { $data = []; $empty_metadata = new BubbleableMetadata(); - $nonempty_metadata = new BubbleableMetadata(['foo:bar'], [], ['settings' => ['foo' => 'bar']]); + $nonempty_metadata = new BubbleableMetadata(['foo:bar'], ['qux'], ['settings' => ['foo' => 'bar']]); $empty_render_array = []; $nonempty_render_array = [ '#cache' => [ 'tags' => ['llamas:are:awesome:but:kittens:too'], - 'contexts' => [], + 'contexts' => ['qux'], ], '#attached' => [ 'library' => [ @@ -65,7 +65,7 @@ public function providerTestApplyTo() { $expected_when_nonempty_metadata = [ '#cache' => [ 'tags' => ['foo:bar'], - 'contexts' => [], + 'contexts' => ['qux'], ], '#attached' => [ 'settings' => [ @@ -97,13 +97,13 @@ public function providerTestCreateFromRenderArray() { $data = []; $empty_metadata = new BubbleableMetadata(); - $nonempty_metadata = new BubbleableMetadata(['foo:bar'], [], ['settings' => ['foo' => 'bar']]); + $nonempty_metadata = new BubbleableMetadata(['foo:bar'], ['qux'], ['settings' => ['foo' => 'bar']]); $empty_render_array = []; $nonempty_render_array = [ '#cache' => [ 'tags' => ['foo:bar'], - 'contexts' => [], + 'contexts' => ['qux'], ], '#attached' => [ 'settings' => [ diff --git a/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php b/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php index b3fb803..3b9f82d 100644 --- a/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php +++ b/core/tests/Drupal/Tests/Core/Render/RendererBubblingTest.php @@ -71,6 +71,320 @@ public function testBubblingWithoutPreRender() { } /** + * 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->cacheContexts->expects($this->any()) + ->method('convertTokensToKeys') + ->willReturnArgument(0); + + $this->renderer->render($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 = []; + + // Bubbled cache contexts cannot override a cache ID set by #cache['cid']. + // But the cache context is bubbled nevertheless. + $test_element = [ + '#cache' => [ + 'cid' => 'parent', + ], + '#markup' => 'parent', + 'child' => [ + '#cache' => [ + 'contexts' => ['foo'], + ], + ], + ]; + $expected_cache_items = [ + 'parent' => [ + '#attached' => [], + '#cache' => [ + 'contexts' => ['foo'], + 'tags' => ['rendered'], + ], + '#post_render_cache' => [], + '#markup' => 'parent', + ], + ]; + $data[] = [$test_element, ['foo'], $expected_cache_items]; + + // 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' => ['rendered'], + ], + '#post_render_cache' => [], + '#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' => ['rendered'], + ], + '#post_render_cache' => [], + '#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; + $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'], + ], + ], + ]; + $expected_cache_items = [ + 'parent:bar:baz:foo' => [ + '#attached' => [], + '#cache' => [ + 'contexts' => ['foo', 'bar', 'baz'], + 'tags' => ['rendered'], + ], + '#post_render_cache' => [], + '#markup' => 'parent', + ], + ]; + $data[] = [$test_element, ['foo', 'bar', 'baz'], $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'], + // The 'rendered' cache tag is also present for the redirecting cache + // item, to ensure it is considered to be part of the render cache + // and thus invalidated along with everything else. + 'tags' => ['dee', 'fiddle', 'har', 'rendered', 'yar'], + ], + ], + 'parent:bar:foo' => [ + '#attached' => [], + '#cache' => [ + 'contexts' => ['foo', 'bar'], + 'tags' => ['dee', 'fiddle', 'har', 'yar', 'rendered'], + ], + '#post_render_cache' => [], + '#markup' => 'parent', + ], + ]; + $data[] = [$test_element, ['foo', 'bar'], $expected_cache_items]; + + return $data; + } + + /** + * Tests the self-healing of the redirect with conditional cache contexts. + */ + public function testConditionalCacheContextBubblingSelfHealing() { + global $foo_cache_context; + $this->setUpRequest(); + $this->setupMemoryCache(); + $this->cacheContexts->expects($this->any()) + ->method('convertTokensToKeys') + ->willReturnCallback(function($context_tokens) use ($foo_cache_context) { + global $foo_cache_context; + $keys = []; + foreach ($context_tokens as $context_id) { + if ($context_id === 'foo') { + $keys[] = $context_id . '.' . ($foo_cache_context ? '1' : '0'); + } + else { + $keys[] = $context_id; + } + } + return $keys; + }); + + $test_element = [ + '#cache' => [ + 'keys' => ['parent'], + 'tags' => ['a'], + ], + '#markup' => 'parent', + 'child' => [ + '#cache' => [ + 'contexts' => ['foo'], + 'tags' => ['b'], + ], + 'grandchild' => [ + '#cache' => [ + 'contexts' => ['bar'], + 'tags' => ['c'], + ], + ], + ], + ]; + + // Request 1: grandchild is inaccessible => bubbled cache contexts: foo. + $element = $test_element; + $foo_cache_context = FALSE; + $element['child']['grandchild']['#access'] = FALSE; + $this->renderer->render($element); + $this->assertRenderCacheItem('parent', [ + '#cache_redirect' => TRUE, + '#cache' => [ + 'keys' => ['parent'], + 'contexts' => ['foo'], + 'tags' => ['a', 'b', 'rendered'], + ], + ]); + $this->assertRenderCacheItem('parent:foo.0', [ + '#attached' => [], + '#cache' => [ + 'contexts' => ['foo'], + 'tags' => ['a', 'b', 'rendered'], + ], + '#post_render_cache' => [], + '#markup' => 'parent', + ]); + + // Request 2: grandchild is accessible => bubbled cache contexts: foo, bar. + $element = $test_element; + $foo_cache_context = TRUE; + $this->renderer->render($element); + $this->assertRenderCacheItem('parent', [ + '#cache_redirect' => TRUE, + '#cache' => [ + 'keys' => ['parent'], + 'contexts' => ['bar', 'foo'], + 'tags' => ['a', 'b', 'c', 'rendered'], + ], + ]); + $this->assertRenderCacheItem('parent:bar:foo.1', [ + '#attached' => [], + '#cache' => [ + 'contexts' => ['foo', 'bar'], + 'tags' => ['a', 'b', 'c', 'rendered'], + ], + '#post_render_cache' => [], + '#markup' => 'parent', + ]); + + // Request 3: grandchild is inaccessible again => bubbled cache contexts: + // foo; 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 'foo' cache context and only the 'bar' cache context, resulting in + // cache miss every time.) + $element = $test_element; + $foo_cache_context = FALSE; + $element['child']['grandchild']['#access'] = FALSE; + $this->renderer->render($element); + $this->assertRenderCacheItem('parent', [ + '#cache_redirect' => TRUE, + '#cache' => [ + 'keys' => ['parent'], + 'contexts' => ['bar', 'foo'], + 'tags' => ['a', 'b', 'c', 'rendered'], + ], + ]); + $this->assertRenderCacheItem('parent:bar:foo.0', [ + '#attached' => [], + '#cache' => [ + 'contexts' => ['bar', 'foo'], + 'tags' => ['a', 'b', 'rendered'], + ], + '#post_render_cache' => [], + '#markup' => 'parent', + ]); + } + + /** * Tests bubbling of bubbleable metadata added by #pre_render callbacks. * * @dataProvider providerTestBubblingWithPrerender @@ -103,7 +417,8 @@ public function testBubblingWithPrerender($test_element) { $output = $this->renderer->renderRoot($test_element); // First, assert the render array is of the expected form. - $this->assertEquals('Cache tag!Asset!Post-render cache!barquxNested!Cached nested!', trim($output), 'Expected HTML generated.'); + $this->assertEquals('Cache context!Cache tag!Asset!Post-render cache!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'], @@ -158,6 +473,12 @@ public static function bubblingPreRender($elements) { ]; $placeholder = \Drupal::service('renderer')->generateCachePlaceholder($callback, $context); $elements += [ + 'child_cache_context' => [ + '#cache' => [ + 'contexts' => ['child.cache_context'], + ], + '#markup' => 'Cache context!', + ], 'child_cache_tag' => [ '#cache' => [ 'tags' => ['child:cache_tag'], diff --git a/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php b/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php index f4a051a..cf08554 100644 --- a/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php +++ b/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php @@ -139,6 +139,22 @@ protected function setUpRequest($method = 'GET') { $this->requestStack->push($request); } + /** + * Asserts a render cache item. + * + * @param string $cid + * The expected cache ID. + * @param mixed $data + * The expected data for that cache ID. + */ + protected function assertRenderCacheItem($cid, $data) { + $cached = $this->memoryCache->get($cid); + $this->assertNotFalse($cached, sprintf('Expected cache item "%s" exists.', $cid)); + if ($cached !== FALSE) { + $this->assertEquals($data, $cached->data, sprintf('Cache item "%s" has the expected data.', $cid)); + } + } + }