core/core.services.yml | 42 +++- .../ContentControllerSubscriber.php | 2 +- .../Core/EventSubscriber/SmartCacheSubscriber.php | 135 +++++++++++++ core/lib/Drupal/Core/Form/FormBuilder.php | 5 + .../PageCache/ResponsePolicy/NoAdminRoutes.php | 48 +++++ .../ProxyClass/SmartCache/DefaultRequestPolicy.php | 92 +++++++++ .../Core/Render/MainContent/HtmlRenderer.php | 19 ++ .../Render/MainContent/SmartCacheHtmlRenderer.php | 225 +++++++++++++++++++++ .../Core/SmartCache/DefaultRequestPolicy.php | 31 +++ .../src/Plugin/Block/TestAccessBlock.php | 2 +- .../system/src/EventSubscriber/ConfigCacheTag.php | 4 +- .../src/Tests/Cache/SmartCacheIntegrationTest.php | 128 ++++++++++++ .../paramconverter_test/src/TestControllers.php | 4 +- .../smart_cache_test/smart_cache_test.info.yml | 6 + .../smart_cache_test/smart_cache_test.routing.yml | 45 +++++ .../src/SmartCacheTestController.php | 51 +++++ 16 files changed, 832 insertions(+), 7 deletions(-) diff --git a/core/core.services.yml b/core/core.services.yml index ba67e2e..683f2bd 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -206,17 +206,26 @@ services: class: Drupal\Core\PageCache\ResponsePolicy\KillSwitch tags: - { name: page_cache_response_policy } + - { name: smart_cache_response_policy } page_cache_no_cache_routes: class: Drupal\Core\PageCache\ResponsePolicy\DenyNoCacheRoutes arguments: ['@current_route_match'] public: false tags: - { name: page_cache_response_policy } + - { name: smart_cache_response_policy } page_cache_no_server_error: class: Drupal\Core\PageCache\ResponsePolicy\NoServerError public: false tags: - { name: page_cache_response_policy } + - { name: smart_cache_response_policy } + smart_cache_no_admin_routes: + class: Drupal\Core\PageCache\ResponsePolicy\NoAdminRoutes + arguments: ['@current_route_match'] + public: false + tags: + - { name: smart_cache_response_policy } config.manager: class: Drupal\Core\Config\ConfigManager arguments: ['@entity.manager', '@config.factory', '@config.typed', '@string_translation', '@config.storage', '@event_dispatcher'] @@ -875,8 +884,8 @@ services: tags: - { name: event_subscriber } main_content_renderer.html: - class: Drupal\Core\Render\MainContent\HtmlRenderer - arguments: ['@title_resolver', '@plugin.manager.display_variant', '@event_dispatcher', '@module_handler', '@renderer', '@render_cache'] + class: Drupal\Core\Render\MainContent\SmartCacheHtmlRenderer + arguments: ['@title_resolver', '@plugin.manager.display_variant', '@event_dispatcher', '@module_handler', '@renderer', '@render_cache', '@cache_contexts_manager', '@smart_cache_request_policy', '@smart_cache_response_policy', '@current_route_match', '@cache.smart_cache_contexts', '@cache.smart_cache_html', '@request_stack'] tags: - { name: render.main_content_renderer, format: html } main_content_renderer.ajax: @@ -895,6 +904,35 @@ services: arguments: ['@title_resolver'] tags: - { name: render.main_content_renderer, format: drupal_modal } + + cache.smart_cache_contexts: + class: Drupal\Core\Cache\CacheBackendInterface + tags: + - { name: cache.bin } + factory: cache_factory:get + arguments: [smart_cache_contexts] + cache.smart_cache_html: + class: Drupal\Core\Cache\CacheBackendInterface + tags: + - { name: cache.bin } + factory: cache_factory:get + arguments: [smart_cache_html] + smart_cache_request_policy: + class: Drupal\Core\SmartCache\DefaultRequestPolicy + tags: + - { name: service_collector, tag: smart_cache_request_policy, call: addPolicy} + lazy: true + smart_cache_response_policy: + class: Drupal\Core\PageCache\ChainResponsePolicy + tags: + - { name: service_collector, tag: smart_cache_response_policy, call: addPolicy} + lazy: true + smart_cache_subscriber: + class: Drupal\Core\EventSubscriber\SmartCacheSubscriber + arguments: ['@current_route_match', '@cache_contexts_manager', '@cache.smart_cache_contexts', '@cache.smart_cache_html'] + tags: + - { name: event_subscriber } + controller.form: class: Drupal\Core\Controller\HtmlFormController arguments: ['@controller_resolver', '@form_builder', '@class_resolver'] diff --git a/core/lib/Drupal/Core/EventSubscriber/ContentControllerSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/ContentControllerSubscriber.php index 16e9613..f6f30fe 100644 --- a/core/lib/Drupal/Core/EventSubscriber/ContentControllerSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/ContentControllerSubscriber.php @@ -40,7 +40,7 @@ public function onRequestDeriveFormWrapper(GetResponseEvent $event) { * An array of event listener definitions. */ static function getSubscribedEvents() { - $events[KernelEvents::REQUEST][] = array('onRequestDeriveFormWrapper', 29); + $events[KernelEvents::REQUEST][] = array('onRequestDeriveFormWrapper', 25); return $events; } diff --git a/core/lib/Drupal/Core/EventSubscriber/SmartCacheSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/SmartCacheSubscriber.php new file mode 100644 index 0000000..c332572 --- /dev/null +++ b/core/lib/Drupal/Core/EventSubscriber/SmartCacheSubscriber.php @@ -0,0 +1,135 @@ + html render array cache bin. + * + * @var \Drupal\Core\Cache\CacheBackendInterface + */ + protected $smartHtmlCache; + + /** + * Constructs a new SmartCacheSubscriber object. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The current route match. + * @param \Drupal\Core\Cache\Context\CacheContextsManager $cache_contexts_manager + * The cache contexts service. + * @param \Drupal\Core\Cache\CacheBackendInterface $contexts_cache + * The Smart Cache contexts cache bin. + * @param \Drupal\Core\Cache\CacheBackendInterface $html_cache + * The Smart Cache #type => html render array cache bin. + */ + public function __construct(RouteMatchInterface $route_match, CacheContextsManager $cache_contexts_manager, CacheBackendInterface $contexts_cache, CacheBackendInterface $html_cache) { + $this->routeMatch = $route_match; + $this->cacheContextsManager = $cache_contexts_manager; + $this->smartContextsCache = $contexts_cache; + $this->smartHtmlCache = $html_cache; + } + + /** + * Sets a response in case of a SmartCache cache hit. + * + * @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event + * The event to process. + */ + public function onRouteMatch(GetResponseEvent $event) { + // SmartCache only supports master requests that are safe, ask for HTML, and + // don't specify a HTML wrapper format. + if (!$event->isMasterRequest() || !$event->getRequest()->isMethodSafe() || $event->getRequest()->getRequestFormat() !== 'html' || $event->getRequest()->query->has(MainContentViewSubscriber::WRAPPER_FORMAT)) { + return; + } + + // @todo For now, SmartCache doesn't handle admin routes. It may be too much + // work to add the necessary cacheability metadata to all admin routes + // before 8.0.0, but that can happen in 8.1.0 without a BC break. + if ($this->routeMatch->getRouteObject()->getOption('_admin_route')) { + return; + } + + $this->routeMatch->getRouteName(); + + // Get the contexts by which the current route's response must be varied. + $cache_contexts = $this->smartContextsCache->get('smartcache:contexts:' . $this->cacheContextsManager->convertTokensToKeys(['route'])->getKeys()[0]); + + // If we already know the contexts by which the current route's response + // must be varied, check if a response already is cached for the current + // request's values for those contexts, and if so, return early. + if ($cache_contexts !== FALSE) { + $cid = 'smartcache:html_render_array:' . implode(':', $this->cacheContextsManager->convertTokensToKeys($cache_contexts->data)->getKeys()); + $cached_html = $this->smartHtmlCache->get($cid); + if ($cached_html !== FALSE) { + $html = $cached_html->data; + // Since https://www.drupal.org/node/2273925, the Renderer filters any + // markup that is given, to ensure it is safe. But, in the case of + // SmartCache, the markup is known to be safe, since it was originally + // generated by the Renderer. + $html['#markup'] = SafeMarkup::set($html['#markup']); + $event->getRequest() + ->attributes + ->set('_controller', function() use ($html) { + // Mark the render array, to skip as much in SmartCacheHtmlRenderer. + $html['#smartcache'] = TRUE; + // Return the #type => html render array. Let Symfony's HttpKernel + // handle the conversion to a Response object via its VIEW event. + return $html; + }); + $event->stopPropagation(); + } + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + $events = []; + $events[KernelEvents::REQUEST][] = ['onRouteMatch', 27]; + + return $events; + } + +} diff --git a/core/lib/Drupal/Core/Form/FormBuilder.php b/core/lib/Drupal/Core/Form/FormBuilder.php index 64e01e6..09b2bfa 100644 --- a/core/lib/Drupal/Core/Form/FormBuilder.php +++ b/core/lib/Drupal/Core/Form/FormBuilder.php @@ -621,6 +621,11 @@ public function prepareForm($form_id, &$form, FormStateInterface &$form_state) { $form['#method'] = 'get'; } + // Mark every non-GET form as uncacheable. + if (!$form_state->isMethodType('get')) { + $form['#cache']['max-age'] = 0; + } + // Generate a new #build_id for this form, if none has been set already. // The form_build_id is used as key to cache a particular build of the form. // For multi-step forms, this allows the user to go back to an earlier diff --git a/core/lib/Drupal/Core/PageCache/ResponsePolicy/NoAdminRoutes.php b/core/lib/Drupal/Core/PageCache/ResponsePolicy/NoAdminRoutes.php new file mode 100644 index 0000000..3c8dcdd --- /dev/null +++ b/core/lib/Drupal/Core/PageCache/ResponsePolicy/NoAdminRoutes.php @@ -0,0 +1,48 @@ +routeMatch = $route_match; + } + + /** + * {@inheritdoc} + */ + public function check(Response $response, Request $request) { + if (($route = $this->routeMatch->getRouteObject()) && $route->getOption('_admin_route')) { + return static::DENY; + } + } + +} diff --git a/core/lib/Drupal/Core/ProxyClass/SmartCache/DefaultRequestPolicy.php b/core/lib/Drupal/Core/ProxyClass/SmartCache/DefaultRequestPolicy.php new file mode 100644 index 0000000..2fdebb5 --- /dev/null +++ b/core/lib/Drupal/Core/ProxyClass/SmartCache/DefaultRequestPolicy.php @@ -0,0 +1,92 @@ +container = $container; + $this->drupalProxyOriginalServiceId = $drupal_proxy_original_service_id; + } + + /** + * Lazy loads the real service from the container. + * + * @return object + * Returns the constructed real service. + */ + protected function lazyLoadItself() + { + if (!isset($this->service)) { + $this->service = $this->container->get($this->drupalProxyOriginalServiceId); + } + + return $this->service; + } + + /** + * {@inheritdoc} + */ + public function check(\Symfony\Component\HttpFoundation\Request $request) + { + return $this->lazyLoadItself()->check($request); + } + + /** + * {@inheritdoc} + */ + public function addPolicy(\Drupal\Core\PageCache\RequestPolicyInterface $policy) + { + return $this->lazyLoadItself()->addPolicy($policy); + } + + } + +} diff --git a/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php b/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php index b53d1e5..e0a2ec0 100644 --- a/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php +++ b/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php @@ -105,6 +105,12 @@ public function __construct(TitleResolverInterface $title_resolver, PluginManage * The entire HTML: takes a #type 'page' and wraps it in a #type 'html'. */ public function renderResponse(array $main_content, Request $request, RouteMatchInterface $route_match) { + // If the _controller result already is #type => html, we can skip + // immediately to the final rendering (only html.html.twig). + if (isset($main_content['#type']) && $main_content['#type'] === 'html') { + return $this->finish($main_content); + } + list($page, $title) = $this->prepare($main_content, $request, $route_match); if (!isset($page['#type']) || $page['#type'] !== 'page') { @@ -125,6 +131,19 @@ public function renderResponse(array $main_content, Request $request, RouteMatch // page.html.twig, hence add them here, just before rendering html.html.twig. $this->buildPageTopAndBottom($html); + return $this->finish($html); + } + + /** + * Receives the render array for the html.twig.twig template and renders it. + * + * @param array $html + * The #type => html render array that represents the entire page. + * + * @return \Symfony\Component\HttpFoundation\Response + * The response. + */ + protected function finish(array $html) { // @todo https://www.drupal.org/node/2495001 Make renderRoot return a // cacheable render array directly. $this->renderer->renderRoot($html); diff --git a/core/lib/Drupal/Core/Render/MainContent/SmartCacheHtmlRenderer.php b/core/lib/Drupal/Core/Render/MainContent/SmartCacheHtmlRenderer.php new file mode 100644 index 0000000..590fca7 --- /dev/null +++ b/core/lib/Drupal/Core/Render/MainContent/SmartCacheHtmlRenderer.php @@ -0,0 +1,225 @@ + html render array cache bin. + * + * @var \Drupal\Core\Cache\CacheBackendInterface + */ + protected $smartHtmlCache; + + /** + * The request stack. + * + * @var \Symfony\Component\HttpFoundation\RequestStack + */ + protected $requestStack; + + /** + * Constructs a new SmartCacheHtmlRenderer. + * + * @param \Drupal\Core\Controller\TitleResolverInterface $title_resolver + * The title resolver. + * @param \Drupal\Component\Plugin\PluginManagerInterface $display_variant_manager + * The display variant manager. + * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher + * The event dispatcher. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler + * The module handler. + * @param \Drupal\Core\Render\RendererInterface $renderer + * The renderer service. + * @param \Drupal\Core\Render\RenderCacheInterface $render_cache + * The render cache service. + * @param \Drupal\Core\Cache\Context\CacheContextsManager $cache_contexts_manager + * The cache contexts service. + * @param \Drupal\Core\PageCache\RequestPolicyInterface $request_policy + * A policy rule determining the cacheability of a request. + * @param \Drupal\Core\PageCache\ResponsePolicyInterface $response_policy + * A policy rule determining the cacheability of the response. + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The current route match. + * @param \Drupal\Core\Cache\CacheBackendInterface $contexts_cache + * The Smart Cache contexts cache bin. + * @param \Drupal\Core\Cache\CacheBackendInterface $html_cache + * The Smart Cache #type => html render array cache bin. + * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack + * The request stack. + */ + public function __construct(TitleResolverInterface $title_resolver, PluginManagerInterface $display_variant_manager, EventDispatcherInterface $event_dispatcher, ModuleHandlerInterface $module_handler, RendererInterface $renderer, RenderCacheInterface $render_cache, CacheContextsManager $cache_contexts_manager, RequestPolicyInterface $request_policy, ResponsePolicyInterface $response_policy, RouteMatchInterface $route_match, CacheBackendInterface $contexts_cache, CacheBackendInterface $html_cache, RequestStack $request_stack) { + parent::__construct($title_resolver, $display_variant_manager, $event_dispatcher, $module_handler, $renderer, $render_cache); + $this->cacheContextsManager = $cache_contexts_manager; + $this->requestPolicy = $request_policy; + $this->responsePolicy = $response_policy; + $this->routeMatch = $route_match; + $this->smartContextsCache = $contexts_cache; + $this->smartHtmlCache = $html_cache; + $this->requestStack = $request_stack; + } + + /** + * {@inheritdoc} + */ + public function renderResponse(array $main_content, Request $request, RouteMatchInterface $route_match) { + // If this is a #type => html render array that comes from SmartCache + // already, then we can return early: no need to redo all the work. + if (isset($main_content['#smartcache'])) { + $html = $main_content; + // Mark the response as a cache hit. + $html['#attached']['http_header'][] = ['X-Drupal-SmartCache', 'HIT']; + return parent::finish($html); + } + else { + return parent::renderResponse($main_content, $request, $route_match); + } + } + + /** + * {@inheritdoc} + */ + protected function finish(array $html) { + // Don't cache the render array if the associated response will not meet the + // SmartCache request & response policies. + $response = new Response(); + $request = $this->requestStack->getCurrentRequest(); + if ($this->requestPolicy->check($request) === RequestPolicyInterface::DENY || $this->responsePolicy->check($response, $request) === ResponsePolicyInterface::DENY) { + return parent::finish($html); + } + + $cacheable_html = $html; + + // Get the contexts by which the current route's response must be varied. + $contexts_cid = 'smartcache:contexts:' . $this->cacheContextsManager->convertTokensToKeys(['route'])->getKeys()[0]; + $stored_cache_contexts = $this->smartContextsCache->get($contexts_cid); + if ($stored_cache_contexts !== FALSE) { + $stored_cache_contexts = $stored_cache_contexts->data; + } + + // "Soft-render" the HTML regions (don't replace placeholders yet, since we + // mustcache the placeholders, not the replaced placeholders). + $render_context = new RenderContext(); + $this->renderer->executeInRenderContext($render_context, function() use (&$cacheable_html) { + $this->renderer->render($cacheable_html); + }); + if (!$render_context->isEmpty()) { + $bubbleable_metadata = $render_context->pop(); + BubbleableMetadata::createFromRenderArray($cacheable_html) + ->merge($bubbleable_metadata) + ->applyTo($cacheable_html); + } + + // Only retain the cacheable render array, to be stored in SmartCache. + $cacheable_html = $this->renderCache->getCacheableRenderArray($cacheable_html); + + // Get the cacheability metadata. + $html_cacheability = CacheableMetadata::createFromRenderArray($cacheable_html) + ->addCacheContexts(['route']) + ->addCacheTags(['rendered']); + + // Retain page titles defined in the main content render array. + if (isset($html['page']['#title'])) { + $cacheable_html['page']['#title'] = $html['page']['#title']; + } + + // @todo DEBUG DEBUG DEBUG PROFILING PROFILING PROFILING — Until only the + // truly uncacheable things set max-age = 0 (such as the search block and + // the breadcrumbs block, which currently set max-age = 0, even though it + // is perfectly possible to cache them), to see the performance boost this + // will bring, uncomment this line. +//$html_cacheability->setCacheMaxAge(Cache::PERMANENT); + + // SmartCache only caches cacheable pages. + if ($html_cacheability->getCacheMaxAge() !== 0) { + // Anonymous function to optimize the cache contexts of CacheableMetadata. + $optimize_cache_contexts = function (CacheableMetadata $cacheability) { + $cacheability->setCacheContexts($this->cacheContextsManager->optimizeTokens($cacheability->getCacheContexts())); + }; + + $optimize_cache_contexts($html_cacheability); + // If the set of cache contexts is different, store the union of the already + // stored cache contexts and the contexts for this request. + if ($html_cacheability->getCacheContexts() !== $stored_cache_contexts) { + if (is_array($stored_cache_contexts)) { + $html_cacheability->addCacheContexts($stored_cache_contexts); + $optimize_cache_contexts($html_cacheability); + } + $this->smartContextsCache->set($contexts_cid, $html_cacheability->getCacheContexts()); + } + + // Finally, cache the #type => html render array by those contexts. + $context_cache_keys = $this->cacheContextsManager->convertTokensToKeys($html_cacheability->getCacheContexts()); + $cid = 'smartcache:html_render_array:' . implode(':', $context_cache_keys->getKeys()); + $html_cacheability = $html_cacheability->merge($context_cache_keys); + $expire = ($html_cacheability->getCacheMaxAge() === Cache::PERMANENT) ? Cache::PERMANENT : (int) $this->requestStack->getMasterRequest()->server->get('REQUEST_TIME') + $html_cacheability->getCacheMaxAge(); + $this->smartHtmlCache->set($cid, $cacheable_html, $expire, $html_cacheability->getCacheTags()); + + // Now that the cacheable HTML is cached, mark the response as a cache miss. + $cacheable_html['#attached']['http_header'][] = ['X-Drupal-SmartCache', 'MISS']; + } + else { + // Now that the cacheable HTML is cached, mark the response as a cache miss. + $cacheable_html['#attached']['http_header'][] = ['X-Drupal-SmartCache', 'UNCACHEABLE']; + } + + return parent::finish($cacheable_html); + } + +} diff --git a/core/lib/Drupal/Core/SmartCache/DefaultRequestPolicy.php b/core/lib/Drupal/Core/SmartCache/DefaultRequestPolicy.php new file mode 100644 index 0000000..7a684dc --- /dev/null +++ b/core/lib/Drupal/Core/SmartCache/DefaultRequestPolicy.php @@ -0,0 +1,31 @@ +addPolicy(new CommandLineOrUnsafeMethod()); + } + +} diff --git a/core/modules/block/tests/modules/block_test/src/Plugin/Block/TestAccessBlock.php b/core/modules/block/tests/modules/block_test/src/Plugin/Block/TestAccessBlock.php index 873a77b..eb9ee56 100644 --- a/core/modules/block/tests/modules/block_test/src/Plugin/Block/TestAccessBlock.php +++ b/core/modules/block/tests/modules/block_test/src/Plugin/Block/TestAccessBlock.php @@ -63,7 +63,7 @@ public static function create(ContainerInterface $container, array $configuratio * {@inheritdoc} */ protected function blockAccess(AccountInterface $account) { - return $this->state->get('test_block_access', FALSE) ? AccessResult::allowed() : AccessResult::forbidden(); + return $this->state->get('test_block_access', FALSE) ? AccessResult::allowed()->setCacheMaxAge(0) : AccessResult::forbidden()->setCacheMaxAge(0); } /** diff --git a/core/modules/system/src/EventSubscriber/ConfigCacheTag.php b/core/modules/system/src/EventSubscriber/ConfigCacheTag.php index 0482a74..640a6f0 100644 --- a/core/modules/system/src/EventSubscriber/ConfigCacheTag.php +++ b/core/modules/system/src/EventSubscriber/ConfigCacheTag.php @@ -60,8 +60,8 @@ public function onSave(ConfigCrudEvent $event) { $this->cacheTagsInvalidator->invalidateTags(['route_match', 'rendered']); } - // Global theme settings. - if ($event->getConfig()->getName() === 'system.theme.global') { + // Theme configuration and global theme settings. + if (in_array($event->getConfig()->getName(), ['system.theme', 'system.theme.global'])) { $this->cacheTagsInvalidator->invalidateTags(['rendered']); } diff --git a/core/modules/system/src/Tests/Cache/SmartCacheIntegrationTest.php b/core/modules/system/src/Tests/Cache/SmartCacheIntegrationTest.php new file mode 100644 index 0000000..efc6df1 --- /dev/null +++ b/core/modules/system/src/Tests/Cache/SmartCacheIntegrationTest.php @@ -0,0 +1,128 @@ +uninstall(['page_cache']); + } + + /** + * Tests that SmartCache works correctly, and verifies the edge cases. + */ + public function testSmartCache() { + // Controllers returning response objects are ignored by SmartCache. + $url = Url::fromUri('route:smart_cache_test.response'); + $this->drupalGet($url); + $this->assertFalse($this->drupalGetHeader('X-Drupal-SmartCache'), 'Response object returned: SmartCache is ignoring.'); + + // Controllers returning render arrays, rendered as HTML responses, are + // handled by SmartCache. + $url = Url::fromUri('route:smart_cache_test.html'); + $this->drupalGet($url); + $this->assertEqual('MISS', $this->drupalGetHeader('X-Drupal-SmartCache'), 'Render array returned, rendered as HTML response: SmartCache is active, SmartCache MISS.'); + $this->assertSmartCache($url, [], []); + $this->drupalGet($url); + $this->assertEqual('HIT', $this->drupalGetHeader('X-Drupal-SmartCache'), 'Render array returned, rendered as HTML response: SmartCache is active, SmartCache HIT.'); + + // The above is the simple case, where the render array returned by the + // response contains no cache contexts. So let's now test a route/controller + // that *does* vary by a cache context whose value we can easily control: it + // varies by the 'animal' query argument. + foreach (['llama', 'piggy', 'unicorn', 'kitten'] as $animal) { + $url = Url::fromUri('route:smart_cache_test.html.with_cache_contexts', ['query' => ['animal' => $animal]]); + $this->drupalGet($url); + $this->assertEqual('MISS', $this->drupalGetHeader('X-Drupal-SmartCache'), 'Render array returned, rendered as HTML response: SmartCache is active, SmartCache MISS.'); + $this->assertSmartCache($url, ['url.query_args:animal'], [$animal]); + $this->drupalGet($url); + $this->assertEqual('HIT', $this->drupalGetHeader('X-Drupal-SmartCache'), 'Render array returned, rendered as HTML response: SmartCache is active, SmartCache HIT.'); + + // Finally, let's also verify that the 'smart_cache_test.html' route + // continued to see cache hits if we specify a query argument, because it + // *should* ignore it and continue to provide SmartCache hits. + $url = Url::fromUri('route:smart_cache_test.html', ['query' => ['animal' => 'piglet']]); + $this->drupalGet($url); + $this->assertEqual('HIT', $this->drupalGetHeader('X-Drupal-SmartCache'), 'Render array returned, rendered as HTML response: SmartCache is active, SmartCache HIT.'); + } + + // Controllers returning render arrays, rendered as anything except a HTML + // response, are ignored by SmartCache. + $this->drupalGet('smart-cache-test/html', array('query' => array(MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_ajax'))); + $this->assertFalse($this->drupalGetHeader('X-Drupal-SmartCache'), 'Render array returned, rendered as AJAX response: SmartCache is ignoring.'); + $this->drupalGet('smart-cache-test/html', array('query' => array(MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_dialog'))); + $this->assertFalse($this->drupalGetHeader('X-Drupal-SmartCache'), 'Render array returned, rendered as dialog response: SmartCache is ignoring.'); + $this->drupalGet('smart-cache-test/html', array('query' => array(MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_modal'))); + $this->assertFalse($this->drupalGetHeader('X-Drupal-SmartCache'), 'Render array returned, rendered as modal response: SmartCache is ignoring.'); + + // Admin routes are ignored by SmartCache. + $this->drupalGet('smart-cache-test/html/admin'); + $this->assertFalse($this->drupalGetHeader('X-Drupal-SmartCache'), 'Response returned, rendered as HTML response, admin route: SmartCache is ignoring'); + $this->drupalGet('smart-cache-test/response/admin'); + $this->assertFalse($this->drupalGetHeader('X-Drupal-SmartCache'), 'Response returned, admin route: SmartCache is ignoring'); + + // Max-age = 0 responses are ignored by SmartCache. + $this->drupalGet('smart-cache-test/html/uncacheable'); + $this->assertEqual('UNCACHEABLE', $this->drupalGetHeader('X-Drupal-SmartCache'), 'Render array returned, rendered as HTML response, but uncacheable: SmartCache is running, but not caching.'); + } + + /** + * Asserts SmartCache cache items. + * + * @param \Drupal\Core\Url $url + * The URL to test. + * @param string[] $expected_cache_contexts + * The expected cache contexts for the given URL. + * @param string[] $cid_parts_for_cache_contexts + * The CID parts corresponding to the values in $expected_cache_contexts. + */ + protected function assertSmartCache(Url $url, array $expected_cache_contexts, array $cid_parts_for_cache_contexts) { + // Assert SmartCache contexts item. + $cid_parts = ['smartcache', 'contexts', $url->getRouteName() . hash('sha256', serialize($url->getRouteParameters()))]; + $cid = implode(':', $cid_parts); + $cache_item = \Drupal::cache('smart_cache_contexts')->get($cid); + $this->assertEqual($expected_cache_contexts, array_values(array_diff($cache_item->data, ['route']))); + + // Assert SmartCache html render array item. + $cid_parts = ['smartcache', 'html_render_array', $url->getRouteName() . hash('sha256', serialize($url->getRouteParameters()))]; + $cid_parts = array_merge($cid_parts, $cid_parts_for_cache_contexts); + $cid = implode(':', $cid_parts); + $cache_item = \Drupal::cache('smart_cache_html')->get($cid); + $this->assertEqual(['#markup', '#attached', '#cache', 'page'], array_keys($cache_item->data)); + } + +} diff --git a/core/modules/system/tests/modules/paramconverter_test/src/TestControllers.php b/core/modules/system/tests/modules/paramconverter_test/src/TestControllers.php index 1e8e2f4..327a8d5 100644 --- a/core/modules/system/tests/modules/paramconverter_test/src/TestControllers.php +++ b/core/modules/system/tests/modules/paramconverter_test/src/TestControllers.php @@ -25,6 +25,8 @@ public function testNodeSetParent(NodeInterface $node, NodeInterface $parent) { } public function testEntityLanguage(NodeInterface $node) { - return ['#markup' => $node->label()]; + $build = ['#markup' => $node->label()]; + \Drupal::service('renderer')->addCacheableDependency($build, $node); + return $build; } } diff --git a/core/modules/system/tests/modules/smart_cache_test/smart_cache_test.info.yml b/core/modules/system/tests/modules/smart_cache_test/smart_cache_test.info.yml new file mode 100644 index 0000000..cfa52e2 --- /dev/null +++ b/core/modules/system/tests/modules/smart_cache_test/smart_cache_test.info.yml @@ -0,0 +1,6 @@ +name: 'Test SmartCache' +type: module +description: 'Provides test routes/responses for SmartCache.' +package: Testing +version: VERSION +core: 8.x diff --git a/core/modules/system/tests/modules/smart_cache_test/smart_cache_test.routing.yml b/core/modules/system/tests/modules/smart_cache_test/smart_cache_test.routing.yml new file mode 100644 index 0000000..c7164b2 --- /dev/null +++ b/core/modules/system/tests/modules/smart_cache_test/smart_cache_test.routing.yml @@ -0,0 +1,45 @@ +smart_cache_test.response: + path: '/smart-cache-test/response' + defaults: + _controller: '\Drupal\smart_cache_test\SmartCacheTestController::response' + requirements: + _access: 'TRUE' + +smart_cache_test.response.admin: + path: '/smart-cache-test/response/admin' + defaults: + _controller: '\Drupal\smart_cache_test\SmartCacheTestController::response' + requirements: + _access: 'TRUE' + options: + _admin_route: TRUE + +smart_cache_test.html: + path: '/smart-cache-test/html' + defaults: + _controller: '\Drupal\smart_cache_test\SmartCacheTestController::html' + requirements: + _access: 'TRUE' + +smart_cache_test.html.admin: + path: '/smart-cache-test/html/admin' + defaults: + _controller: '\Drupal\smart_cache_test\SmartCacheTestController::html' + requirements: + _access: 'TRUE' + options: + _admin_route: TRUE + +smart_cache_test.html.with_cache_contexts: + path: '/smart-cache-test/html/with-cache-contexts' + defaults: + _controller: '\Drupal\smart_cache_test\SmartCacheTestController::htmlWithCacheContexts' + requirements: + _access: 'TRUE' + +smart_cache_test.html.uncacheable: + path: '/smart-cache-test/html/uncacheable' + defaults: + _controller: '\Drupal\smart_cache_test\SmartCacheTestController::htmlUncacheable' + requirements: + _access: 'TRUE' diff --git a/core/modules/system/tests/modules/smart_cache_test/src/SmartCacheTestController.php b/core/modules/system/tests/modules/smart_cache_test/src/SmartCacheTestController.php new file mode 100644 index 0000000..28bb937 --- /dev/null +++ b/core/modules/system/tests/modules/smart_cache_test/src/SmartCacheTestController.php @@ -0,0 +1,51 @@ + [ + '#markup' => 'Hello world.', + ], + ]; + } + + public function htmlWithCacheContexts() { + $build = $this->html(); + $build['dynamic_part'] = [ + '#markup' => SafeMarkup::format('Hello there, %animal.', ['%animal' => \Drupal::requestStack()->getCurrentRequest()->query->get('animal')]), + '#cache' => [ + 'contexts' => [ + 'url.query_args:animal', + ], + ], + ]; + return $build; + } + + public function htmlUncacheable() { + $build = $this->html(); + $build['very_dynamic_part'] = [ + '#markup' => 'Drupal cannot handle the awesomeness of llamas.', + '#cache' => [ + 'max-age' => 0, + ], + ]; + return $build; + } + +}