core/core.services.yml | 40 +++- core/includes/theme.inc | 2 +- .../ContentControllerSubscriber.php | 2 +- .../Core/EventSubscriber/SmartCacheSubscriber.php | 238 +++++++++++++++++++++ core/lib/Drupal/Core/Form/FormBuilder.php | 5 + .../PageCache/ResponsePolicy/NoAdminRoutes.php | 48 +++++ .../ProxyClass/SmartCache/DefaultRequestPolicy.php | 92 ++++++++ core/lib/Drupal/Core/Render/HtmlResponse.php | 5 +- .../Render/HtmlResponseAttachmentsProcessor.php | 50 ++++- .../Core/Render/MainContent/HtmlRenderer.php | 35 ++- .../Core/SmartCache/DefaultRequestPolicy.php | 31 +++ .../src/Plugin/Block/TestAccessBlock.php | 2 +- .../modules/page_cache/src/Tests/PageCacheTest.php | 2 +- core/modules/path/src/Tests/PathAliasTest.php | 4 + .../system/src/EventSubscriber/ConfigCacheTag.php | 4 +- .../src/Tests/Cache/SmartCacheIntegrationTest.php | 147 +++++++++++++ core/modules/system/system.routing.yml | 2 + .../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 +++++ core/modules/tracker/src/Tests/TrackerTest.php | 8 +- core/modules/tracker/tracker.pages.inc | 6 +- 23 files changed, 802 insertions(+), 27 deletions(-) diff --git a/core/core.services.yml b/core/core.services.yml index b832314..cb72fd4 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -211,17 +211,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'] @@ -898,7 +907,7 @@ services: - { 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'] + arguments: ['@title_resolver', '@plugin.manager.display_variant', '@event_dispatcher', '@module_handler', '@renderer', '@render_cache', '%renderer.config%'] tags: - { name: render.main_content_renderer, format: html } main_content_renderer.ajax: @@ -917,6 +926,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', '@smart_cache_request_policy', '@smart_cache_response_policy', '%renderer.config%'] + tags: + - { name: event_subscriber } + controller.form: class: Drupal\Core\Controller\HtmlFormController arguments: ['@controller_resolver', '@form_builder', '@class_resolver'] diff --git a/core/includes/theme.inc b/core/includes/theme.inc index 3b156a5..f61cc67 100644 --- a/core/includes/theme.inc +++ b/core/includes/theme.inc @@ -1319,7 +1319,7 @@ function template_preprocess_html(&$variables) { '@token' => $token, ]); $variables[$type]['#markup'] = $placeholder; - $variables[$type]['#attached']['html_response_placeholders'][$type] = $placeholder; + $variables[$type]['#attached']['html_response_attachment_placeholders'][$type] = $placeholder; } } 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..6c1534d --- /dev/null +++ b/core/lib/Drupal/Core/EventSubscriber/SmartCacheSubscriber.php @@ -0,0 +1,238 @@ +routeMatch = $route_match; + $this->cacheContextsManager = $cache_contexts_manager; + $this->smartContextsCache = $contexts_cache; + $this->smartHtmlCache = $html_cache; + $this->requestPolicy = $request_policy; + $this->responsePolicy = $response_policy; + $this->rendererConfig = $renderer_config; + } + + /** + * 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; + } + + // Don't cache the HTML response if the SmartCache request policies are not + // met. + if ($this->requestPolicy->check($event->getRequest()) === RequestPolicyInterface::DENY) { + return; + } + + // Get the contexts by which the current route's response must be varied. + $cid = implode(':', $this->cacheContextsManager->convertTokensToKeys(['route'])->getKeys()); + $cache_contexts_for_route = $this->smartContextsCache->get($cid); + + // Get & set the cached HTML response for the current route, if any. + if ($cache_contexts_for_route !== FALSE) { + $cid = implode(':', $this->cacheContextsManager->convertTokensToKeys($cache_contexts_for_route->data)->getKeys()); + $cached_html = $this->smartHtmlCache->get($cid); + if ($cached_html !== FALSE) { + $response = $cached_html->data; + $response->headers->set('X-Drupal-SmartCache', 'HIT'); + $event->setResponse($response); + } + } + } + + /** + * Stores a response in case of a SmartCache cache miss, if cacheable. + * + * @param \Symfony\Component\HttpKernel\Event\FilterResponseEvent $event + * The event to process. + */ + public function onResponse(FilterResponseEvent $event) { + $response = $event->getResponse(); + + // SmartCache only cares about HTML responses. + if (!$response instanceof HtmlResponse) { + return; + } + + // There's no work left to be done if this is a SmartCache cache hit. + if ($response->headers->get('X-Drupal-SmartCache') === 'HIT') { + return; + } + + // Don't cache the HTML response if the SmartCache request & response + // policies are not met. + $request = $event->getRequest(); + if ($this->requestPolicy->check($request) === RequestPolicyInterface::DENY || $this->responsePolicy->check($response, $request) === ResponsePolicyInterface::DENY) { + return; + } + + // Get the contexts by which the current route's response must be varied. + $contexts_cid = $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; + } + + // Get the cacheability metadata. + $html_cacheability = CacheableMetadata::createFromObject($response->getCacheableMetadata()) + // SmartCache caches per route. + ->addCacheContexts(['route']) + // SmartCache also respects the Renderer's required cache contexts. + ->addCacheContexts($this->rendererConfig['required_cache_contexts']) + ->addCacheTags(['rendered']); + + // @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 HTML responses. + 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 HTML response by those contexts. + $context_cache_keys = $this->cacheContextsManager->convertTokensToKeys($html_cacheability->getCacheContexts()); + $cid = implode(':', $context_cache_keys->getKeys()); + $html_cacheability = $html_cacheability->merge($context_cache_keys); + $expire = ($html_cacheability->getCacheMaxAge() === Cache::PERMANENT) ? Cache::PERMANENT : (int) $request->server->get('REQUEST_TIME') + $html_cacheability->getCacheMaxAge(); + $this->smartHtmlCache->set($cid, $response, $expire, $html_cacheability->getCacheTags()); + + // Now that the HTML response is cached, mark the response as a cache miss. + $response->headers->set('X-Drupal-SmartCache', 'MISS'); + } + else { + // The HTML response is uncacheable, mark it as such. + $response->headers->set('X-Drupal-SmartCache', 'UNCACHEABLE'); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + $events = []; + $events[KernelEvents::REQUEST][] = ['onRouteMatch', 27]; + + // Run before HtmlResponseSubscriber::onRespond(), which has priority 0. + $events[KernelEvents::RESPONSE][] = ['onResponse', 100]; + + return $events; + } + +} diff --git a/core/lib/Drupal/Core/Form/FormBuilder.php b/core/lib/Drupal/Core/Form/FormBuilder.php index a24330d..cbdcaa7 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/HtmlResponse.php b/core/lib/Drupal/Core/Render/HtmlResponse.php index c5339d6..4aea1e0 100644 --- a/core/lib/Drupal/Core/Render/HtmlResponse.php +++ b/core/lib/Drupal/Core/Render/HtmlResponse.php @@ -36,12 +36,15 @@ public function setContent($content) { // A render array can automatically be converted to a string and set the // necessary metadata. if (is_array($content) && (isset($content['#markup']))) { - $content += ['#attached' => ['html_response_placeholders' => []]]; + $content += ['#attached' => ['html_response_attachment_placeholders' => [], 'placeholders' => []]]; $this->addCacheableDependency(CacheableMetadata::createFromRenderArray($content)); $this->setAttachments($content['#attached']); $content = $content['#markup']; } parent::setContent($content); + + return $this; } + } diff --git a/core/lib/Drupal/Core/Render/HtmlResponseAttachmentsProcessor.php b/core/lib/Drupal/Core/Render/HtmlResponseAttachmentsProcessor.php index 50ff584..ab8db4d 100644 --- a/core/lib/Drupal/Core/Render/HtmlResponseAttachmentsProcessor.php +++ b/core/lib/Drupal/Core/Render/HtmlResponseAttachmentsProcessor.php @@ -99,13 +99,19 @@ public function processAttachments(AttachmentsInterface $response) { throw new \InvalidArgumentException('\Drupal\Core\Render\HtmlResponse instance expected.'); } + // First, render the actual placeholders; this may cause additional + // attachments to be added to the response, which the attachment + // placeholders rendered by renderHtmlResponseAttachmentPlaceholders() will + // need to include. + $this->renderPlaceholders($response); + $attached = $response->getAttachments(); // Get the placeholders from attached and then remove them. - $placeholders = $attached['html_response_placeholders']; - unset($attached['html_response_placeholders']); + $attachment_placeholders = $attached['html_response_attachment_placeholders']; + unset($attached['html_response_attachment_placeholders']); - $variables = $this->processAssetLibraries($attached, $placeholders); + $variables = $this->processAssetLibraries($attached, $attachment_placeholders); // Handle all non-asset attachments. This populates drupal_get_html_head() // and drupal_get_http_header(). @@ -113,12 +119,12 @@ public function processAttachments(AttachmentsInterface $response) { drupal_process_attached($all_attached); // Get HTML head elements - if present. - if (isset($placeholders['head'])) { + if (isset($attachment_placeholders['head'])) { $variables['head'] = drupal_get_html_head(FALSE); } - // Now replace the placeholders in the response content with the real data. - $this->renderPlaceholders($response, $placeholders, $variables); + // Now replace the attachment placeholders. + $this->renderHtmlResponseAttachmentPlaceholders($response, $attachment_placeholders, $variables); // Finally set the headers on the response. $headers = drupal_get_http_header(); @@ -128,6 +134,33 @@ public function processAttachments(AttachmentsInterface $response) { } /** + * Renders placeholders. + * + * Renders #attached[placeholders]. + * + * @param \Drupal\Core\Render\HtmlResponse $response + * The HTML response whose placeholders to replace. + * + * @see \Drupal\Core\Render\Renderer::replacePlaceholders() + * @see \Drupal\Core\Render\Renderer::renderPlaceholder() + */ + protected function renderPlaceholders(HtmlResponse $response) { + // Render the placeholders in the HTML Response object. + $build = [ + '#markup' => SafeString::create($response->getContent()), + '#attached' => $response->getAttachments(), + ]; + $this->renderer->renderRoot($build); + + // Update the Response object now that the placeholders have been rendered. + $placeholders_bubbleable_metadata = BubbleableMetadata::createFromRenderArray($build); + $response + ->setContent($build['#markup']) + ->addCacheableDependency($placeholders_bubbleable_metadata) + ->setAttachments($placeholders_bubbleable_metadata->getAttachments()); + } + + /** * Processes asset libraries into render arrays. * * @param array $attached @@ -174,8 +207,7 @@ protected function processAssetLibraries(array $attached, array $placeholders) { } /** - * Renders variables into HTML markup and replaces placeholders in the - * response content. + * Renders HTML response attachment placeholders. * * @param \Drupal\Core\Render\HtmlResponse $response * The HTML response to update. @@ -186,7 +218,7 @@ protected function processAssetLibraries(array $attached, array $placeholders) { * The variables to render and replace, keyed by type with renderable * arrays as values. */ - protected function renderPlaceholders(HtmlResponse $response, array $placeholders, array $variables) { + protected function renderHtmlResponseAttachmentPlaceholders(HtmlResponse $response, array $placeholders, array $variables) { $content = $response->getContent(); foreach ($placeholders as $type => $placeholder) { if (isset($variables[$type])) { diff --git a/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php b/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php index b53d1e5..4b5f0a5 100644 --- a/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php +++ b/core/lib/Drupal/Core/Render/MainContent/HtmlRenderer.php @@ -8,9 +8,11 @@ namespace Drupal\Core\Render\MainContent; use Drupal\Component\Plugin\PluginManagerInterface; +use Drupal\Core\Cache\Cache; use Drupal\Core\Controller\TitleResolverInterface; use Drupal\Core\Display\PageVariantInterface; use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Render\BubbleableMetadata; use Drupal\Core\Render\HtmlResponse; use Drupal\Core\Render\PageDisplayVariantSelectionEvent; use Drupal\Core\Render\RenderCacheInterface; @@ -75,6 +77,13 @@ class HtmlRenderer implements MainContentRendererInterface { protected $renderCache; /** + * The renderer configuration array. + * + * @var array + */ + protected $rendererConfig; + + /** * Constructs a new HtmlRenderer. * * @param \Drupal\Core\Controller\TitleResolverInterface $title_resolver @@ -89,14 +98,17 @@ class HtmlRenderer implements MainContentRendererInterface { * The renderer service. * @param \Drupal\Core\Render\RenderCacheInterface $render_cache * The render cache service. + * @param array $renderer_config + * The renderer configuration array. */ - public function __construct(TitleResolverInterface $title_resolver, PluginManagerInterface $display_variant_manager, EventDispatcherInterface $event_dispatcher, ModuleHandlerInterface $module_handler, RendererInterface $renderer, RenderCacheInterface $render_cache) { + public function __construct(TitleResolverInterface $title_resolver, PluginManagerInterface $display_variant_manager, EventDispatcherInterface $event_dispatcher, ModuleHandlerInterface $module_handler, RendererInterface $renderer, RenderCacheInterface $render_cache, array $renderer_config) { $this->titleResolver = $title_resolver; $this->displayVariantManager = $display_variant_manager; $this->eventDispatcher = $event_dispatcher; $this->moduleHandler = $module_handler; $this->renderer = $renderer; $this->renderCache = $render_cache; + $this->rendererConfig = $renderer_config; } /** @@ -125,11 +137,26 @@ 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); - // @todo https://www.drupal.org/node/2495001 Make renderRoot return a - // cacheable render array directly. - $this->renderer->renderRoot($html); + // Render, but don't replace placeholders yet, because that happens later in + // the render pipeline. + // @see \Drupal\Core\Render\HtmlResponseAttachmentsProcessor. + $render_context = new RenderContext(); + $this->renderer->executeInRenderContext($render_context, function() use (&$html) { + $this->renderer->render($html); + }); + if (!$render_context->isEmpty()) { + $bubbleable_metadata = $render_context->pop(); + BubbleableMetadata::createFromRenderArray($html) + ->merge($bubbleable_metadata) + ->applyTo($html); + } $content = $this->renderCache->getCacheableRenderArray($html); + // Also associate the required cache contexts. + // (Because we use ::render() above and not ::renderRoot(), we manually must + // ensure the HTML response varies by the required cache contexts.) + $content['#cache']['contexts'] = Cache::mergeContexts($content['#cache']['contexts'], $this->rendererConfig['required_cache_contexts']); + // Also associate the "rendered" cache tag. This allows us to invalidate the // entire render cache, regardless of the cache bin. $content['#cache']['tags'][] = 'rendered'; 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/page_cache/src/Tests/PageCacheTest.php b/core/modules/page_cache/src/Tests/PageCacheTest.php index e97a1b4..18a6dfd 100644 --- a/core/modules/page_cache/src/Tests/PageCacheTest.php +++ b/core/modules/page_cache/src/Tests/PageCacheTest.php @@ -399,7 +399,7 @@ public function testFormImmutability() { // that implementation. \Drupal::state()->set('page_cache_bypass_form_immutability', TRUE); \Drupal::moduleHandler()->resetImplementations(); - \Drupal::cache('render')->deleteAll(); + Cache::invalidateTags(['rendered']); $this->drupalGet('page_cache_form_test_immutability'); diff --git a/core/modules/path/src/Tests/PathAliasTest.php b/core/modules/path/src/Tests/PathAliasTest.php index e4d07e2..651c11f 100644 --- a/core/modules/path/src/Tests/PathAliasTest.php +++ b/core/modules/path/src/Tests/PathAliasTest.php @@ -7,6 +7,8 @@ namespace Drupal\path\Tests; +use Drupal\Core\Cache\Cache; + /** * Add, edit, delete, and change alias and verify its consistency in the * database. @@ -57,6 +59,8 @@ function testPathCache() { // Visit the alias for the node and confirm a cache entry is created. \Drupal::cache('data')->deleteAll(); + // @todo Remove this once https://www.drupal.org/node/2480077 lands. + Cache::invalidateTags(['rendered']); $this->drupalGet(trim($edit['alias'], '/')); $this->assertTrue(\Drupal::cache('data')->get('preload-paths:' . $edit['source']), 'Cache entry was created.'); } 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..42a43c0 --- /dev/null +++ b/core/modules/system/src/Tests/Cache/SmartCacheIntegrationTest.php @@ -0,0 +1,147 @@ +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[] $final_cache_contexts + * Assocative array, with the keys being the expected cache contexts for the + * given URL and the values being the corresponding cache keys. + * (Excluding the required cache contexts (%renderer.config%) and the + * 'route' cache context, all of which are added by SmartCache automatically + * and are asserted here too.) + */ + protected function assertSmartCache(Url $url, array $final_cache_contexts) { + // The complete cache context to key mapping consists of: + // - the expected cache contexts as passed in; + $cache_context_to_key_mapping = $final_cache_contexts; + // - the required cache contexts; + $cache_context_to_key_mapping['languages:language_interface'] = 'en'; + $cache_context_to_key_mapping['theme'] = 'classy'; + $cache_context_to_key_mapping['user.permissions'] = 'ph.' . \Drupal::service('user_permissions_hash_generator')->generate(new AnonymousUserSession()); + // - the 'route' cache context added by SmartCache. + $cache_context_to_key_mapping['route'] = $url->getRouteName() . hash('sha256', serialize($url->getRouteParameters())); + + // The final list of cache contexts that should be present. + $final_cache_contexts = Cache::mergeContexts(array_keys($cache_context_to_key_mapping)); + + // Assert SmartCache contexts item. + $cid_parts = [$url->getRouteName() . hash('sha256', serialize($url->getRouteParameters()))]; + $cid = implode(':', $cid_parts); + $cache_item = \Drupal::cache('smart_cache_contexts')->get($cid); + $this->assertEqual($final_cache_contexts, array_values($cache_item->data)); + + // Assert SmartCache HTML response. + $cid_parts = []; + foreach ($final_cache_contexts as $cache_context_token) { + $cid_parts[] = $cache_context_to_key_mapping[$cache_context_token]; + } + $cid = implode(':', $cid_parts); + $cache_item = \Drupal::cache('smart_cache_html')->get($cid); + $this->assertTrue($cache_item->data instanceof HtmlResponse); + } + +} diff --git a/core/modules/system/system.routing.yml b/core/modules/system/system.routing.yml index 9d806bb..2bc24a0 100644 --- a/core/modules/system/system.routing.yml +++ b/core/modules/system/system.routing.yml @@ -454,6 +454,8 @@ system.db_update: _title: 'Drupal database update' _controller: '\Drupal\system\Controller\DbUpdateController::handle' op: 'info' + options: + _admin_route: TRUE requirements: _access_system_update: 'TRUE' 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; + } + +} diff --git a/core/modules/tracker/src/Tests/TrackerTest.php b/core/modules/tracker/src/Tests/TrackerTest.php index c57f1c9..aaf7bad 100644 --- a/core/modules/tracker/src/Tests/TrackerTest.php +++ b/core/modules/tracker/src/Tests/TrackerTest.php @@ -83,10 +83,10 @@ function testTrackerAll() { $this->assertLink(t('My recent content'), 0, 'User tab shows up on the global tracker page.'); // Assert cache contexts, specifically the pager and node access contexts. - $this->assertCacheContexts(['languages:language_interface', 'theme', 'url.query_args.pagers:0', 'user.node_grants:view', 'user.permissions']); - // Assert cache tags for the visible node and node list cache tag. + $this->assertCacheContexts(['languages:language_interface', 'theme', 'url.query_args.pagers:0', 'user.node_grants:view', 'user.permissions', 'user.roles:authenticated']); + // Assert cache tags for the visible node, node lists and comment lists. $expected_tags = Cache::mergeTags($published->getCacheTags(), $published->getOwner()->getCacheTags()); - $expected_tags = Cache::mergeTags($expected_tags, ['node_list', 'rendered']); + $expected_tags = Cache::mergeTags($expected_tags, ['node_list', 'comment_list', 'rendered']); $this->assertCacheTags($expected_tags); // Delete a node and ensure it no longer appears on the tracker. @@ -155,7 +155,7 @@ function testTrackerUser() { $expected_tags = Cache::mergeTags($my_published->getCacheTags(), $my_published->getOwner()->getCacheTags()); $expected_tags = Cache::mergeTags($expected_tags, $other_published_my_comment->getCacheTags()); $expected_tags = Cache::mergeTags($expected_tags, $other_published_my_comment->getOwner()->getCacheTags()); - $expected_tags = Cache::mergeTags($expected_tags, ['node_list', 'rendered']); + $expected_tags = Cache::mergeTags($expected_tags, ['node_list', 'comment_list', 'rendered']); $this->assertCacheTags($expected_tags); $this->assertCacheContexts(['languages:language_interface', 'theme', 'url.query_args.pagers:0', 'user', 'user.node_grants:view']); diff --git a/core/modules/tracker/tracker.pages.inc b/core/modules/tracker/tracker.pages.inc index 3be3966..78293b7 100644 --- a/core/modules/tracker/tracker.pages.inc +++ b/core/modules/tracker/tracker.pages.inc @@ -123,8 +123,9 @@ function tracker_page($account = NULL) { } } - // Add the list cache tag for nodes. + // Add the list cache tag for nodes and comments. $cache_tags = Cache::mergeTags($cache_tags, \Drupal::entityManager()->getDefinition('node')->getListCacheTags()); + $cache_tags = Cache::mergeTags($cache_tags, \Drupal::entityManager()->getDefinition('comment')->getListCacheTags()); $page['tracker'] = array( '#rows' => $rows, @@ -140,6 +141,9 @@ function tracker_page($account = NULL) { $page['#cache']['tags'] = $cache_tags; $page['#cache']['contexts'][] = 'user.node_grants:view'; + // Cacheable per "authenticated or not", because we can only track (and show) + // reading history for authenticated users, not for anonymous users. + $page['#cache']['contexts'][] = 'user.roles:authenticated'; if (Drupal::moduleHandler()->moduleExists('history') && \Drupal::currentUser()->isAuthenticated()) { $page['#attached']['library'][] = 'tracker/history'; }