diff --git a/core/core.services.yml b/core/core.services.yml index 09494a117fe3dd978c219573965fc7c42407f86e..54ef20c0c834075b268b9f1b06b11624ac40b5ff 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -694,9 +694,15 @@ services: arguments: ['@route_filter.lazy_collector'] tags: - { name: event_subscriber } - url_generator: + url_generator.non_bubbling: class: Drupal\Core\Routing\UrlGenerator arguments: ['@router.route_provider', '@path_processor_manager', '@route_processor_manager', '@config.factory', '@request_stack'] + public: false + calls: + - [setContext, ['@?router.request_context']] + url_generator: + class: Drupal\Core\Render\MetadataBubblingUrlGenerator + arguments: ['@url_generator.non_bubbling', '@renderer'] calls: - [setContext, ['@?router.request_context']] redirect.destination: @@ -1425,7 +1431,7 @@ services: arguments: ['@request_stack', '@cache_factory', '@cache_contexts_manager'] renderer: class: Drupal\Core\Render\Renderer - arguments: ['@controller_resolver', '@theme.manager', '@plugin.manager.element_info', '@render_cache', '%renderer.config%'] + arguments: ['@controller_resolver', '@theme.manager', '@plugin.manager.element_info', '@render_cache', '@request_stack', '%renderer.config%'] early_rendering_controller_wrapper_subscriber: class: Drupal\Core\EventSubscriber\EarlyRenderingControllerWrapperSubscriber arguments: ['@controller_resolver', '@renderer'] diff --git a/core/includes/batch.inc b/core/includes/batch.inc index 4e474715981ef187a9171cfef285ea0b9709ebbb..7aa8ee794226c7bc8292af172011d97eeb74b9b9 100644 --- a/core/includes/batch.inc +++ b/core/includes/batch.inc @@ -165,7 +165,7 @@ function _batch_progress_page() { $query_options['op'] = $new_op; $batch['url']->setOption('query', $query_options); - $url = $batch['url']->toString(); + $url = $batch['url']->toString(TRUE)->getGeneratedUrl(); $build = array( '#theme' => 'progress_bar', diff --git a/core/includes/form.inc b/core/includes/form.inc index 817082010f634a41aee91daf2401763ec4f642f0..81c6406160684fe35005c523fa7dae95d55e8538 100644 --- a/core/includes/form.inc +++ b/core/includes/form.inc @@ -810,7 +810,7 @@ function batch_process($redirect = NULL, Url $url = NULL, $redirect_callback = N $query_options['op'] = 'finished'; $error_url->setOption('query', $query_options); - $batch['error_message'] = t('Please continue to the error page', array('@error_url' => $error_url->toString())); + $batch['error_message'] = t('Please continue to the error page', array('@error_url' => $error_url->toString(TRUE)->getGeneratedUrl())); // Clear the way for the redirection to the batch processing page, by // saving and unsetting the 'destination', if there is any. @@ -840,7 +840,7 @@ function batch_process($redirect = NULL, Url $url = NULL, $redirect_callback = N $function($batch_url->toString(), ['query' => $query_options]); } else { - return new RedirectResponse($batch_url->setAbsolute()->toString()); + return new RedirectResponse($batch_url->setAbsolute()->toString(TRUE)->getGeneratedUrl()); } } else { diff --git a/core/includes/pager.inc b/core/includes/pager.inc index 7ce34072e9ddd1832bf604d014160cf83ed0008a..57f860c646ca1830481960ccf6b6ca76001e2b22 100644 --- a/core/includes/pager.inc +++ b/core/includes/pager.inc @@ -176,6 +176,7 @@ function template_preprocess_pager(&$variables) { $element = $variables['pager']['#element']; $parameters = $variables['pager']['#parameters']; $quantity = $variables['pager']['#quantity']; + $route_name = $variables['pager']['#route_name']; global $pager_page_array, $pager_total; // Nothing to do if there is only one page. @@ -218,7 +219,7 @@ function template_preprocess_pager(&$variables) { $options = array( 'query' => pager_query_add_page($parameters, $element, 0), ); - $items['first']['href'] = \Drupal::url('', [], $options); + $items['first']['href'] = \Drupal::url($route_name, [], $options); if (isset($tags[0])) { $items['first']['text'] = $tags[0]; } @@ -227,7 +228,7 @@ function template_preprocess_pager(&$variables) { $options = array( 'query' => pager_query_add_page($parameters, $element, $pager_page_array[$element] - 1), ); - $items['previous']['href'] = \Drupal::url('', [], $options); + $items['previous']['href'] = \Drupal::url($route_name, [], $options); if (isset($tags[1])) { $items['previous']['text'] = $tags[1]; } @@ -243,7 +244,7 @@ function template_preprocess_pager(&$variables) { $options = array( 'query' => pager_query_add_page($parameters, $element, $i - 1), ); - $items['pages'][$i]['href'] = \Drupal::url('', [], $options); + $items['pages'][$i]['href'] = \Drupal::url($route_name, [], $options); if ($i == $pager_current) { $variables['current'] = $i; } @@ -260,7 +261,7 @@ function template_preprocess_pager(&$variables) { $options = array( 'query' => pager_query_add_page($parameters, $element, $pager_page_array[$element] + 1), ); - $items['next']['href'] = \Drupal::url('', [], $options); + $items['next']['href'] = \Drupal::url($route_name, [], $options); if (isset($tags[3])) { $items['next']['text'] = $tags[3]; } @@ -269,13 +270,18 @@ function template_preprocess_pager(&$variables) { $options = array( 'query' => pager_query_add_page($parameters, $element, $pager_max - 1), ); - $items['last']['href'] = \Drupal::url('', [], $options); + $items['last']['href'] = \Drupal::url($route_name, [], $options); if (isset($tags[4])) { $items['last']['text'] = $tags[4]; } } $variables['items'] = $items; + + // The rendered link needs to play well with any other query parameter + // used on the page, like exposed filters, so for the cacheability all query + // parameters matter. + $variables['#cache']['contexts'][] = 'url.query_args'; } /** diff --git a/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php index 9dbe48877b1013adab685cf1e318aa41f8adb40c..0acda56ef8aef237a3bbf86ba548bc98f57a4668 100644 --- a/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php @@ -7,6 +7,7 @@ namespace Drupal\Core\EventSubscriber; +use Drupal\Core\Ajax\AjaxResponse; use Drupal\Core\Cache\CacheableDependencyInterface; use Drupal\Core\Cache\CacheableResponseInterface; use Drupal\Core\Controller\ControllerResolverInterface; @@ -14,6 +15,7 @@ use Drupal\Core\Render\BubbleableMetadata; use Drupal\Core\Render\RenderContext; use Drupal\Core\Render\RendererInterface; +use Drupal\Core\Routing\RouteMatchInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\FilterControllerEvent; use Symfony\Component\HttpKernel\KernelEvents; @@ -44,10 +46,12 @@ * metadata is then merged onto the render array. * * In other words: this just exists to ease the transition to Drupal 8: it - * allows controllers that return render arrays (the majority) to still do early - * rendering. But controllers that return responses are already expected to do - * the right thing: if early rendering is detected in such a case, an exception - * is thrown. + * allows controllers that return render arrays (the majority) and + * \Drupal\Core\Ajax\AjaxResponse\AjaxResponse objects (a sizable minority that + * often involve a fair amount of rendering) to still do early rendering. But + * controllers that return any other kind of response are already expected to + * do the right thing, so if early rendering is detected in such a case, an + * exception is thrown. * * @see \Drupal\Core\Render\RendererInterface * @see \Drupal\Core\Render\Renderer @@ -129,15 +133,26 @@ protected function wrapControllerExecutionInRenderContext($controller, array $ar // drupal_render() outside of a render context, then the bubbleable metadata // for that is stored in the current render context. if (!$context->isEmpty()) { - // If a render array is returned by the controller, merge the "lost" - // bubbleable metadata. + /** @var \Drupal\Core\Render\BubbleableMetadata $early_rendering_bubbleable_metadata */ + $early_rendering_bubbleable_metadata = $context->pop(); + + // If a render array or AjaxResponse is returned by the controller, merge + // the "lost" bubbleable metadata. if (is_array($response)) { - $early_rendering_bubbleable_metadata = $context->pop(); BubbleableMetadata::createFromRenderArray($response) ->merge($early_rendering_bubbleable_metadata) ->applyTo($response); } - // If a Response or domain object is returned, and it cares about + elseif ($response instanceof AjaxResponse) { + $response->addAttachments($early_rendering_bubbleable_metadata->getAttachments()); + // @todo Make AjaxResponse cacheable in + // https://www.drupal.org/node/956186. Meanwhile, allow contrib + // subclasses to be. + if ($response instanceof CacheableResponseInterface) { + $response->addCacheableDependency($early_rendering_bubbleable_metadata); + } + } + // If a non-Ajax Response or domain object is returned and it cares about // attachments or cacheability, then throw an exception: early rendering // is not permitted in that case. It is the developer's responsibility // to not use early rendering. diff --git a/core/lib/Drupal/Core/Render/Element/FormElement.php b/core/lib/Drupal/Core/Render/Element/FormElement.php index 401c85056f53e5eb8ee771e8973ee26b186eb471..ab13289aa7c177eca64a3a33df06a5f53eec222d 100644 --- a/core/lib/Drupal/Core/Render/Element/FormElement.php +++ b/core/lib/Drupal/Core/Render/Element/FormElement.php @@ -8,6 +8,8 @@ namespace Drupal\Core\Render\Element; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Render\BubbleableMetadata; +use Drupal\Core\Url; /** * Provides a base class for form element plugins. @@ -111,18 +113,29 @@ public static function validatePattern(&$element, FormStateInterface $form_state * The form element. */ public static function processAutocomplete(&$element, FormStateInterface $form_state, &$complete_form) { + $url = NULL; $access = FALSE; + if (!empty($element['#autocomplete_route_name'])) { $parameters = isset($element['#autocomplete_route_parameters']) ? $element['#autocomplete_route_parameters'] : array(); - - $path = \Drupal::urlGenerator()->generate($element['#autocomplete_route_name'], $parameters); - $access = \Drupal::service('access_manager')->checkNamedRoute($element['#autocomplete_route_name'], $parameters, \Drupal::currentUser()); + $url = Url::fromRoute($element['#autocomplete_route_name'], $parameters)->toString(TRUE); + /** @var \Drupal\Core\Access\AccessManagerInterface $access_manager */ + $access_manager = \Drupal::service('access_manager'); + $access = $access_manager->checkNamedRoute($element['#autocomplete_route_name'], $parameters, \Drupal::currentUser(), TRUE); } + if ($access) { - $element['#attributes']['class'][] = 'form-autocomplete'; - $element['#attached']['library'][] = 'core/drupal.autocomplete'; - // Provide a data attribute for the JavaScript behavior to bind to. - $element['#attributes']['data-autocomplete-path'] = $path; + $metadata = BubbleableMetadata::createFromRenderArray($element); + if ($access->isAllowed()) { + $element['#attributes']['class'][] = 'form-autocomplete'; + $element['#attached']['library'][] = 'core/drupal.autocomplete'; + // Provide a data attribute for the JavaScript behavior to bind to. + $element['#attributes']['data-autocomplete-path'] = $url->getGeneratedUrl(); + $metadata->merge($url); + } + $metadata + ->merge(BubbleableMetadata::createFromObject($access)) + ->applyTo($element); } return $element; diff --git a/core/lib/Drupal/Core/Render/Element/Pager.php b/core/lib/Drupal/Core/Render/Element/Pager.php index eeb0f9dd57655052b629f3de077f77003331ffdb..8e87ccaa5cba9926c5fc595c270cca9ad84dd3e4 100644 --- a/core/lib/Drupal/Core/Render/Element/Pager.php +++ b/core/lib/Drupal/Core/Render/Element/Pager.php @@ -34,18 +34,29 @@ public function getInfo() { '#quantity' => 9, // An array of labels for the controls in the pager. '#tags' => [], + // The name of the route to be used to build pager links. By default no + // path is provided, which will make links relative to the current URL. + // This makes the page more effectively cacheable. + '#route_name' => '', ]; } /** * #pre_render callback to associate the appropriate cache context. * + * * @param array $pager * A renderable array of #type => pager. * * @return array */ public static function preRenderPager(array $pager) { + // Note: the default pager theme process function + // template_preprocess_pager() also calls pager_query_add_page(), which + // maintains the existing query string. Therefore + // template_preprocess_pager() adds the 'url.query_args' cache context, + // which causes the more specific cache context below to be optimized away. + // In other themes, however, that may not be the case. $pager['#cache']['contexts'][] = 'url.query_args.pagers:' . $pager['#element']; return $pager; } diff --git a/core/lib/Drupal/Core/Render/Element/RenderElement.php b/core/lib/Drupal/Core/Render/Element/RenderElement.php index 5e7568b7a8a7b53ed52a7e22ec10fbb5657b04b8..54463b67ce422b7222fe739ea7c777663bd544be 100644 --- a/core/lib/Drupal/Core/Render/Element/RenderElement.php +++ b/core/lib/Drupal/Core/Render/Element/RenderElement.php @@ -10,6 +10,7 @@ use Drupal\Core\Form\FormBuilderInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Plugin\PluginBase; +use Drupal\Core\Render\BubbleableMetadata; use Drupal\Core\Render\Element; use Drupal\Core\Url; @@ -253,7 +254,11 @@ public static function preRenderAjaxForm($element) { // Convert \Drupal\Core\Url object to string. if (isset($settings['url']) && $settings['url'] instanceof Url) { - $settings['url'] = $settings['url']->setOptions($settings['options'])->toString(); + $url = $settings['url']->setOptions($settings['options'])->toString(TRUE); + BubbleableMetadata::createFromRenderArray($element) + ->merge($url) + ->applyTo($element); + $settings['url'] = $url->getGeneratedUrl(); } else { $settings['url'] = NULL; diff --git a/core/lib/Drupal/Core/Render/HtmlResponse.php b/core/lib/Drupal/Core/Render/HtmlResponse.php index 7053f83e5293a59b56290d007885763bac8f7483..c5339d613fad844cf2bb03eb9d52b7ac2c3b4d46 100644 --- a/core/lib/Drupal/Core/Render/HtmlResponse.php +++ b/core/lib/Drupal/Core/Render/HtmlResponse.php @@ -36,6 +36,7 @@ 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' => []]]; $this->addCacheableDependency(CacheableMetadata::createFromRenderArray($content)); $this->setAttachments($content['#attached']); $content = $content['#markup']; diff --git a/core/lib/Drupal/Core/Render/MetadataBubblingUrlGenerator.php b/core/lib/Drupal/Core/Render/MetadataBubblingUrlGenerator.php new file mode 100644 index 0000000000000000000000000000000000000000..3a4535ed0d9f0f7c01f8eed2c66fc81e19606079 --- /dev/null +++ b/core/lib/Drupal/Core/Render/MetadataBubblingUrlGenerator.php @@ -0,0 +1,142 @@ +urlGenerator = $url_generator; + $this->renderer = $renderer; + } + + /** + * {@inheritdoc} + */ + public function setContext(SymfonyRequestContext $context) { + $this->urlGenerator->setContext($context); + } + + /** + * {@inheritdoc} + */ + public function getContext() { + return $this->urlGenerator->getContext(); + } + + /** + * {@inheritdoc} + */ + public function getPathFromRoute($name, $parameters = array()) { + return $this->urlGenerator->getPathFromRoute($name, $parameters); + } + + /** + * Bubbles the bubbleable metadata to the current render context. + * + * @param \Drupal\Core\GeneratedUrl $generated_url + * The generated URL whose bubbleable metadata to bubble. + * @param array $options + * (optional) The URL options. Defaults to none. + */ + protected function bubble(GeneratedUrl $generated_url, array $options = []) { + // Bubbling metadata makes sense only if the code is executed inside a + // render context. All code running outside controllers has no render + // context by default, so URLs used there are not supposed to affect the + // response cacheability. + if ($this->renderer->hasRenderContext()) { + $build = []; + $generated_url->applyTo($build); + $this->renderer->render($build); + } + } + + /** + * {@inheritdoc} + */ + public function generate($name, $parameters = array(), $absolute = FALSE) { + $options['absolute'] = $absolute; + $generated_url = $this->generateFromRoute($name, $parameters, $options, TRUE); + $this->bubble($generated_url); + return $generated_url->getGeneratedUrl(); + } + + /** + * {@inheritdoc} + */ + public function generateFromRoute($name, $parameters = array(), $options = array(), $collect_bubbleable_metadata = FALSE) { + $generated_url = $this->urlGenerator->generateFromRoute($name, $parameters, $options, TRUE); + if (!$collect_bubbleable_metadata) { + $this->bubble($generated_url, $options); + } + return $collect_bubbleable_metadata ? $generated_url : $generated_url->getGeneratedUrl(); + } + + /** + * {@inheritdoc} + */ + public function generateFromPath($path = NULL, $options = array(), $collect_bubbleable_metadata = FALSE) { + $generated_url = $this->urlGenerator->generateFromPath($path, $options, TRUE); + if (!$collect_bubbleable_metadata) { + $this->bubble($generated_url, $options); + } + return $collect_bubbleable_metadata ? $generated_url : $generated_url->getGeneratedUrl(); + } + + /** + * {@inheritdoc} + */ + public function supports($name) { + return $this->urlGenerator->supports($name); + } + + /** + * {@inheritdoc} + */ + public function getRouteDebugMessage($name, array $parameters = array()) { + return $this->urlGenerator->getRouteDebugMessage($name, $parameters); + } + +} diff --git a/core/lib/Drupal/Core/Render/Renderer.php b/core/lib/Drupal/Core/Render/Renderer.php index 503a26dac55afb19a0130001c83d6854a990d695..d68282278771d068d992b79a80970eabc90daf09 100644 --- a/core/lib/Drupal/Core/Render/Renderer.php +++ b/core/lib/Drupal/Core/Render/Renderer.php @@ -7,7 +7,6 @@ namespace Drupal\Core\Render; -use Drupal\Component\Utility\NestedArray; use Drupal\Component\Utility\SafeMarkup; use Drupal\Component\Utility\UrlHelper; use Drupal\Core\Access\AccessResultInterface; @@ -16,6 +15,7 @@ use Drupal\Core\Controller\ControllerResolverInterface; use Drupal\Core\Template\Attribute; use Drupal\Core\Theme\ThemeManagerInterface; +use Symfony\Component\HttpFoundation\RequestStack; /** * Turns a render array into a HTML string. @@ -58,7 +58,25 @@ class Renderer implements RendererInterface { protected $rendererConfig; /** - * The render context. + * Whether we're currently in a ::renderRoot() call. + * + * @var bool + */ + protected $isRenderingRoot = FALSE; + + /** + * The request stack. + * + * @var \Symfony\Component\HttpFoundation\RequestStack + */ + protected $requestStack; + + /** + * The render context collection. + * + * An individual global render context is tied to the current request. We then + * need to maintain a different context for each request to correctly handle + * rendering in subrequests. * * This must be static as long as some controllers rebuild the container * during a request. This causes multiple renderer instances to co-exist @@ -66,16 +84,9 @@ class Renderer implements RendererInterface { * fail to render correctly. As soon as it is guaranteed that during a request * the same container is used, it no longer needs to be static. * - * @var \Drupal\Core\Render\RenderContext|null + * @var \Drupal\Core\Render\RenderContext[] */ - protected static $context; - - /** - * Whether we're currently in a ::renderRoot() call. - * - * @var bool - */ - protected $isRenderingRoot = FALSE; + protected static $contextCollection; /** * Constructs a new Renderer. @@ -88,15 +99,23 @@ class Renderer implements RendererInterface { * The element info. * @param \Drupal\Core\Render\RenderCacheInterface $render_cache * The render cache service. + * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack + * The request stack. * @param array $renderer_config * The renderer configuration array. */ - public function __construct(ControllerResolverInterface $controller_resolver, ThemeManagerInterface $theme, ElementInfoManagerInterface $element_info, RenderCacheInterface $render_cache, array $renderer_config) { + public function __construct(ControllerResolverInterface $controller_resolver, ThemeManagerInterface $theme, ElementInfoManagerInterface $element_info, RenderCacheInterface $render_cache, RequestStack $request_stack, array $renderer_config) { $this->controllerResolver = $controller_resolver; $this->theme = $theme; $this->elementInfo = $element_info; $this->renderCache = $render_cache; $this->rendererConfig = $renderer_config; + $this->requestStack = $request_stack; + + // Initialize the context collection if needed. + if (!isset(static::$contextCollection)) { + static::$contextCollection = new \SplObjectStorage(); + } } /** @@ -225,10 +244,11 @@ protected function doRender(&$elements, $is_root_call = FALSE) { return ''; } - if (!isset(static::$context)) { + $context = $this->getCurrentRenderContext(); + if (!isset($context)) { throw new \LogicException("Render context is empty, because render() was called outside of a renderRoot() or renderPlain() call. Use renderPlain()/renderRoot() or #lazy_builder/#pre_render instead."); } - static::$context->push(new BubbleableMetadata()); + $context->push(new BubbleableMetadata()); // Set the bubbleable rendering metadata that has configurable defaults, if: // - this is the root call, to ensure that the final render array definitely @@ -269,10 +289,10 @@ protected function doRender(&$elements, $is_root_call = FALSE) { } // The render cache item contains all the bubbleable rendering metadata // for the subtree. - static::$context->update($elements); + $context->update($elements); // Render cache hit, so rendering is finished, all necessary info // collected! - static::$context->bubble(); + $context->bubble(); return $elements['#markup']; } } @@ -370,9 +390,9 @@ protected function doRender(&$elements, $is_root_call = FALSE) { if (!empty($elements['#printed'])) { // The #printed element contains all the bubbleable rendering metadata for // the subtree. - static::$context->update($elements); + $context->update($elements); // #printed, so rendering is finished, all necessary info collected! - static::$context->bubble(); + $context->bubble(); return ''; } @@ -499,7 +519,7 @@ protected function doRender(&$elements, $is_root_call = FALSE) { $elements['#markup'] = $prefix . $elements['#children'] . $suffix; // We've rendered this element (and its subtree!), now update the context. - static::$context->update($elements); + $context->update($elements); // Cache the processed element if both $pre_bubbling_elements and $elements // have the metadata necessary to generate a cache ID. @@ -522,40 +542,73 @@ protected function doRender(&$elements, $is_root_call = FALSE) { if ($is_root_call) { $this->replacePlaceholders($elements); // @todo remove as part of https://www.drupal.org/node/2511330. - if (static::$context->count() !== 1) { + if ($context->count() !== 1) { throw new \LogicException('A stray drupal_render() invocation with $is_root_call = TRUE is causing bubbling of attached assets to break.'); } } // Rendering is finished, all necessary info collected! - static::$context->bubble(); + $context->bubble(); $elements['#printed'] = TRUE; $elements['#markup'] = SafeMarkup::set($elements['#markup']); return $elements['#markup']; } + /** + * {@inheritdoc} + */ + public function hasRenderContext() { + return (bool) $this->getCurrentRenderContext(); + } + /** * {@inheritdoc} */ public function executeInRenderContext(RenderContext $context, callable $callable) { // Store the current render context. - $current_context = static::$context; + $previous_context = $this->getCurrentRenderContext(); // Set the provided context and call the callable, it will use that context. - static::$context = $context; + $this->setCurrentRenderContext($context); $result = $callable(); // @todo Convert to an assertion in https://www.drupal.org/node/2408013 - if (static::$context->count() > 1) { + if ($context->count() > 1) { throw new \LogicException('Bubbling failed.'); } // Restore the original render context. - static::$context = $current_context; + $this->setCurrentRenderContext($previous_context); return $result; } + /** + * Returns the current render context. + * + * @return \Drupal\Core\Render\RenderContext + * The current render context. + */ + protected function getCurrentRenderContext() { + $request = $this->requestStack->getCurrentRequest(); + return isset(static::$contextCollection[$request]) ? static::$contextCollection[$request] : NULL; + } + + /** + * Sets the current render context. + * + * @param \Drupal\Core\Render\RenderContext|null $context + * The render context. This can be NULL for instance when restoring the + * original render context, which is in fact NULL. + * + * @return $this + */ + protected function setCurrentRenderContext(RenderContext $context = NULL) { + $request = $this->requestStack->getCurrentRequest(); + static::$contextCollection[$request] = $context; + return $this; + } + /** * Replaces placeholders. * diff --git a/core/lib/Drupal/Core/Render/RendererInterface.php b/core/lib/Drupal/Core/Render/RendererInterface.php index b55966f8ab168016dbc16c02c007b850c5610656..af3bcdad3c16d2f71421b1e6a0efa2126ed04027 100644 --- a/core/lib/Drupal/Core/Render/RendererInterface.php +++ b/core/lib/Drupal/Core/Render/RendererInterface.php @@ -321,6 +321,18 @@ public function renderPlain(&$elements); */ public function render(&$elements, $is_root_call = FALSE); + /** + * Checks whether a render context is active. + * + * This is useful only in very specific situations to determine whether the + * system is already capable of collecting bubbleable metadata. Normally it + * should not be necessary to be concerned about this. + * + * @return bool + * TRUE if the renderer has a render context active, FALSE otherwise. + */ + public function hasRenderContext(); + /** * Executes a callable within a render context. * diff --git a/core/lib/Drupal/Core/Routing/UrlGenerator.php b/core/lib/Drupal/Core/Routing/UrlGenerator.php index 53c3098c71004d7c48d0507f013edfc5d882112c..5103bf2688e73ed5b39a7e729697f5d9c9308c5f 100644 --- a/core/lib/Drupal/Core/Routing/UrlGenerator.php +++ b/core/lib/Drupal/Core/Routing/UrlGenerator.php @@ -278,12 +278,9 @@ public function generate($name, $parameters = array(), $absolute = FALSE) { * {@inheritdoc} */ public function generateFromRoute($name, $parameters = array(), $options = array(), $collect_bubbleable_metadata = FALSE) { - $generated_url = $collect_bubbleable_metadata ? new GeneratedUrl() : NULL; - $options += array('prefix' => ''); $route = $this->getRoute($name); - $name = $this->getRouteDebugMessage($name); - $this->processRoute($name, $route, $parameters, $generated_url); + $generated_url = $collect_bubbleable_metadata ? new GeneratedUrl() : NULL; $query_params = []; // Symfony adds any parameters that are not path slugs as query strings. @@ -291,6 +288,23 @@ public function generateFromRoute($name, $parameters = array(), $options = array $query_params = $options['query']; } + $fragment = ''; + if (isset($options['fragment'])) { + if (($fragment = trim($options['fragment'])) != '') { + $fragment = '#' . $fragment; + } + } + + // Generate a relative URL having no path, just query string and fragment. + if ($route->getOption('_no_path')) { + $query = $query_params ? '?' . http_build_query($query_params, '', '&') : ''; + $url = $query . $fragment; + return $collect_bubbleable_metadata ? $generated_url->setGeneratedUrl($url) : $url; + } + + $options += array('prefix' => ''); + $name = $this->getRouteDebugMessage($name); + $this->processRoute($name, $route, $parameters, $generated_url); $path = $this->getInternalPathFromRoute($name, $route, $parameters, $query_params); $path = $this->processPath($path, $options, $generated_url); @@ -300,13 +314,6 @@ public function generateFromRoute($name, $parameters = array(), $options = array $path = '/' . str_replace('%2F', '/', rawurlencode($prefix)) . $path; } - $fragment = ''; - if (isset($options['fragment'])) { - if (($fragment = trim($options['fragment'])) != '') { - $fragment = '#' . $fragment; - } - } - // The base_url might be rewritten from the language rewrite in domain mode. if (isset($options['base_url'])) { $base_url = $options['base_url']; @@ -328,11 +335,6 @@ public function generateFromRoute($name, $parameters = array(), $options = array $absolute = !empty($options['absolute']); if (!$absolute || !$host = $this->context->getHost()) { - - if ($route->getOption('_only_fragment')) { - return $collect_bubbleable_metadata ? $generated_url->setGeneratedUrl($fragment) : $fragment; - } - $url = $base_url . $path . $fragment; return $collect_bubbleable_metadata ? $generated_url->setGeneratedUrl($url) : $url; } diff --git a/core/lib/Drupal/Core/Theme/ThemeManager.php b/core/lib/Drupal/Core/Theme/ThemeManager.php index 3f7ffae483c5f96fac558353876d7b5633453d51..b7eff0bdd98c0246230d0b221d563b68975fb18d 100644 --- a/core/lib/Drupal/Core/Theme/ThemeManager.php +++ b/core/lib/Drupal/Core/Theme/ThemeManager.php @@ -302,14 +302,23 @@ protected function theme($hook, $variables = array()) { $preprocessor_function($variables, $hook, $info); } } - // Allow theme preprocess functions to set $variables['#attached'] and use - // it like the #attached property on render arrays. In Drupal 8, this is - // the (only) officially supported method of attaching assets from - // preprocess functions. Assets attached here should be associated with - // the template that we're preprocessing variables for. - if (isset($variables['#attached'])) { - $preprocess_attached = ['#attached' => $variables['#attached']]; - drupal_render($preprocess_attached); + // Allow theme preprocess functions to set $variables['#attached'] and + // $variables['#cache'] and use them like the corresponding element + // properties on render arrays. In Drupal 8, this is the (only) officially + // supported method of attaching bubbleable metadata from preprocess + // functions. Assets attached here should be associated with the template + // that we are preprocessing variables for. + $preprocess_bubbleable = []; + foreach (['#attached', '#cache'] as $key) { + if (isset($variables[$key])) { + $preprocess_bubbleable[$key] = $variables[$key]; + } + } + // We do not allow preprocess functions to define cacheable elements. + unset($preprocess_bubbleable['#cache']['keys']); + if ($preprocess_bubbleable) { + // @todo Inject the Renderer in https://www.drupal.org/node/2529438. + drupal_render($preprocess_bubbleable); } } diff --git a/core/modules/comment/src/Controller/CommentController.php b/core/modules/comment/src/Controller/CommentController.php index add53333117a8234823c7f3fcc824f8068326d1e..e52e3b9f64cc1e1f84024a79f6efe7e148fa2ba9 100644 --- a/core/modules/comment/src/Controller/CommentController.php +++ b/core/modules/comment/src/Controller/CommentController.php @@ -11,6 +11,7 @@ use Drupal\comment\CommentManagerInterface; use Drupal\comment\Plugin\Field\FieldType\CommentItemInterface; use Drupal\Core\Access\AccessResult; +use Drupal\Core\Cache\CacheableResponseInterface; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityManagerInterface; @@ -129,7 +130,8 @@ public function commentPermalink(Request $request, CommentInterface $comment) { // Find the current display page for this comment. $page = $this->entityManager()->getStorage('comment')->getDisplayOrdinal($comment, $field_definition->getSetting('default_mode'), $field_definition->getSetting('per_page')); // @todo: Cleaner sub request handling. - $redirect_request = Request::create($entity->url(), 'GET', $request->query->all(), $request->cookies->all(), array(), $request->server->all()); + $subrequest_url = $entity->urlInfo()->toString(TRUE); + $redirect_request = Request::create($subrequest_url->getGeneratedUrl(), 'GET', $request->query->all(), $request->cookies->all(), array(), $request->server->all()); $redirect_request->query->set('page', $page); // Carry over the session to the subrequest. if ($session = $request->getSession()) { @@ -137,7 +139,16 @@ public function commentPermalink(Request $request, CommentInterface $comment) { } // @todo: Convert the pager to use the request object. $request->query->set('page', $page); - return $this->httpKernel->handle($redirect_request, HttpKernelInterface::SUB_REQUEST); + $response = $this->httpKernel->handle($redirect_request, HttpKernelInterface::SUB_REQUEST); + if ($response instanceof CacheableResponseInterface) { + // @todo Once path aliases have cache tags (see + // https://www.drupal.org/node/2480077), add test coverage that + // the cache tag for a commented entity's path alias is added to the + // comment's permalink response, because there can be blocks or + // other content whose renderings depend on the subrequest's URL. + $response->addCacheableDependency($subrequest_url); + } + return $response; } throw new NotFoundHttpException(); } diff --git a/core/modules/comment/src/Tests/CommentRssTest.php b/core/modules/comment/src/Tests/CommentRssTest.php index f843e25b985c51f8524cfd59b0230242d1b589ca..2a878591c16320e444c30d8974e9176d1b0cc3c4 100644 --- a/core/modules/comment/src/Tests/CommentRssTest.php +++ b/core/modules/comment/src/Tests/CommentRssTest.php @@ -56,6 +56,7 @@ function testCommentRss() { $cache_contexts = [ 'languages:language_interface', 'theme', + 'url.site', 'user.node_grants:view', 'user.permissions', 'timezone', diff --git a/core/modules/filter/src/Element/TextFormat.php b/core/modules/filter/src/Element/TextFormat.php index 916576b4d087b630726ae772898437828e13409a..9247dbadb74e6f56c5f40031f4f6138c4586c00c 100644 --- a/core/modules/filter/src/Element/TextFormat.php +++ b/core/modules/filter/src/Element/TextFormat.php @@ -192,12 +192,17 @@ public static function processFormat(&$element, FormStateInterface $form_state, '#parents' => array_merge($element['#parents'], array('format')), ); - $element['format']['help'] = array( + $element['format']['help'] = [ '#type' => 'container', - '#attributes' => array('class' => array('filter-help')), - '#markup' => \Drupal::l(t('About text formats'), new Url('filter.tips_all', array(), array('attributes' => array('target' => '_blank')))), + 'about' => [ + '#type' => 'link', + '#title' => t('About text formats'), + '#url' => new Url('filter.tips_all'), + '#attributes' => ['target' => '_blank'], + ], + '#attributes' => ['class' => ['filter-help']], '#weight' => 0, - ); + ]; $all_formats = filter_formats(); $format_exists = isset($all_formats[$element['#format']]); diff --git a/core/modules/language/src/Tests/LanguageUrlRewritingTest.php b/core/modules/language/src/Tests/LanguageUrlRewritingTest.php index 81602d1f4bd6ea1da4821ebed0619eb344e5ce99..f590861b4c43cacc701168e9a692ae71355dd81f 100644 --- a/core/modules/language/src/Tests/LanguageUrlRewritingTest.php +++ b/core/modules/language/src/Tests/LanguageUrlRewritingTest.php @@ -139,7 +139,7 @@ function testDomainNameNegotiationPort() { // Create an absolute French link. $language = \Drupal::languageManager()->getLanguage('fr'); - $url = Url::fromRoute('', [], [ + $url = Url::fromRoute('', [], [ 'absolute' => TRUE, 'language' => $language, ])->toString(); @@ -149,7 +149,7 @@ function testDomainNameNegotiationPort() { $this->assertEqual($url, $expected, 'The right port is used.'); // If we set the port explicitly, it should not be overridden. - $url = Url::fromRoute('', [], [ + $url = Url::fromRoute('', [], [ 'absolute' => TRUE, 'language' => $language, 'base_url' => $request->getBaseUrl() . ':90', diff --git a/core/modules/node/src/Tests/Views/FrontPageTest.php b/core/modules/node/src/Tests/Views/FrontPageTest.php index 9a68a06255068ac2f6c1aee69c714084f085c8fd..ffb53140d7b48ec4729a3a28b02646822fc5e7ec 100644 --- a/core/modules/node/src/Tests/Views/FrontPageTest.php +++ b/core/modules/node/src/Tests/Views/FrontPageTest.php @@ -191,7 +191,7 @@ public function testAdminFrontPage() { */ public function testCacheTagsWithCachePluginNone() { $this->enablePageCaching(); - $this->assertFrontPageViewCacheTags(FALSE); + $this->doTestFrontPageViewCacheTags(FALSE); } /** @@ -207,7 +207,7 @@ public function testCacheTagsWithCachePluginTag() { ]); $view->save(); - $this->assertFrontPageViewCacheTags(TRUE); + $this->doTestFrontPageViewCacheTags(TRUE); } /** @@ -227,7 +227,7 @@ public function testCacheTagsWithCachePluginTime() { ]); $view->save(); - $this->assertFrontPageViewCacheTags(TRUE); + $this->doTestFrontPageViewCacheTags(TRUE); } /** @@ -236,7 +236,7 @@ public function testCacheTagsWithCachePluginTime() { * @param bool $do_assert_views_caches * Whether to check Views' result & output caches. */ - protected function assertFrontPageViewCacheTags($do_assert_views_caches) { + protected function doTestFrontPageViewCacheTags($do_assert_views_caches) { $view = Views::getView('frontpage'); $view->setDisplay('page_1'); @@ -248,7 +248,9 @@ protected function assertFrontPageViewCacheTags($do_assert_views_caches) { 'user.permissions', // Default cache contexts of the renderer. 'theme', - 'url.query_args.pagers:0', + 'url.query_args', + // Attached feed. + 'url.site', ]; $cache_context_tags = \Drupal::service('cache_contexts_manager')->convertTokensToKeys($cache_contexts)->getCacheTags(); diff --git a/core/modules/page_cache/src/Tests/PageCacheTagsIntegrationTest.php b/core/modules/page_cache/src/Tests/PageCacheTagsIntegrationTest.php index ebf086714d6aa308e673e5ffbb06b3160e3038fe..c471cea9684327360d9cf9040f4b7211c21bb784 100644 --- a/core/modules/page_cache/src/Tests/PageCacheTagsIntegrationTest.php +++ b/core/modules/page_cache/src/Tests/PageCacheTagsIntegrationTest.php @@ -71,12 +71,7 @@ function testPageCacheTags() { $cache_contexts = [ 'languages:' . LanguageInterface::TYPE_INTERFACE, - 'route.menu_active_trails:account', - 'route.menu_active_trails:footer', - 'route.menu_active_trails:main', - 'route.menu_active_trails:tools', - // The user login block access is not visible on certain routes. - 'route.name', + 'route', 'theme', 'timezone', 'user.permissions', diff --git a/core/modules/rest/src/Plugin/rest/resource/EntityResource.php b/core/modules/rest/src/Plugin/rest/resource/EntityResource.php index 86310863b874fd9bbf034656ef80e9e8557f070d..8aa4dc32554e6891e3abf2d8f080a78f807ac1bf 100644 --- a/core/modules/rest/src/Plugin/rest/resource/EntityResource.php +++ b/core/modules/rest/src/Plugin/rest/resource/EntityResource.php @@ -109,7 +109,10 @@ public function post(EntityInterface $entity = NULL) { $this->logger->notice('Created entity %type with ID %id.', array('%type' => $entity->getEntityTypeId(), '%id' => $entity->id())); // 201 Created responses have an empty body. - return new ResourceResponse(NULL, 201, array('Location' => $entity->url('canonical', ['absolute' => TRUE]))); + $url = $entity->urlInfo('canonical', ['absolute' => TRUE])->toString(TRUE); + $response = new ResourceResponse(NULL, 201, ['Location' => $url->getGeneratedUrl()]); + $response->addCacheableDependency($url); + return $response; } catch (EntityStorageException $e) { throw new HttpException(500, 'Internal Server Error', $e); diff --git a/core/modules/rest/src/RequestHandler.php b/core/modules/rest/src/RequestHandler.php index b558b217d70497c9f1e81bdeb34c313a1ec22b21..ee4b890aa8b96cc24c665bdf9bb1f44788d1ebd4 100644 --- a/core/modules/rest/src/RequestHandler.php +++ b/core/modules/rest/src/RequestHandler.php @@ -7,6 +7,7 @@ namespace Drupal\rest; +use Drupal\Core\Render\RenderContext; use Drupal\Core\Routing\RouteMatchInterface; use Symfony\Component\DependencyInjection\ContainerAwareInterface; use Symfony\Component\DependencyInjection\ContainerAwareTrait; @@ -103,10 +104,23 @@ public function handle(RouteMatchInterface $route_match, Request $request) { } // Serialize the outgoing data for the response, if available. - $data = $response->getResponseData(); - if ($data != NULL) { - $output = $serializer->serialize($data, $format); + if ($response instanceof ResourceResponse && $data = $response->getResponseData()) { + // Serialization can invoke rendering (e.g., generating URLs), but the + // serialization API does not provide a mechanism to collect the + // bubbleable metadata associated with that (e.g., language and other + // contexts), so instead, allow those to "leak" and collect them here in + // a render context. + // @todo Add test coverage for language negotiation contexts in + // https://www.drupal.org/node/2135829. + $context = new RenderContext(); + $output = $this->container->get('renderer')->executeInRenderContext($context, function() use ($serializer, $data, $format) { + return $serializer->serialize($data, $format); + }); $response->setContent($output); + if (!$context->isEmpty()) { + $response->addCacheableDependency($context->pop()); + } + $response->headers->set('Content-Type', $request->getMimeType($format)); // Add rest settings config's cache tags. $response->addCacheableDependency($this->container->get('config.factory')->get('rest.settings')); diff --git a/core/modules/shortcut/shortcut.module b/core/modules/shortcut/shortcut.module index 975a309fca54d3e46942b78d4da2e7ef0c4e03b7..be8383980e9e73f74bebfd0a9e2850a0ff521b75 100644 --- a/core/modules/shortcut/shortcut.module +++ b/core/modules/shortcut/shortcut.module @@ -313,7 +313,6 @@ function shortcut_preprocess_page(&$variables) { 'link' => $link, 'name' => $variables['title'], ); - $query += \Drupal::destination()->getAsArray(); $shortcut_set = shortcut_current_displayed_set(); @@ -341,6 +340,7 @@ function shortcut_preprocess_page(&$variables) { } if (theme_get_setting('third_party_settings.shortcut.module_link')) { + $query += \Drupal::destination()->getAsArray(); $variables['title_suffix']['add_or_remove_shortcut'] = array( '#attached' => array( 'library' => array( diff --git a/core/modules/shortcut/src/Tests/ShortcutTranslationUITest.php b/core/modules/shortcut/src/Tests/ShortcutTranslationUITest.php index e324d80501e3bce253c569709a8ff884f11b6074..9a9113b9771fc9992f6daf15cc6c6afc65130cb7 100644 --- a/core/modules/shortcut/src/Tests/ShortcutTranslationUITest.php +++ b/core/modules/shortcut/src/Tests/ShortcutTranslationUITest.php @@ -21,7 +21,7 @@ class ShortcutTranslationUITest extends ContentTranslationUITestBase { /** * {inheritdoc} */ - protected $defaultCacheContexts = ['languages:language_interface', 'theme', 'user']; + protected $defaultCacheContexts = ['languages:language_interface', 'theme', 'user', 'url.site']; /** * Modules to enable. diff --git a/core/modules/simpletest/src/WebTestBase.php b/core/modules/simpletest/src/WebTestBase.php index 0ea88ba4465e7248ca1e032288d6158c636031ef..7c6a4283177c73c743f0e58856af574a5394f88b 100644 --- a/core/modules/simpletest/src/WebTestBase.php +++ b/core/modules/simpletest/src/WebTestBase.php @@ -32,6 +32,7 @@ use Drupal\node\Entity\NodeType; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\Request; +use Zend\Diactoros\Uri; /** * Test case for typical Drupal tests. @@ -2393,16 +2394,35 @@ protected function clickLinkHelper($label, $index, $pattern) { /** * Takes a path and returns an absolute path. * - * @param $path + * This method is implemented in the way that browsers work, see + * https://url.spec.whatwg.org/#relative-state for more information about the + * possible cases. + * + * @param string $path * A path from the internal browser content. * - * @return + * @return string * The $path with $base_url prepended, if necessary. */ protected function getAbsoluteUrl($path) { global $base_url, $base_path; $parts = parse_url($path); + + // In case the $path has a host, it is already an absolute URL and we are + // done. + if (!empty($parts['host'])) { + return $path; + } + + // In case the $path contains just a query, we turn it into an absolute URL + // with the same scheme, host and path, see + // https://url.spec.whatwg.org/#relative-state. + if (array_keys($parts) === ['query']) { + $current_uri = new Uri($this->getUrl()); + return (string) $current_uri->withQuery($parts['query']); + } + if (empty($parts['host'])) { // Ensure that we have a string (and no xpath object). $path = (string) $path; @@ -2860,6 +2880,17 @@ protected function assertCacheContext($expected_cache_context) { $this->assertTrue(in_array($expected_cache_context, $cache_contexts), "'" . $expected_cache_context . "' is present in the X-Drupal-Cache-Contexts header."); } + /** + * Asserts that a cache context was not present in the last response. + * + * @param string $not_expected_cache_context + * The expected cache context. + */ + protected function assertNoCacheContext($not_expected_cache_context) { + $cache_contexts = explode(' ', $this->drupalGetHeader('X-Drupal-Cache-Contexts')); + $this->assertFalse(in_array($not_expected_cache_context, $cache_contexts), "'" . $not_expected_cache_context . "' is not present in the X-Drupal-Cache-Contexts header."); + } + /** * Asserts whether an expected cache tag was present in the last response. * diff --git a/core/modules/simpletest/tests/src/Unit/WebTestBaseTest.php b/core/modules/simpletest/tests/src/Unit/WebTestBaseTest.php index 27598d480f0e2b72a48b1a06b085535912ff0717..f86b85e3c083fc3ea9e7ef09422359578364b690 100644 --- a/core/modules/simpletest/tests/src/Unit/WebTestBaseTest.php +++ b/core/modules/simpletest/tests/src/Unit/WebTestBaseTest.php @@ -198,4 +198,41 @@ public function testClickLink($expected, $label, $index, $xpath_data) { $this->assertSame($expected, $clicklink_method->invoke($web_test, $label, $index)); } + /** + * @dataProvider providerTestGetAbsoluteUrl + */ + public function testGetAbsoluteUrl($href, $expected_absolute_path) { + $web_test = $this->getMockBuilder('Drupal\simpletest\WebTestBase') + ->disableOriginalConstructor() + ->setMethods(['getUrl']) + ->getMock(); + + $web_test->expects($this->any()) + ->method('getUrl') + ->willReturn('http://example.com/drupal/current-path?foo=baz'); + + $GLOBALS['base_url'] = 'http://example.com'; + $GLOBALS['base_path'] = 'drupal'; + + $get_absolute_url_method = new \ReflectionMethod($web_test, 'getAbsoluteUrl'); + $get_absolute_url_method->setAccessible(TRUE); + + $this->assertSame($expected_absolute_path, $get_absolute_url_method->invoke($web_test, $href)); + } + + /** + * Provides test data for testGetAbsoluteUrl. + * + * @return array + */ + public function providerTestGetAbsoluteUrl() { + $data = []; + $data['host'] = ['http://example.com/drupal/test-example', 'http://example.com/drupal/test-example']; + $data['path'] = ['/drupal/test-example', 'http://example.com/drupal/test-example']; + $data['path-with-query'] = ['/drupal/test-example?foo=bar', 'http://example.com/drupal/test-example?foo=bar']; + $data['just-query'] = ['?foo=bar', 'http://example.com/drupal/current-path?foo=bar']; + + return $data; + } + } diff --git a/core/modules/system/src/Controller/DbUpdateController.php b/core/modules/system/src/Controller/DbUpdateController.php index d207a42ab4948c6b09cddd88e775790dd5efda8f..a55ef02aaf1da216166cd2a0096762ddc0375fa7 100644 --- a/core/modules/system/src/Controller/DbUpdateController.php +++ b/core/modules/system/src/Controller/DbUpdateController.php @@ -220,7 +220,7 @@ protected function info() { $info[] = $this->t("Back up your code. Hint: when backing up module code, do not leave that backup in the 'modules' or 'sites/*/modules' directories as this may confuse Drupal's auto-discovery mechanism."); $info[] = $this->t('Put your site into maintenance mode.', array( - '@url' => $this->url('system.site_maintenance_mode'), + '@url' => Url::fromRoute('system.site_maintenance_mode')->toString(TRUE)->getGeneratedUrl(), )); $info[] = $this->t('Back up your database. This process will change your database values and in case of emergency you may need to revert to a backup.'); $info[] = $this->t('Install your new files in the appropriate location, as described in the handbook.'); @@ -388,7 +388,7 @@ protected function results() { $dblog_exists = $this->moduleHandler->moduleExists('dblog'); if ($dblog_exists && $this->account->hasPermission('access site reports')) { $log_message = $this->t('All errors have been logged.', array( - '@url' => $this->url('dblog.overview'), + '@url' => Url::fromRoute('dblog.overview')->toString(TRUE)->getGeneratedUrl(), )); } else { @@ -396,7 +396,7 @@ protected function results() { } if (!empty($_SESSION['update_success'])) { - $message = '

' . $this->t('Updates were attempted. If you see no failures below, you may proceed happily back to your site. Otherwise, you may need to update your database manually.', array('@url' => $this->url(''))) . ' ' . $log_message . '

'; + $message = '

' . $this->t('Updates were attempted. If you see no failures below, you may proceed happily back to your site. Otherwise, you may need to update your database manually.', array('@url' => Url::fromRoute('')->toString(TRUE)->getGeneratedUrl())) . ' ' . $log_message . '

'; } else { $last = reset($_SESSION['updates_remaining']); @@ -497,7 +497,7 @@ protected function results() { */ public function requirements($severity, array $requirements) { $options = $severity == REQUIREMENT_WARNING ? array('continue' => 1) : array(); - $try_again_url = $this->url('system.db_update', $options); + $try_again_url = Url::fromRoute('system.db_update', $options)->toString(TRUE)->getGeneratedUrl(); $build['status_report'] = array( '#theme' => 'status_report', diff --git a/core/modules/system/src/Tests/Common/EarlyRenderingControllerTest.php b/core/modules/system/src/Tests/Common/EarlyRenderingControllerTest.php index 1759880620ec8c09cabc3c56d2e7d4a73186559e..b3b363ad92ab2bbd8ea5b89098d6ec01fd9589cc 100644 --- a/core/modules/system/src/Tests/Common/EarlyRenderingControllerTest.php +++ b/core/modules/system/src/Tests/Common/EarlyRenderingControllerTest.php @@ -42,6 +42,16 @@ function testEarlyRendering() { $this->assertRaw('Hello world!'); $this->assertCacheTag('foo'); + // AjaxResponse: non-early & early. + // @todo Add cache tags assertion when AjaxResponse is made cacheable in + // https://www.drupal.org/node/956186. + $this->drupalGet(Url::fromRoute('early_rendering_controller_test.ajax_response')); + $this->assertResponse(200); + $this->assertRaw('Hello world!'); + $this->drupalGet(Url::fromRoute('early_rendering_controller_test.ajax_response.early')); + $this->assertResponse(200); + $this->assertRaw('Hello world!'); + // Basic Response object: non-early & early. $this->drupalGet(Url::fromRoute('early_rendering_controller_test.response')); $this->assertResponse(200); diff --git a/core/modules/system/src/Tests/Pager/PagerTest.php b/core/modules/system/src/Tests/Pager/PagerTest.php index 87730fff58ae5732db5a369fffef8f8488716614..c556c292af67ced761e20080b0f8317f01bfff00 100644 --- a/core/modules/system/src/Tests/Pager/PagerTest.php +++ b/core/modules/system/src/Tests/Pager/PagerTest.php @@ -65,7 +65,7 @@ function testActiveClass() { $elements = $this->xpath('//li[contains(@class, :class)]/a', array(':class' => 'pager__item--last')); preg_match('@page=(\d+)@', $elements[0]['href'], $matches); $current_page = (int) $matches[1]; - $this->drupalGet($GLOBALS['base_root'] . $elements[0]['href'], array('external' => TRUE)); + $this->drupalGet($GLOBALS['base_root'] . parse_url($this->getUrl())['path'] . $elements[0]['href'], array('external' => TRUE)); $this->assertPagerItems($current_page); } @@ -77,18 +77,22 @@ protected function testPagerQueryParametersAndCacheContext() { $this->drupalGet('pager-test/query-parameters'); $this->assertText(t('Pager calls: 0'), 'Initial call to pager shows 0 calls.'); $this->assertText('pager.0.0'); + $this->assertCacheContext('url.query_args'); // Go to last page, the count of pager calls need to go to 1. $elements = $this->xpath('//li[contains(@class, :class)]/a', array(':class' => 'pager__item--last')); - $this->drupalGet($GLOBALS['base_root'] . $elements[0]['href'], array('external' => TRUE)); + $this->drupalGet($this->getAbsoluteUrl($elements[0]['href'])); $this->assertText(t('Pager calls: 1'), 'First link call to pager shows 1 calls.'); $this->assertText('pager.0.60'); + $this->assertCacheContext('url.query_args'); // Go back to first page, the count of pager calls need to go to 2. $elements = $this->xpath('//li[contains(@class, :class)]/a', array(':class' => 'pager__item--first')); - $this->drupalGet($GLOBALS['base_root'] . $elements[0]['href'], array('external' => TRUE)); + $this->drupalGet($this->getAbsoluteUrl($elements[0]['href'])); + $this->drupalGet($GLOBALS['base_root'] . parse_url($this->getUrl())['path'] . $elements[0]['href'], array('external' => TRUE)); $this->assertText(t('Pager calls: 2'), 'Second link call to pager shows 2 calls.'); $this->assertText('pager.0.0'); + $this->assertCacheContext('url.query_args'); } /** diff --git a/core/modules/system/src/Tests/Render/UrlBubbleableMetadataBubblingTest.php b/core/modules/system/src/Tests/Render/UrlBubbleableMetadataBubblingTest.php new file mode 100644 index 0000000000000000000000000000000000000000..2ae068ea821a0eabc8b726ae687747d83f0bbb7d --- /dev/null +++ b/core/modules/system/src/Tests/Render/UrlBubbleableMetadataBubblingTest.php @@ -0,0 +1,47 @@ +dumpHeaders = TRUE; + } + + /** + * Tests that URL bubbleable metadata is correctly bubbled. + */ + public function testUrlBubbleableMetadataBubbling() { + // Test that regular URLs bubble up bubbleable metadata when converted to + // string. + $url = Url::fromRoute('cache_test.url_bubbling'); + $this->drupalGet($url); + $this->assertCacheContext('url.site'); + $this->assertRaw($url->setAbsolute()->toString()); + } + +} diff --git a/core/modules/system/system.module b/core/modules/system/system.module index 7a5aed0b9bbcfc6a8dfeb190108ad784ebf7868a..a826e69612a04683f994ca904099404b126cf637 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -657,7 +657,7 @@ function system_js_settings_alter(&$settings, AttachedAssetsInterface $assets) { $pathPrefix = ''; $current_query = $request->query->all(); - Url::fromRoute('', [], array('script' => &$scriptPath, 'prefix' => &$pathPrefix))->toString(); + Url::fromRoute('', [], array('script' => &$scriptPath, 'prefix' => &$pathPrefix))->toString(TRUE); $current_path = \Drupal::routeMatch()->getRouteName() ? Url::fromRouteMatch(\Drupal::routeMatch())->getInternalPath() : ''; $current_path_is_admin = \Drupal::service('router.admin_context')->isAdminRoute(); $path_settings = [ diff --git a/core/modules/system/system.routing.yml b/core/modules/system/system.routing.yml index a386196c34cdf1c7d15c9d5e69b6616ae0bff98b..9d806bb532cefe95d8c22ea7db75e9d26f137c1c 100644 --- a/core/modules/system/system.routing.yml +++ b/core/modules/system/system.routing.yml @@ -385,7 +385,7 @@ system.theme_settings_theme: '': path: '' options: - _only_fragment: TRUE + _no_path: TRUE requirements: _access: 'TRUE' diff --git a/core/modules/system/tests/modules/cache_test/cache_test.routing.yml b/core/modules/system/tests/modules/cache_test/cache_test.routing.yml new file mode 100644 index 0000000000000000000000000000000000000000..fb87d3ded0e8b4bcf28702edf7277f51a75fd15d --- /dev/null +++ b/core/modules/system/tests/modules/cache_test/cache_test.routing.yml @@ -0,0 +1,6 @@ +cache_test.url_bubbling: + path: '/cache-test/url-bubbling' + defaults: + _controller: '\Drupal\cache_test\Controller\CacheTestController::urlBubbling' + requirements: + _access: 'TRUE' diff --git a/core/modules/system/tests/modules/cache_test/src/Controller/CacheTestController.php b/core/modules/system/tests/modules/cache_test/src/Controller/CacheTestController.php new file mode 100644 index 0000000000000000000000000000000000000000..78c29b3ed0241fa49fa8bd310323c3d34e77a670 --- /dev/null +++ b/core/modules/system/tests/modules/cache_test/src/Controller/CacheTestController.php @@ -0,0 +1,29 @@ +')->setAbsolute(); + return [ + '#markup' => SafeMarkup::format('This URL is early-rendered: !url. Yet, its bubbleable metadata should be bubbled.', ['!url' => $url->toString()]) + ]; + } + +} diff --git a/core/modules/system/tests/modules/early_rendering_controller_test/early_rendering_controller_test.routing.yml b/core/modules/system/tests/modules/early_rendering_controller_test/early_rendering_controller_test.routing.yml index 4e050e4be1c629b6668a8b28461af364b3245711..b71fd822b81cf6ab3acddf3396aa3918a01ccfdf 100644 --- a/core/modules/system/tests/modules/early_rendering_controller_test/early_rendering_controller_test.routing.yml +++ b/core/modules/system/tests/modules/early_rendering_controller_test/early_rendering_controller_test.routing.yml @@ -12,6 +12,20 @@ early_rendering_controller_test.render_array.early: requirements: _access: 'TRUE' +# Controller returning an AjaxResponse. +early_rendering_controller_test.ajax_response: + path: '/early-rendering-controller-test/ajax-response' + defaults: + _controller: '\Drupal\early_rendering_controller_test\EarlyRenderingTestController::ajaxResponse' + requirements: + _access: 'TRUE' +early_rendering_controller_test.ajax_response.early: + path: '/early-rendering-controller-test/ajax-response/early' + defaults: + _controller: '\Drupal\early_rendering_controller_test\EarlyRenderingTestController::ajaxResponseEarly' + requirements: + _access: 'TRUE' + # Controller returning a basic Response object. early_rendering_controller_test.response: path: '/early-rendering-controller-test/response' diff --git a/core/modules/system/tests/modules/early_rendering_controller_test/src/EarlyRenderingTestController.php b/core/modules/system/tests/modules/early_rendering_controller_test/src/EarlyRenderingTestController.php index f551a25698c0e4180c562c49bf70062fff72ce25..d560f94a3f95c6d7528de485245b6f5177d517f7 100644 --- a/core/modules/system/tests/modules/early_rendering_controller_test/src/EarlyRenderingTestController.php +++ b/core/modules/system/tests/modules/early_rendering_controller_test/src/EarlyRenderingTestController.php @@ -7,6 +7,8 @@ namespace Drupal\early_rendering_controller_test; +use Drupal\Core\Ajax\AjaxResponse; +use Drupal\Core\Ajax\InsertCommand; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Render\RendererInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -76,6 +78,18 @@ public function renderArrayEarly() { ]; } + public function ajaxResponse() { + $response = new AjaxResponse(); + $response->addCommand(new InsertCommand(NULL, $this->renderArray())); + return $response; + } + + public function ajaxResponseEarly() { + $response = new AjaxResponse(); + $response->addCommand(new InsertCommand(NULL, $this->renderArrayEarly())); + return $response; + } + public function response() { return new Response('Hello world!'); } diff --git a/core/modules/views/src/Controller/ViewAjaxController.php b/core/modules/views/src/Controller/ViewAjaxController.php index 83a203be024bbb6883a6580194591054026eae21..01e69ef05f8c507592a39b73ab2c1f0d1e7e9b5f 100644 --- a/core/modules/views/src/Controller/ViewAjaxController.php +++ b/core/modules/views/src/Controller/ViewAjaxController.php @@ -14,6 +14,8 @@ use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\EventSubscriber\AjaxResponseSubscriber; use Drupal\Core\Path\CurrentPathStack; +use Drupal\Core\Render\BubbleableMetadata; +use Drupal\Core\Render\RenderContext; use Drupal\Core\Render\RendererInterface; use Drupal\Core\Routing\RedirectDestinationInterface; use Drupal\views\Ajax\ScrollTopCommand; @@ -174,9 +176,18 @@ public function ajaxView(Request $request) { // Reuse the same DOM id so it matches that in drupalSettings. $view->dom_id = $dom_id; - if ($preview = $view->preview($display_id, $args)) { - $response->addCommand(new ReplaceCommand(".js-view-dom-id-$dom_id", $preview)); + $context = new RenderContext(); + $preview = $this->renderer->executeInRenderContext($context, function() use ($view, $display_id, $args) { + return $view->preview($display_id, $args); + }); + if (!$context->isEmpty()) { + $bubbleable_metadata = $context->pop(); + BubbleableMetadata::createFromRenderArray($preview) + ->merge($bubbleable_metadata) + ->applyTo($preview); } + $response->addCommand(new ReplaceCommand(".js-view-dom-id-$dom_id", $preview)); + return $response; } else { diff --git a/core/modules/views/src/Plugin/views/pager/Full.php b/core/modules/views/src/Plugin/views/pager/Full.php index da1a0ec7b99e003987b2255b46c6a3c3988e4a15..a0af225493e8dacc002e04630cb15a02311fb010 100644 --- a/core/modules/views/src/Plugin/views/pager/Full.php +++ b/core/modules/views/src/Plugin/views/pager/Full.php @@ -96,6 +96,7 @@ public function render($input) { '#element' => $this->options['id'], '#parameters' => $input, '#quantity' => $this->options['quantity'], + '#route_name' => !empty($this->view->live_preview) ? '' : '', ); } diff --git a/core/modules/views/src/Plugin/views/pager/Mini.php b/core/modules/views/src/Plugin/views/pager/Mini.php index 6547242945728d18ee33b88c6a36b205805f7548..72f5d1c387776b74800372dab4f1a538b2fa5ffd 100644 --- a/core/modules/views/src/Plugin/views/pager/Mini.php +++ b/core/modules/views/src/Plugin/views/pager/Mini.php @@ -103,6 +103,7 @@ public function render($input) { '#tags' => $tags, '#element' => $this->options['id'], '#parameters' => $input, + '#route_name' => !empty($this->view->live_preview) ? '' : '', ); } diff --git a/core/modules/views/src/Plugin/views/pager/SqlBase.php b/core/modules/views/src/Plugin/views/pager/SqlBase.php index b2d73892483db7d824b466bf813c7f66f579cd33..4262701d9f16cfc85f5d7b8710f526458102ca60 100644 --- a/core/modules/views/src/Plugin/views/pager/SqlBase.php +++ b/core/modules/views/src/Plugin/views/pager/SqlBase.php @@ -382,14 +382,9 @@ public function isCacheable() { * {@inheritdoc} */ public function getCacheContexts() { - $contexts = ['url.query_args.pagers:' . $this->options['id']]; - if ($this->options['expose']['items_per_page']) { - $contexts[] = 'url.query_args:items_per_page'; - } - if ($this->options['expose']['offset']) { - $contexts[] = 'url.query_args:offset'; - } - return $contexts; + // The rendered link needs to play well with any other query parameter used + // on the page, like other pagers and exposed filter. + return ['url.query_args']; } } diff --git a/core/modules/views/src/Plugin/views/style/Table.php b/core/modules/views/src/Plugin/views/style/Table.php index 4efeda2e6a47bbc641c489da0cf94c8fdb3c7666..8a594cf0afdb37a65d4ea565362a934a1c0636c0 100644 --- a/core/modules/views/src/Plugin/views/style/Table.php +++ b/core/modules/views/src/Plugin/views/style/Table.php @@ -444,8 +444,9 @@ public function getCacheContexts() { foreach ($this->options['info'] as $field_id => $info) { if (!empty($info['sortable'])) { - $contexts[] = 'url.query_args:order'; - $contexts[] = 'url.query_args:sort'; + // The rendered link needs to play well with any other query parameter + // used on the page, like pager and exposed filter. + $contexts[] = 'url.query_args'; break; } } diff --git a/core/modules/views/src/Tests/GlossaryTest.php b/core/modules/views/src/Tests/GlossaryTest.php index 7d55f85e3ec04678a896cb559e3408820f2704ad..72854e90e7eff97bbdb8812986bb4c7c55c89cd2 100644 --- a/core/modules/views/src/Tests/GlossaryTest.php +++ b/core/modules/views/src/Tests/GlossaryTest.php @@ -71,17 +71,29 @@ public function testGlossaryView() { $url = Url::fromRoute('view.glossary.page_1'); // Verify cache tags. - $this->assertPageCacheContextsAndTags($url, ['languages:' . LanguageInterface::TYPE_CONTENT, 'languages:' . LanguageInterface::TYPE_INTERFACE, 'theme', 'url', 'user.node_grants:view', 'user.permissions'], [ - 'config:views.view.glossary', - 'node:' . $nodes_by_char['a'][0]->id(), 'node:' . $nodes_by_char['a'][1]->id(), 'node:' . $nodes_by_char['a'][2]->id(), - 'node_list', - 'user:0', - 'user_list', - 'rendered', - // FinishResponseSubscriber adds this cache tag to responses that have the - // 'user.permissions' cache context for anonymous users. - 'config:user.role.anonymous', - ]); + $this->assertPageCacheContextsAndTags( + $url, + [ + 'languages:' . LanguageInterface::TYPE_CONTENT, + 'languages:' . LanguageInterface::TYPE_INTERFACE, + 'theme', + 'url', + 'user.node_grants:view', + 'user.permissions', + 'route', + ], + [ + 'config:views.view.glossary', + 'node:' . $nodes_by_char['a'][0]->id(), 'node:' . $nodes_by_char['a'][1]->id(), 'node:' . $nodes_by_char['a'][2]->id(), + 'node_list', + 'user:0', + 'user_list', + 'rendered', + // FinishResponseSubscriber adds this cache tag to responses that have the + // 'user.permissions' cache context for anonymous users. + 'config:user.role.anonymous', + ] + ); // Check the actual page response. $this->drupalGet($url); diff --git a/core/modules/views/src/Tests/Handler/FieldWebTest.php b/core/modules/views/src/Tests/Handler/FieldWebTest.php index 39e1eb2b04fe3666bc2257a02f824b036ad99783..3ffec6b8d5e2b8d17f8d1d7fa0654778e2f72f27 100644 --- a/core/modules/views/src/Tests/Handler/FieldWebTest.php +++ b/core/modules/views/src/Tests/Handler/FieldWebTest.php @@ -68,23 +68,21 @@ public function testClickSorting() { $this->assertResponse(200); // Only the id and name should be click sortable, but not the name. - $this->assertLinkByHref(\Drupal::url('view.test_click_sort.page_1', [], ['query' => ['order' => 'id', 'sort' => 'asc']])); - $this->assertLinkByHref(\Drupal::url('view.test_click_sort.page_1', [], ['query' => ['order' => 'name', 'sort' => 'desc']])); - $this->assertNoLinkByHref(\Drupal::url('view.test_click_sort.page_1', [], ['query' => ['order' => 'created']])); + $this->assertLinkByHref(\Drupal::url('', [], ['query' => ['order' => 'id', 'sort' => 'asc']])); + $this->assertLinkByHref(\Drupal::url('', [], ['query' => ['order' => 'name', 'sort' => 'desc']])); + $this->assertNoLinkByHref(\Drupal::url('', [], ['query' => ['order' => 'created']])); // Check that the view returns the click sorting cache contexts. $expected_contexts = [ 'languages:language_interface', 'theme', - 'url.query_args.pagers:0', - 'url.query_args:order', - 'url.query_args:sort', + 'url.query_args', ]; $this->assertCacheContexts($expected_contexts); // Clicking a click sort should change the order. $this->clickLink(t('ID')); - $this->assertLinkByHref(\Drupal::url('view.test_click_sort.page_1', [], ['query' => ['order' => 'id', 'sort' => 'desc']])); + $this->assertLinkByHref(\Drupal::url('', [], ['query' => ['order' => 'id', 'sort' => 'desc']])); // Check that the output has the expected order (asc). $ids = $this->clickSortLoadIdsFromOutput(); $this->assertEqual($ids, range(1, 5)); diff --git a/core/modules/views/src/Tests/Plugin/ExposedFormTest.php b/core/modules/views/src/Tests/Plugin/ExposedFormTest.php index ffc9bbe513db7d0bfa322370033f37f4f30e69b9..1427c5de50816e24d229a69e9feb4a769ac3bb5a 100644 --- a/core/modules/views/src/Tests/Plugin/ExposedFormTest.php +++ b/core/modules/views/src/Tests/Plugin/ExposedFormTest.php @@ -206,11 +206,7 @@ public function testExposedSortAndItemsPerPage() { 'languages:language_interface', 'entity_test_view_grants', 'theme', - 'url.query_args.pagers:0', - 'url.query_args:items_per_page', - 'url.query_args:offset', - 'url.query_args:sort_order', - 'url.query_args:sort_by', + 'url.query_args', 'languages:language_content' ]; diff --git a/core/modules/views/src/Tests/Plugin/PagerTest.php b/core/modules/views/src/Tests/Plugin/PagerTest.php index 4a75fdc5f355fb20f319f6f55ea472dfa748ced7..c1c6c97bb1335955111398de41c34a0af1d84335 100644 --- a/core/modules/views/src/Tests/Plugin/PagerTest.php +++ b/core/modules/views/src/Tests/Plugin/PagerTest.php @@ -261,7 +261,7 @@ public function testNormalPager() { // Test pager cache contexts. $this->drupalGet('test_pager_full'); - $this->assertCacheContexts(['languages:language_interface', 'theme', 'timezone', 'url.query_args.pagers:0', 'user.node_grants:view']); + $this->assertCacheContexts(['languages:language_interface', 'theme', 'timezone', 'url.query_args', 'user.node_grants:view']); } /** diff --git a/core/modules/views/src/Tests/RenderCacheIntegrationTest.php b/core/modules/views/src/Tests/RenderCacheIntegrationTest.php index b1235b1ecbaf046dcb4621e80a43c18c908a4af4..de7903981b02c1abd492466ec11643b40a4b0b04 100644 --- a/core/modules/views/src/Tests/RenderCacheIntegrationTest.php +++ b/core/modules/views/src/Tests/RenderCacheIntegrationTest.php @@ -292,7 +292,7 @@ public function testViewAddCacheMetadata() { $view = View::load('test_display'); $view->save(); - $this->assertEqual(['languages:' . LanguageInterface::TYPE_CONTENT, 'languages:' . LanguageInterface::TYPE_INTERFACE, 'url.query_args.pagers:0', 'user.node_grants:view', 'user.permissions'], $view->getDisplay('default')['cache_metadata']['contexts']); + $this->assertEqual(['languages:' . LanguageInterface::TYPE_CONTENT, 'languages:' . LanguageInterface::TYPE_INTERFACE, 'url.query_args', 'user.node_grants:view', 'user.permissions'], $view->getDisplay('default')['cache_metadata']['contexts']); } } diff --git a/core/modules/views/tests/src/Unit/Controller/ViewAjaxControllerTest.php b/core/modules/views/tests/src/Unit/Controller/ViewAjaxControllerTest.php index dc5a898f69ac44853b05bdfba0475b7e2acf8b9f..f054b912d03b3e8df335f9265ee542ec99d92c8f 100644 --- a/core/modules/views/tests/src/Unit/Controller/ViewAjaxControllerTest.php +++ b/core/modules/views/tests/src/Unit/Controller/ViewAjaxControllerTest.php @@ -7,11 +7,13 @@ namespace Drupal\Tests\views\Unit\Controller { +use Drupal\Core\Render\RenderContext; use Drupal\Tests\UnitTestCase; use Drupal\views\Ajax\ViewAjaxResponse; use Drupal\views\Controller\ViewAjaxController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpFoundation\RequestStack; /** * @coversDefaultClass \Drupal\views\Controller\ViewAjaxController @@ -76,6 +78,11 @@ protected function setUp() { $elements['#attached'] = []; return isset($elements['#markup']) ? $elements['#markup'] : ''; })); + $this->renderer->expects($this->any()) + ->method('executeInRenderContext') + ->willReturnCallback(function (RenderContext $context, callable $callable) { + return $callable(); + }); $this->currentPath = $this->getMockBuilder('Drupal\Core\Path\CurrentPathStack') ->disableOriginalConstructor() ->getMock(); @@ -83,8 +90,23 @@ protected function setUp() { $this->viewAjaxController = new ViewAjaxController($this->viewStorage, $this->executableFactory, $this->renderer, $this->currentPath, $this->redirectDestination); + $request_stack = new RequestStack(); + $request_stack->push(new Request()); + $args = [ + $this->getMock('\Drupal\Core\Controller\ControllerResolverInterface'), + $this->getMock('\Drupal\Core\Theme\ThemeManagerInterface'), + $this->getMock('\Drupal\Core\Render\ElementInfoManagerInterface'), + $this->getMock('\Drupal\Core\Render\RenderCacheInterface'), + $request_stack, + [ + 'required_cache_contexts' => [ + 'languages:language_interface', + 'theme', + ], + ], + ]; $this->renderer = $this->getMockBuilder('Drupal\Core\Render\Renderer') - ->disableOriginalConstructor() + ->setConstructorArgs($args) ->setMethods(NULL) ->getMock(); $container = new ContainerBuilder(); diff --git a/core/modules/views/views.theme.inc b/core/modules/views/views.theme.inc index fbe962e3da35eea08d8e5ef056e32a450459ffae..7728d89f335dbe0934061f4185fc8120ea8a4351 100644 --- a/core/modules/views/views.theme.inc +++ b/core/modules/views/views.theme.inc @@ -494,7 +494,9 @@ function template_preprocess_views_view_table(&$variables) { 'attributes' => array('title' => $title), 'query' => $query, ); - $variables['header'][$field]['content'] = \Drupal::l($label, new Url('', [], $link_options)); + // It is ok to specify no URL path here as we will always reload the + // current page. + $variables['header'][$field]['content'] = \Drupal::l($label, new Url('', [], $link_options)); } $variables['header'][$field]['default_classes'] = $fields[$field]->options['element_default_classes']; @@ -1050,6 +1052,10 @@ function template_preprocess_views_mini_pager(&$variables) { } $variables['items']['next']['attributes'] = new Attribute(); } + + // This is is based on the entire current query string. We need to ensure + // cacheability is affected accordingly. + $variables['#cache']['contexts'][] = 'url.query_args'; } /** diff --git a/core/tests/Drupal/Tests/Core/Render/Element/RenderElementTest.php b/core/tests/Drupal/Tests/Core/Render/Element/RenderElementTest.php index a554b8a5b20c32dd75ef1917eb6d89d1204b144d..35a0dab5c589f852f4a24f3a7920289c1d87bf16 100644 --- a/core/tests/Drupal/Tests/Core/Render/Element/RenderElementTest.php +++ b/core/tests/Drupal/Tests/Core/Render/Element/RenderElementTest.php @@ -9,6 +9,7 @@ use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\Form\FormBuilderInterface; +use Drupal\Core\GeneratedUrl; use Drupal\Core\Render\Element\RenderElement; use Drupal\Tests\UnitTestCase; use Symfony\Component\HttpFoundation\Request; @@ -56,8 +57,8 @@ public function testPreRenderAjaxForm() { $prophecy = $this->prophesize('Drupal\Core\Routing\UrlGeneratorInterface'); $url = '/test?foo=bar&ajax_form=1'; - $prophecy->generateFromRoute('', [], ['query' => ['foo' => 'bar', FormBuilderInterface::AJAX_FORM_REQUEST => TRUE]], FALSE) - ->willReturn($url); + $prophecy->generateFromRoute('', [], ['query' => ['foo' => 'bar', FormBuilderInterface::AJAX_FORM_REQUEST => TRUE]], TRUE) + ->willReturn((new GeneratedUrl())->setCacheContexts(['route'])->setGeneratedUrl($url)); $url_generator = $prophecy->reveal(); $this->container->set('url_generator', $url_generator); @@ -87,8 +88,8 @@ public function testPreRenderAjaxFormWithQueryOptions() { $prophecy = $this->prophesize('Drupal\Core\Routing\UrlGeneratorInterface'); $url = '/test?foo=bar&other=query&ajax_form=1'; - $prophecy->generateFromRoute('', [], ['query' => ['foo' => 'bar', 'other' => 'query', FormBuilderInterface::AJAX_FORM_REQUEST => TRUE]], FALSE) - ->willReturn($url); + $prophecy->generateFromRoute('', [], ['query' => ['foo' => 'bar', 'other' => 'query', FormBuilderInterface::AJAX_FORM_REQUEST => TRUE]], TRUE) + ->willReturn((new GeneratedUrl())->setCacheContexts(['route'])->setGeneratedUrl($url)); $url_generator = $prophecy->reveal(); $this->container->set('url_generator', $url_generator); diff --git a/core/tests/Drupal/Tests/Core/Render/MetadataBubblingUrlGeneratorTest.php b/core/tests/Drupal/Tests/Core/Render/MetadataBubblingUrlGeneratorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..29e3fbf70ebbf510cd085a6bbc26095bc42d3f81 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Render/MetadataBubblingUrlGeneratorTest.php @@ -0,0 +1,84 @@ +renderer = $this->getMock('\Drupal\Core\Render\RendererInterface'); + $this->renderer->expects($this->any()) + ->method('hasRenderContext') + ->willReturn(TRUE); + + $this->generator = new MetadataBubblingUrlGenerator($this->generator, $this->renderer); + } + + /** + * Tests bubbling of cacheable metadata for URLs. + * + * @param bool $collect_bubbleable_metadata + * Whether bubbleable metadata should be collected. + * @param int $invocations + * The expected amount of invocations for the ::bubble() method. + * @param array $options + * The URL options. + * + * @covers ::bubble + * + * @dataProvider providerUrlBubbleableMetadataBubbling + */ + public function testUrlBubbleableMetadataBubbling($collect_bubbleable_metadata, $invocations, array $options) { + $self = $this; + + $this->renderer->expects($this->exactly($invocations)) + ->method('render') + ->willReturnCallback(function ($build) use ($self) { + $self->assertTrue(!empty($build['#cache'])); + }); + + $url = new Url('test_1', [], $options); + $url->setUrlGenerator($this->generator); + $url->toString($collect_bubbleable_metadata); + } + + /** + * Data provider for ::testUrlBubbleableMetadataBubbling(). + */ + public function providerUrlBubbleableMetadataBubbling() { + return [ + // No bubbling when bubbleable metadata is collected. + [TRUE, 0, []], + // Bubbling when bubbleable metadata is not collected. + [FALSE, 1, []], + ]; + } + +} diff --git a/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php b/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php index 9f25596812cbdf4b3f45190f98aad5c5f5f2a7a5..9ab89785e929568b1384a89b3816bf07a46b9b0d 100644 --- a/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php +++ b/core/tests/Drupal/Tests/Core/Render/RendererTestBase.php @@ -11,10 +11,9 @@ use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Cache\Context\ContextCacheKeys; use Drupal\Core\Cache\MemoryBackend; -use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Render\Element; -use Drupal\Core\Render\Renderer; use Drupal\Core\Render\RenderCache; +use Drupal\Core\Render\Renderer; use Drupal\Tests\UnitTestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpFoundation\Request; @@ -102,6 +101,9 @@ protected function setUp() { $this->themeManager = $this->getMock('Drupal\Core\Theme\ThemeManagerInterface'); $this->elementInfo = $this->getMock('Drupal\Core\Render\ElementInfoManagerInterface'); $this->requestStack = new RequestStack(); + $request = new Request(); + $request->server->set('REQUEST_TIME', $_SERVER['REQUEST_TIME']); + $this->requestStack->push($request); $this->cacheFactory = $this->getMock('Drupal\Core\Cache\CacheFactoryInterface'); $this->cacheContextsManager = $this->getMockBuilder('Drupal\Core\Cache\Context\CacheContextsManager') ->disableOriginalConstructor() @@ -129,7 +131,7 @@ protected function setUp() { return new ContextCacheKeys($keys, new CacheableMetadata()); }); $this->renderCache = new RenderCache($this->requestStack, $this->cacheFactory, $this->cacheContextsManager); - $this->renderer = new Renderer($this->controllerResolver, $this->themeManager, $this->elementInfo, $this->renderCache, $this->rendererConfig); + $this->renderer = new Renderer($this->controllerResolver, $this->themeManager, $this->elementInfo, $this->renderCache, $this->requestStack, $this->rendererConfig); $container = new ContainerBuilder(); $container->set('cache_contexts_manager', $this->cacheContextsManager); diff --git a/core/tests/Drupal/Tests/Core/Routing/UrlGeneratorTest.php b/core/tests/Drupal/Tests/Core/Routing/UrlGeneratorTest.php index e78a0b07ce516d21ca740c89b5112567fcb5c46c..a0f621370bd56b2f794550d7fd67b5353ec16a6d 100644 --- a/core/tests/Drupal/Tests/Core/Routing/UrlGeneratorTest.php +++ b/core/tests/Drupal/Tests/Core/Routing/UrlGeneratorTest.php @@ -23,6 +23,7 @@ /** * Confirm that the UrlGenerator is functioning properly. * + * @coversDefaultClass \Drupal\Core\Routing\UrlGenerator * @group Routing */ class UrlGeneratorTest extends UnitTestCase { @@ -70,11 +71,14 @@ protected function setUp() { $first_route = new Route('/test/one'); $second_route = new Route('/test/two/{narf}'); $third_route = new Route('/test/two/'); - $fourth_route = new Route('/test/four', array(), array(), array(), '', ['https']); + $fourth_route = new Route('/test/four', [], [], [], '', ['https']); + $none_route = new Route('', [], [], ['_no_path' => TRUE]); + $routes->add('test_1', $first_route); $routes->add('test_2', $second_route); $routes->add('test_3', $third_route); $routes->add('test_4', $fourth_route); + $routes->add('', $none_route); // Create a route provider stub. $provider = $this->getMockBuilder('Drupal\Core\Routing\RouteProvider') @@ -85,22 +89,26 @@ protected function setUp() { // are not passed in and default to an empty array. $route_name_return_map = $routes_names_return_map = array(); $return_map_values = array( - array( + [ 'route_name' => 'test_1', 'return' => $first_route, - ), - array( + ], + [ 'route_name' => 'test_2', 'return' => $second_route, - ), - array( + ], + [ 'route_name' => 'test_3', 'return' => $third_route, - ), - array( + ], + [ 'route_name' => 'test_4', 'return' => $fourth_route, - ), + ], + [ + 'route_name' => '', + 'return' => $none_route, + ], ); foreach ($return_map_values as $values) { $route_name_return_map[] = array($values['route_name'], $values['return']); @@ -414,6 +422,43 @@ public function testPathBasedURLGeneration() { } } + /** + * Tests generating a relative URL with no path. + * + * @param array $options + * An array of URL options. + * @param string $expected_url + * The expected relative URL. + * + * @covers ::generateFromRoute + * + * @dataProvider providerTestNoPath + */ + public function testNoPath($options, $expected_url) { + $url = $this->generator->generateFromRoute('', [], $options); + $this->assertEquals($expected_url, $url); + } + + /** + * Data provider for ::testNoPath(). + */ + public function providerTestNoPath() { + return [ + // Empty options. + [[], ''], + // Query parameters only. + [['query' => ['foo' => 'bar']], '?foo=bar'], + // Multiple query parameters. + [['query' => ['foo' => 'bar', 'baz' => '']], '?foo=bar&baz='], + // Fragment only. + [['fragment' => 'foo'], '#foo'], + // Query parameters and fragment. + [['query' => ['bar' => 'baz'], 'fragment' => 'foo'], '?bar=baz#foo'], + // Multiple query parameters and fragment. + [['query' => ['bar' => 'baz', 'foo' => 'bar'], 'fragment' => 'foo'], '?bar=baz&foo=bar#foo'], + ]; + } + /** * Asserts \Drupal\Core\Routing\UrlGenerator::generateFromRoute()'s output. *