Proposed commit message:

Issue #2351015 by plach, effulgentsia, Wim Leers, dawehner, martin107, damiankloip, cilefen, Fabianx, catch, pwolanin, Damien Tournoud, znerol, YesCT, larowlan: URL generation does not bubble cache contexts

Problem/Motivation

From #48:

Thought about this overnight, there is a security issue if we don't do this I think.

1. User A visits a page, the HTML is cached with their CSRF token.

2. User B visits the page, the User A's CSRF token is in the HTML. Clicking the link will fail.

3. However, User B then takes the link with User A's token, and creates a CSRF attack using that.

4. User A visits the page with the CSRF attack, and the link is valid for them.

CSRF token is based on session, so it'll work as long as the user has the same session ID, which could be a long time.

Proposed resolution

  • Keep \Drupal\Core\Routing\UrlGenerator, unchanged, but make it a private service called e.g. url_generator.uncacheable
  • Add \Drupal\Core\Render\UrlGenerator, make it the new url_generator service
  • (This is similar to what we do with config.storage & config.storage.active.)
  • (This also happens to be exactly what Fabian and Crell concluded at #2450993-24: Rendered Cache Metadata created during the main controller request gets lost, point 6. :))
  • (Also: this is a great validation of the architecture: we have a flawed URL generation API, and the fix is filthy, but we can fix it by just swapping the service out for another implementation that thinly layers on top of the existing implementation, but also is coupled to the renderer. Isolated crappiness. Hurray! :))
  • Don't add new methods to Renderer, just use the same technique that ThemeManager uses to allow theme preprocess functions to specify $variables['#attached']: call Renderer::render() with an in-place generated render array that contains the cacheability metadata. That'll put it in the renderer's context for us :)
  • After #2450993: Rendered Cache Metadata created during the main controller request gets lost landed, we get exceptions complaining that no render context is active, so solving all failures in the patch means fixing all tested occurrences of generating a URL (without collecting cacheability metadata) outside of an explicit render context. (i.e. they end up in the early rendering wrapping subscriber introduced by #2450993.)
  • In doing the above, we found several places in core that did generate URLs outside of an explicit render context. They are all in REST (RequestHandler) and Views (ViewAjaxController).
  • But there are also several controllers returning AJAX responses. These are basically minimally upgraded compared to Drupal 7 (compare D8's API with D7's: https://api.drupal.org/api/drupal/includes%21ajax.inc/group/ajax_commands/7). Which means the same reasoning as in #2450993 applies: be gentle with legacy-style code; ease the transition to D8. This is why we were forgiving for render arrays in #2450993, and for the same reason this issue chooses to be forgiving for AJAX responses.
  • Besides that, there are also several controllers that explicitly chose to opt out from caching altogether: their routes have the no_cache: TRUE option. EarlyRenderingControllerWrapperSubscriber ignores these routes.
  • And finally, there is some code — e.g. when sending e-mails — that runs outside of controllers and therefore cacheability is irrelevant and does not need to be bubbled. To not burden those cases, this patch detects whether a render context is active, and if there isn't, it doesn't bubble (see CacheableMetadataAwareUrlGenerator::bubble()).
  • Finally, it simply fixes a bunch of places that were incorrect: FormElement::processAutocomplete(), RenderElement::preRenderAjaxForm(), TextFormat::processFormat(), shortcut_preprocess_page(), \Drupal\views\Plugin\views\pager\SqlBase and \Drupal\views\Plugin\views\style\Table.

And in implementing this, a noteworthy bug was discovered: HEAD's rendering bubbleable metadata bubbling breaks when a subrequest happens (which CommentController::commentPermalink() uses), because HEAD doesn't tie the render context to the current request. This patch fixes that.

Remaining tasks

TBD

User interface changes

None.

API changes

  • API addition: RendererInterface::hasRenderContext()
  • Behavior change: Pager links no longer include the domain/path, they are querystring-only URLs. This ensures pagers are not cacheable only for the current URL. (Think a View block with a mini pager that is now cacheable across pages, whereas in HEAD it needed to be cached separately for every URL it appeared on!).
  • API addition: #type => pager now has an overridable #route_name property, which defaults to <none>.
  • API addition: UrlGenerator is now able to generate querystring-only URLs (needed for pager, see above).
  • API addition: theme preprocess hooks can now specify $variables['#cache'] (needed for pager).
  • Behavior addition: WebTestBase::clickLink() now supports querystring-only URLs
CommentFileSizeAuthor
#308 interdiff.txt4.62 KBWim Leers
#308 2351015-308.patch85.66 KBWim Leers
#307 interdiff.txt9.69 KBWim Leers
#307 2351015-307.patch85.66 KBWim Leers
#305 interdiff.txt8.31 KBWim Leers
#305 2351015-305.patch92.68 KBWim Leers
#300 interdiff.txt1.99 KBWim Leers
#300 2351015-300.patch90.3 KBWim Leers
#299 interdiff.txt7.27 KBWim Leers
#299 2351015-299.patch88.31 KBWim Leers
#297 rebase-interdiff.txt2.72 KBWim Leers
#297 2351015-297.patch88.05 KBWim Leers
#290 interdiff-281-290.txt2.49 KBmartin107
#290 2351015-290.patch88.06 KBmartin107
#281 interdiff.txt4 KBdawehner
#281 2351015-281.patch88.05 KBdawehner
#272 cache-link_csrf-2351015-272.patch87.7 KBplach
#272 cache-link_csrf-2351015-272.interdiff.txt716 bytesplach
#269 cache-link_csrf-2351015-269.patch87.7 KBplach
#269 cache-link_csrf-2351015-269.interdiff.txt1.46 KBplach
#268 increment.txt12.07 KBeffulgentsia
#268 cache-link_csrf-2351015-268.patch87.74 KBeffulgentsia
#264 interdiff.txt3.32 KBeffulgentsia
#264 cache-link_csrf-2351015-264.patch85.12 KBeffulgentsia
#260 interdiff.txt6.65 KBeffulgentsia
#260 cache-link_csrf-2351015-260.patch83.29 KBeffulgentsia
#258 interdiff.txt16.7 KBeffulgentsia
#258 cache-link_csrf-2351015-258.patch77.14 KBeffulgentsia
#257 cache-link_csrf-2351015-234.patch91.97 KBeffulgentsia
#252 interdiff.txt1.66 KBeffulgentsia
#252 cache-link_csrf-2351015-252.patch86.66 KBeffulgentsia
#250 interdiff.txt10.75 KBeffulgentsia
#250 cache-link_csrf-2351015-250.patch86.35 KBeffulgentsia
#241 interdiff.txt13.97 KBeffulgentsia
#241 cache-link_csrf-2351015-241.patch86.82 KBeffulgentsia
#234 cache-link_csrf-2351015-234.interdiff.txt10.06 KBplach
#234 cache-link_csrf-2351015-234.patch91.97 KBplach
#228 cache-link_csrf-2351015-226.interdiff.txt669 bytesplach
#227 cache-link_csrf-2351015-226.patch85.33 KBplach
#226 cache-link_csrf-2351015-226.patch85.33 KBplach
#224 cache-link_csrf-2351015-222.patch85.31 KBplach
#224 cache-link_csrf-2351015-222.interdiff.txt10.7 KBplach
#220 cache-link_csrf-2351015-221.patch81.88 KBplach
#219 cache-link_csrf-2351015-218.patch81.88 KBplach
#219 cache-link_csrf-2351015-218.interdiff.txt2.13 KBplach
#208 cache-link_csrf-2351015-208.test.patch2.74 KBplach
#208 cache-link_csrf-2351015-208.interdiff.txt5.82 KBplach
#208 cache-link_csrf-2351015-208.patch77.27 KBplach
#202 cache-link_csrf-2351015-202.interdiff.txt4.35 KBplach
#202 cache-link_csrf-2351015-202.patch74.85 KBplach
#200 cache-link_csrf-2351015-200.patch72.65 KBplach
#200 cache-link_csrf-2351015-200.interdiff.txt3.2 KBplach
#197 cache-link_csrf-2351015-197.patch72.58 KBplach
#195 cache-link_csrf-2351015-191.patch72.4 KBplach
#195 cache-link_csrf-2351015-191.interdiff.txt26.36 KBplach
#190 cache-link_csrf-2351015-190.patch78.68 KBplach
#190 cache-link_csrf-2351015-190.interdiff.txt11.21 KBplach
#189 cache-link_csrf-2351015-188.interdiff.txt3.1 KBplach
#188 cache-link_csrf-2351015-188.patch79.85 KBplach
#188 ct-prepare-1810394-98.interdiff.txt1.18 KBplach
#185 cache-link_csrf-2351015-185.interdiff.txt1.05 KBplach
#185 cache-link_csrf-2351015-185.patch76.75 KBplach
#181 cache-link_csrf-2351015-178.patch76.32 KBplach
#179 interdiff.txt4.36 KBdawehner
#179 2351015-177.patch73.25 KBdawehner
#175 cache-link_csrf-2351015-175.patch74.95 KBplach
#175 cache-link_csrf-2351015-175.interdiff.txt12.07 KBplach
#173 cache-link_csrf-2351015-173.patch66.29 KBplach
#173 cache-link_csrf-2351015-173.interdiff.txt10.96 KBplach
#170 ct-prepare-1810394-98.interdiff.txt1.18 KBplach
#170 cache-link_csrf-2351015-170.patch56.58 KBplach
#169 cache-link_csrf-2351015-169.patch57.97 KBplach
#169 cache-link_csrf-2351015-169.interdiff.txt6.06 KBplach
#166 cache-link_csrf-2351015-166.patch53.58 KBplach
#166 cache-link_csrf-2351015-166.interdiff.txt17.81 KBplach
#161 cache-link_csrf-2351015-161.patch40.42 KBplach
#161 cache-link_csrf-2351015-161.interdiff.txt3.52 KBplach
#157 cache-link_csrf-2351015-157.patch39.01 KBplach
#157 cache-link_csrf-2351015-157.interdiff.txt4.23 KBplach
#155 cache-link_csrf-2351015-155.patch38.63 KBplach
#155 cache-link_csrf-2351015-155.interdiff.txt7.61 KBplach
#153 cache-link_csrf-2351015-153.patch40.08 KBplach
#153 cache-link_csrf-2351015-153.interdiff.txt2.01 KBplach
#150 cache-link_csrf-2351015-149.patch41.3 KBplach
#150 cache-link_csrf-2351015-149.interdiff.txt2.97 KBplach
#148 cache-link_csrf-2351015-148.patch38.34 KBplach
#145 cache-link_csrf-2351015-145.patch38.34 KBplach
#145 cache-link_csrf-2351015-145.interdiff.txt4.29 KBplach
#142 2351015-142.patch36.35 KBdawehner
#136 cache-link_csrf-2351015-136.patch36.35 KBplach
#136 cache-link_csrf-2351015-136.interdiff.txt5.33 KBplach
#132 cache-link_csrf-2351015-132.patch31.95 KBplach
#132 cache-link_csrf-2351015-132.interdiff.txt17.21 KBplach
#122 interdiff.txt4.7 KBdawehner
#122 2351015-122.patch17.72 KBdawehner
#120 2351015-120.patch14.47 KBdawehner
#114 interdiff.txt3.87 KBWim Leers
#114 link_csrf-2351015-114.patch15.11 KBWim Leers
#111 interdiff.txt9.02 KBWim Leers
#111 link_csrf-2351015-111.patch12.74 KBWim Leers
#104 link_csrf-2351015-104.patch4.46 KBWim Leers
#90 2351015-90.patch939 byteseffulgentsia
#87 2351015-87.patch892 byteseffulgentsia
#85 2351015-85.patch654 byteseffulgentsia
#82 rough-outline-2351015-do-not-test.patch4.52 KBWim Leers
#64 link_csrf_tokens_can_be-2351015-64.patch7.82 KBFabianx
#45 interdiff.txt3.69 KBmartin107
#45 convert_menu_csrf-2351015-45.patch9.28 KBmartin107
#42 convert_menu_csrf-2351015-41.patch5.59 KBmartin107
#41 interdiff-37-41.txt778 bytesmartin107
#41 convert_menu_csrf-2351015-41.patch0 bytesmartin107
#37 convert_menu_csrf-2351015-37.patch5.52 KBcilefen
#36 convert_menu_csrf-2351015-36.patch0 bytescilefen
#24 2351015-24.patch5.5 KBdamiankloip
#9 2351015.patch6.48 KBdamiankloip
#215 cache-link_csrf-2351015-215.patch81.93 KBplach
#215 cache-link_csrf-2351015-215.private.patch81.93 KBplach
#215 cache-link_csrf-2351015-215.interdiff.txt21.86 KBplach
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

Fabianx’s picture

Fabianx’s picture

Title: Fix CSRF for render caching / caching in Akamai » Fix CSRF for render caching / caching in CDNs
Issue summary: View changes
catch’s picture

.g. Yes, need separate token for admin forms and admin links, but could have just one token for content forms.

Or even better we could decide to use token groups, e.g.:

_csrf_token: TRUE
_csrf_token_group: admin

If you don't send the $value argument to drupal_get_token(), you already get the same token for every occurrence on the page.

So I think what you're really saying is that we'd restrict $value to a handful of constants? That seems completely reasonable. Some of the worst hacks in cacheable_csrf are due to the value argument, especially for links.

Wim Leers’s picture

OMG AWESOME!

I'll help with this patch once you get it going — definitely by providing reviews, but if necessary also by pushing it further. I'd love to see this in :)

This will make all blocks containing forms cacheable, but also Views exposed filters!

catch’s picture

I've read through this a few times now and it looks great!

If we use cookies/local storage, then this feels like exactly the same security model that cacheable_csrf has.

I think it'd be good to get someone less involved in 8.x caching, but with a big interest in security to look at the client-side tokens, just in case we missed something. I especially say that as the main author of cacheable_csrf, but it has had review from several other people by now already so feel pretty good about it personally.

If we use #post_render_cache then there's no change in the security model compared to 7.x - assuming we support token groups (for admin vs. non-admin etc.). We'd also need to do that for the fallback confirm forms anyway.

Fabianx’s picture

It seems we already have an indication that routes are _admin: TRUE, which we could well use as the default for the CSRF-TokenGroup to get the CSRF token.

I unfortunately don't have an implementation plan, yet. Who is currently working on the CSRF to service conversion?

Would that be @znerol?

And does anyone know who implemented _csrf tokens for routes?

I would like to implement it in a way that is least disturbing to current efforts in that conversion area and in agreement with the person that did the router integration.

catch’s picture

#1798296: Integrate CSRF link token directly into routing system was the original issue. We discussed keeping the route flag but swapping out the implementation of the token to something cacheable/replaceable on the issue too.

znerol’s picture

Great plan.

For the record, the implementation in Authcache does not even bother collecting all the tokens into one request. This made the ESI implementation straight forward. The only problem on real sites I've encountered with this approach is the commerce add-to-cart form (which generates as many different forms as there are buttons on the page). But this would be easily solvable with the proposed csrf group method.

There is still the problem with cached forms on cacheable pages , but it looks like this is worked on in #2242749: Port Form API security fix SA-CORE-2014-002 to Drupal 8.

damiankloip’s picture

FileSize
6.48 KB

This is a rough start, but we would need something like this. Has a few benefits first off;

- Outbound route processor is greatly simplified - it just needs to insert a placeholder
- The CsrfAccessCheck would also not need to do the path dance. It would just use the _csrf_token requirement value from the route. As long as we are ok with using this limited set of values. I think it would be fine, per-path is maybe a step too far, more idealistic from a security point of view but not everything else. So security feedback would be good. Some compromise here.

Wim Leers’s picture

damiankloip++ :)

Damien Tournoud’s picture

At that point, wouldn't it be a lot easier to just start using the Origin header instead of token-based validation?

Fabianx’s picture

#11 That is a possibility, too, but I believe in micro-steps - even if that means some work will be thrown away, but if someone gets Origin approved via SecTeam, I am not gonna complain :).

Damien Tournoud’s picture

Origin is approved by the secteam, we discussed that internally several times. The only problem with this is browser support, but I guess it's not a problem anymore for Drupal 8?

Damien Tournoud’s picture

(Relying on the Origin header could actually be *more* secure than token-based validation because third-party plugins seem to be doing a better job at preventing the Origin header from being spoofed than at enforcing basic cross-domain boundaries.)

Fabianx’s picture

I think the origin header might be feasible now, but if we just implement

CSRF -> #post_render_cache

this would unblock a ton of issues without any discussion about that, so it probably makes sense to do that anyway and leave the origin discussion to another issue.

catch’s picture

Since we're only doing PHP replacement here there's no significant change to the security model, seems OK to proceed.

I do think we should discuss the Origin header before looking at JavaScript replacement, since that's more complex to implement.

damiankloip’s picture

Agreed. Sounds like a sensible way forward.

catch’s picture

Priority: Major » Critical

Since this is really the only way to fix #2335661: Outbound path & route processors must specify cacheability metadata properly, bumping to critical.

znerol’s picture

In order to reduce risk and speed up this issue, I'd rather only attack menu-link CSRF tokens here and do forms in another issue. This is because some forms may have user dependent #default_value and caching them will cause problems (e.g. #2307555: Comments intermittently misattributed over in Authcache was one not so obvious instance of that problem. The same fragile code still exists in D8 AFAIK). We cannot expect #4 to magically happen by just offloading CSRF form token generation to Ajax or #post_render_cache.

Fabianx’s picture

I agree with znerol and that is the MVP to strive here:

- Convert menu CSRF tokens to use #post_render_cache

moshe weitzman’s picture

Would anyone be willing to Assign this issue to themselves. I'm a Base system component maintainer and doing triage on Critical items.

moshe weitzman’s picture

Title: Fix CSRF for render caching / caching in CDNs » Convert menu CSRF tokens to use #post_render_cache
damiankloip’s picture

I can do this. I did the first half. Might as well carry on I guess :)

damiankloip’s picture

FileSize
5.5 KB

New patch with a CsrfController instead. Will talk to Fabianx about the render cache side of things. Should have something today.

dawehner’s picture

@alexpott, @effulgentsia, @xjm, and I discussed this and were unclear as to how this cache poisoning bug would be more than a very rare edgecase, and it's not a security bug because sessions that receive the invalid link would still be denied access.

The only case the cache poisoning would occur is if a link with a CSRF token were added to a menu.
This is really unlikely given that CRSF dependend routes are dynamic.
Examples:

  • Theme install/uninstall links: admin/theme/install?theme=bar
  • Comment approve links: /comment/299792/approve

If someone puts those dynamic links into menu links, they have other problems. CSRF links appears on admin listing forms, not on usual menu links.

xjm’s picture

Issue tags: +Needs followup

It does sound from #19 and #20 that we want a separate issue for forms, and I think that separate issue would actually be critical?

znerol’s picture

The only case the cache poisoning would occur is if a link with a CSRF token were added to a menu. This is really unlikely [...]

Note #144538: User logout is vulnerable to CSRF. Also I think this is not only about menu, but also render cache (?)

Regarding forms: This is IMHO not feasible unless most core/contrib forms are crafted in a way that they are cacheable. This is not the case at the moment. E.g. the comment form puts the username in a '#type' => 'value' element, the contact form has profile data in #default_value. Therefore in this cases it is not enough to just dynamically generate the CSRF token. Rather it is necessary to strip personalized data from the form (and perhaps restore it in the browser via Ajax).

What we might want to do is adding cacheability metadata to forms as a first step (or even form fields?).

catch’s picture

If we make the csrf token support for forms cacheable, then it'd make it much easier to create a cacheable form at the API level (i.e. without requiring cacheable_csrf module). I think it's very unlikely we'll be able to enable HTML caching of all forms by default though for the reasons znerol states.

Fabianx’s picture

So the problem with using #post_render_cache at this moment is that the URL might be created at a moment, when the render stack is not yet available.

I thought about two ways:

a) Inform some subscriber about the used tokens, still restrict to token groups and add something to replace the tokens in markup _before_ pushing to cache. A little error prone but given we know how links look it could work as long as we have a mapping of value => token somewhere, we can replace it with the original value and replace the token dynamically. I will explore that option in Drupal 7 more.

b) Lazily create the tokens for the URLs before any __toString happens.

The advantage is that the __toString should happen in a rendering context (when we want to use render caching), while the new Url() might not be.

Soooo:


if (\Drupal::service('renderer')->isRecursive()) {
  // Do not replace tokens, add post render cache - in current stack frame.
  \Drupal::service('renderer')->addPostRenderCache('csrf_post_render_cache');
  return ...;
}

// Replace token placeholders now.
return fully build URL;

Given we now have a renderer service that is possible now, even better if URL would not return a string but a render array ...

I am using that strategy in Drupal 7 to support drupal_add_js/drupal_add_css and it works great!

YesCT’s picture

Issue summary: View changes
Issue tags: +Needs issue summary update

needs the remaining tasks identified in the issue summary

YesCT’s picture

Issue summary: View changes

and to see if the summary can be more of a summary: be more concise

Wim Leers’s picture

Anonymous’s picture

re. #33 - interesting. maybe Wim should write a blog post about 'cache blobs with tokens, and replace them within the context of single request' as another way to do this ;-)

dawehner’s picture

cilefen’s picture

reroll

cilefen’s picture

oops

cilefen’s picture

Status: Active » Needs review

There is a patch, so "needs review".

martin107’s picture

Status: Needs work » Needs review
FileSize
0 bytes
778 bytes

Nothing special, just found couple of minor nits while reading up on this.

martin107’s picture

Ah 0 bytes in the last patch :(

martin107’s picture

While looking at the test failings in RouteProcessorCsrfTest I can see :-

a documentation bug on the interface.
a couple of invalid assertNull's
and some testing that is too tightly coupled to the current implementation, which will need fixing for this issue to pass.

In short a bundle of side issues that now have a dedicated issue all of their own.

#2430167: Broken tests for \Drupal\Core\RouteProcessor\OutboundRouteProcessorInterface::processOutbound

martin107’s picture

Status: Needs work » Needs review
FileSize
9.28 KB
3.69 KB

This is me acting on #44

RouteProcessorCsrfTest has been refactored in light of the fact that RouteProcessorCsrf no longer depends on CsrfToken.

[ One failing test should now pass. ]

catch’s picture

Title: Convert menu CSRF tokens to use #post_render_cache » Convert link CSRF tokens to use #post_render_cache
Issue tags: -blocker

Retitling - this is any link.

I don't think this blocks the related issues - we already cache some links which may or may not be CSRF enabled.

Also we discussed this on the maintainer call and are thinking about downgrading to major, but this was a very borderline decision and I need to type the reasoning up properly - so leaving critical unless that's typed up.

catch’s picture

Thought about this overnight, there is a security issue if we don't do this I think.

1. User A visits a page, the HTML is cached with their CSRF token.

2. User B visits the page, the User A's CSRF token is in the HTML. Clicking the link will fail.

3. However, User B then takes the link with User A's token, and creates a CSRF attack using that.

4. User A visits the page with the CSRF attack, and the link is valid for them.

CSRF token is based on session, so it'll work as long as the user has the same session ID, which could be a long time.

Fabianx’s picture

There is another problem we need to fix when we cache CSRF tokens.

There is currently no kind of fallback, e.g. lets say we had CSRF on /user/logout and lets further say we had a block caching that incorrectly per role instead of per user / session (even!).

That logout link would work for one user, but not for another user and there is no way to get it to work (for the user).

I have seen this on D7 sites with advanced caching, so this is not just theoretical and caching per session() is less than ideal ...

The best solution for this is:

- Have a fallback page that shows the link again and asks the user to confirm the operation with a message that CSRF validation failed.

The difference to D7 where this problem is omnipotent with links and forms (flag module, user relationships, other ajax links, ajax forms, etc.) is that in D8 cacheability is on-by-default.

Hence the larger importance to fix this.

What is stalling this issue currently is that you can generate a link outside of a render array, but the CSRF token is generated before.

The problem - like with attachments - is that you would like to push the link via some other medium like an ajax response or such and hence #post_render_cache is not called.

This is fine for forms, but difficult for the link generator.

For e.g. #type => link this issue would be trivial in comparison, as all that logic would be coupled in the render layer, where we have all the advanced caching, etc. things.

Will discuss with Wim and catch again, maybe the first step is to make anything work and then care about the edge cases of someone wanting to display a link with CSRF out of bounds.

effulgentsia’s picture

Issue tags: +Triaged D8 critical

Discussed with the branch maintainers, and they were convinced by this being a security bug, and therefore critical, per #48.

effulgentsia’s picture

Title: Convert link CSRF tokens to use #post_render_cache » Link CSRF tokens can be hijacked when cached
Category: Task » Bug report

Recategorizing as a bug and retitling to describe the bug rather than the proposed solution.

effulgentsia’s picture

Title: Link CSRF tokens can be hijacked when cached » Link CSRF tokens can be hijacked when cached with insufficient contexts
Fabianx’s picture

Assigned: Unassigned » Fabianx
YesCT’s picture

Issue summary: View changes

updating issue summary #1805054-138: Cache localized, access filtered, URL resolved, and rendered menu trees said this is not blocking that anymore. (still related though)

pwolanin’s picture

Issue tags: +D8 Accelerate Dev Days

taking a look at this

pwolanin’s picture

Looking at the approach so far, I'm not seeing how it would be secure against replay attacks. maybe we need to move to a post-render approach?

pwolanin’s picture

Status: Needs work » Postponed

per discussion with Wim Leers, postponing on #2335661: Outbound path & route processors must specify cacheability metadata

We can't implement this using post-render without more generally solving the cachability of links.

The patch as is seemed only about 30% complete, and had issues like defining the same constant in multiple places and yet not using it. e.g. I started down this path for a while:

+++ b/core/tests/Drupal/Tests/Core/Access/RouteProcessorCsrfTest.php
@@ -7,6 +7,7 @@
 
 namespace Drupal\Tests\Core\Access;
 
+use Drupal\Core\Access\CsrfAccessCheck;
 use Drupal\Tests\UnitTestCase;
 use Drupal\Core\Access\RouteProcessorCsrf;
 use Symfony\Component\Routing\Route;
@@ -44,7 +45,7 @@ public function testProcessOutboundNoRequirement() {
    * Tests the processOutbound() method with a _csrf_token route requirement.
    */
   public function testProcessOutbound() {
-    $route = new Route('/test-path', array(), array('_csrf_token' => 'TRUE'));
+    $route = new Route('/test-path', array(), array(CsrfAccessCheck::ROUTE_REQUIREMENT_KEY => 'TRUE'));
     $parameters = array();
 

Fabianx’s picture

Status: Postponed » Active

The other issue is independently critical, but I have an idea for this one so setting briefly active again to provide a proof-of-concept (based on an idea for #2450993: Rendered Cache Metadata created during the main controller request gets lost).

Note that this will most probably not be a clean or committable solution, but it should be one that can be used to make VRC / Smartcache green in the mean time to ensure they are not blocked until we have the clean solution.

- Introduce #pre_render_cache, but with a different meaning, which means: processing needed to be done before putting the cache entry into cache
- Set #pre_render_cache data using the token generator in the render chain with both the token value and its source value.

We'll see how that works ...

damiankloip’s picture

Yes. Sounds reasonable. The approach so far is just the token placeholder side. Not the actual rendering. Fabian has some good ideas about that :)

Wim Leers’s picture

Yes. Sounds reasonable. The approach so far is just the token placeholder side. Not the actual rendering. Fabian has some good ideas about that :)

If Damian is also convinced, then I'm pretty sure this is going to be awesome :)

pwolanin’s picture

I'm not convinced that the approach in this issue is correct at all.

Please update the issue summary to clarify if you have a new approach.

Wim Leers’s picture

+1 to pwolanin's skepticism: this needs a much clearer explanation.

Fabianx’s picture

"Brace yourselves, Patches are coming".

The approach here is orthogonal to #2335661: Outbound path & route processors must specify cacheability metadata anyway, because that issue sets just a max-age=0 for now.

Lets start with a quick hack and proof-of-concept.

Fabianx’s picture

Status: Active » Needs review
FileSize
7.82 KB

And here is the quick proof-of-concept.

I didn't get to upload it yesterday:

- Inform the renderer of used CSRF tokens
- Check CSRF tokens before setting cache (as the stack is bubbled here still before cacheSet, need to move that over to the bubbleStack)
- Replace CSRF tokens before putting into the cache and add a #post_render_cache

As CSRF tokens are unique that approach works easily.

- Is that a clean approach?

Not really ...

- Does it work?

Yes, it does.

- Performance impact?

Low, even with 100s of links only those few CSRF tokens are checked in the markup with a strpos and str_replace call and only when a cacheable area is present anyway ... (e.g. #cache with keys) of which we only have 3-4 dimensions currently.

Worst case is that something creates a CSRF key early on, then all other cache sets, test for that CSRF key during the request.

Cache Gets are not affected.

- How to test:

Run:

drush scr $(pwd)/core/test_csrf.php

( currently cache_set / cache_get don't work with drush as drush does not simulate a GET request, so isMethodSafe() is always false. Not sure if that is a bug or a feature, hence we look at $build['#children'] instead.)

Note:

This is a proof-of-concept that could be used in combination with other patches to ensure CSRF tokens are not an issue.

webchick’s picture

Issue tags: +blocker

Just the tag fairy, carry on.

Fabianx’s picture

Title: Link CSRF tokens can be hijacked when cached with insufficient contexts » [PP-1] Link CSRF tokens can be hijacked when cached with insufficient contexts
Status: Needs review » Postponed

Just need to re-visit once 'Outbound ...' is in.

Likely can demote to major then.

dawehner’s picture

Thank you fabian!

webchick’s picture

Issue summary: View changes

Quick issue summary update to note what this issue's postponed on.

dawehner’s picture

So #2335661: Outbound path & route processors must specify cacheability metadata is in.

As far as I understand we would be safe, in case all those CSRF cache contexts are bubbled up. For menu links this is the case after #1805054: Cache localized, access filtered, URL resolved, and rendered menu trees
but in custom code, you could still render a URL to a random route, but the probability that wrong entries are stored in cache is less likely.

webchick’s picture

Title: [PP-1] Link CSRF tokens can be hijacked when cached with insufficient contexts » Link CSRF tokens can be hijacked when cached with insufficient contexts
Status: Postponed » Needs review

If I'm not mistaken, this is no longer blocked, since #2335661: Outbound path & route processors must specify cacheability metadata got in.

dawehner’s picture

Assigned: Fabianx » Unassigned

Removing assigned field to encourage also other people to look at it.

catch’s picture

So #2335661: Outbound path & route processors must specify cacheability metadata at least when combined with #1805054: Cache localized, access filtered, URL resolved, and rendered menu trees should fix the security issue.

Then we have the (major) issue that the presence of a CSRF token in any rendered HTML will disable caching. In core this isn't a big deal, but it means for example flag module will disable caching for all users with access to flag links on entities or similar.

On the patch, I'd expect this to look more like #2478443: Set the 'is-active' class for anonymous users in a Response Filter instead of a #post_render_cache callback. We know there are use-cases for CSRF tokens to be replaced via either PHP or JS, and the HTML parsing would also be very similar.

Wim Leers’s picture

On the patch, I'd expect this to look more like #2478443: Set the 'is-active' class for anonymous users in a Response Filter instead of a #post_render_cache callback We know there are use-cases for CSRF tokens to be replaced via either PHP or JS, and the HTML parsing would also be very similar.

Hah, that is exactly what @effulgentsia and I concluded yesterday too.

But!

So #2335661: Outbound path & route processors must specify cacheability metadata at least when combined with #1805054: Cache localized, access filtered, URL resolved, and rendered menu trees should fix the security issue.

That will fix the security issues for links with CSRF tokens if they are part of menus, NOT the more general problem of any link.

Then we have the (major) issue that the presence of a CSRF token in any rendered HTML will disable caching. In core this isn't a big deal, but it means for example flag module will disable caching for all users with access to flag links on entities or similar.

Aha, I see. The idea that @effulgentsia and I had was to have that response filter just search the HTML for CSRF token placeholders, and replace all of them, always.

But even then, we are left with the problem of other outbound path & route processing having cacheability metadata that isn't bubbled when URLs/links are added to a render array in the typical $this->t('Please see <a href="@url">the docs</a>", ['@url' => \Drupal::url()]) way. For example:

  1. language path processing: a link that points to "/fr/node/1" is render cached, and is reused when the current language is English. Solvable by adding the "URL language" cache context to renderer.config.required_cache_contexts.
  2. URL alias path processing: if we link to /node/1 and the alias is /foo at time A, and is render cached, yet at time B it is changed to /bar, then we don't invalidate it, hence the cached link (/foo) will result in a 404. This is a general problem we've never really solved (see #2480077: Using the URL alias UI to change aliases doesn't do necessary invalidations: path aliases don't have cache tags), and which you can kind of solve by using the Global Redirect. So I don't think we need to solve that specific problem here, but the general problem (no cacheability metadata) remains
  3. more generally speaking, other outbound path/route processors may need cacheability metadata to be bubbled.

But that's the thing here: we can mitigate the problems/risks of the 99% cases (CSRF tokens via response filter + outbound language path processor by adding an additional required cache context) very easily, but doing so does not solve the general problem. One could argue that writing additional path/route processors is so very rare that it's okay for that to be kinda painful to get to work correctly in combination with render caching, but at the same time that is icky. On the other hand, solving it properly requires quiet widespread changes, which makes me lean towards solving the 99% here and then in a non-critical follow-up work on making it *better*.

Thoughts?

Fabianx’s picture

Yes, using always placeholders and the response filter to replace them should work.

IIRC even Ajax responses go via the response filter, so as long as it does not get somehow encoded, it should work ...

The other possibility is to placeholder it on demand ...

---

$this->t('Please see <a href="@url">the docs</a>", ['@url' => \Drupal::url()])

There is a possibility, have everything go via the renderer and force code to use renderPlain() with e.g. a closure for okay DX to render something early ...

But its a chicken-egg problem:

- If you put everything through the renderer, even current request unrelated things (email, etc.) go onto the stack, possibly get an exception
- If you do not put things through the renderer, you use metadata

Same for placeholders:

If we placeholder all CSRF tokens regardless of where they appear, we might end up with placeholders somewhere that we e.g. want to send to some other service (in which case #render_placeholder is better than FilterResponseEvent as it would be at least in renderPlain processed).

If we do not placeholder CSRF tokens we could end up with security issues on custom code ...

Wim Leers’s picture

Yes, using always placeholders and the response filter to replace them should work.

IIRC even Ajax responses go via the response filter, so as long as it does not get somehow encoded, it should work ...

Indeed.

But I just thought of a case where it would break: if an e-mail containing links with CSRF tokens is being sent. Those e-mails aren't responses, so would never get processed by a response filter, so … damn :(

The key problem here is that we render URLs using the same context-unaware API for vastly different use cases: when sending e-mails or when sending HTML responses…

dawehner’s picture

The key problem here is that we render URLs using the same context-unaware API for vastly different use cases: when sending e-mails or when sending HTML responses…

Could we introduce some basic replacement logic subsystem, which you can either use it when you render mails (maybe have a dedicated renderapi method to do so), or when you
render things via responses?

Fabianx’s picture

#76:

Yes, that was my idea with renderPlain() taking a closure:

  $url_with_csrf_tokens_replaced = $renderer->renderPlain(function() {
    return url('system/cron');
});

It's not totally nice, but it works in all cases and properly says: This is a new rendering context, render this isolated from the rest of the page ...

Wim Leers’s picture

But URL rendering cannot specify cache contexts. Not without #2450993: Rendered Cache Metadata created during the main controller request gets lost at least, which I'm not sure is actually going to happen.

catch’s picture

But I just thought of a case where it would break: if an e-mail containing links with CSRF tokens is being sent. Those e-mails aren't responses, so would never get processed by a response filter, so … damn :(

There's no use-case for this though.

If you send a CSRF-protected link via e-mail, there is only a small chance that it will be opened in the same session as the one that was used to generate it. Also if the token doesn't get replaced, the worst that happens is you get a 403 - same as if your session has expired/you opened the link in a different browser/on a different device. So I don't think we have to handle that case at all.

If you have an e-mail that links to an action, it'll have to be a link to a confirm form, or it'll be a special one-off link like password reset. Either way what we do here doesn't affect that.

Wim Leers’s picture

Of course you're right.

I'll get a patch going for this.

dawehner’s picture

What are the next steps on this issue? Is this issue somehow actionable in any form?

Wim Leers’s picture

Assigned: Unassigned » Fabianx
Status: Needs review » Active
FileSize
4.52 KB

Yeah, sorry for the silence on my part. I said in #80 I was going to work on it, but then got stuck. Attempting my rough outline.

The part I got stuck on was the bit where the CSRF outbound route processor needs to track somewhere somehow which the placeholders are that it uses, because the response subscriber will need to know those placeholders. And I don't see how we can do that.

I think we kind of need #2450993: Rendered Cache Metadata created during the main controller request gets lost for this to be resolved, and fortunately @Fabianx & @Crell came up with a good plan for that at DrupalCon Los Angeles 1.5 week ago.

Assigning to @Fabianx for feedback.

@Fabianx, can you provide a very short explanation of how we can resolve this using #2450993, and link to another comment with more details?

Fabianx’s picture

#2450993-24: Rendered Cache Metadata created during the main controller request gets lost has all the details from Crell, but here is a TL;DR of that:

- Rendering pages is like drawing on a canvas (compare 3D libraries or [canvas] HTML element)
- Render Array concatenation is like creating a larger canvas out of several smaller canvas'

We found out after much discussion that we implicitly have a 'drawing canvas' or 'drawing context' already now with the stack.

However currently we throw some of it away (#2450993: Rendered Cache Metadata created during the main controller request gets lost) and we also have APIs that don't allow easily to store / retrieve / concat whole canvas' (cacheable render arrays with #attached and #cache metadata).

We want to make that canvas way more explicit and require it always.

We also want to allow creating a new canvas on the fly, e.g. via a closure.

As that would be quite a change we also opt-in controllers to have a default drawing context, which we merge if the controller returns a render array.

That gives us the best out of both worlds:

- No exceptions for code that early renders / does something that affects the canvas within a controller. (we track it for you)
- "Scoped" canvas for everything else, as we need to explicitly create a rendering context.

e.g. the renderer does need a new rendering context whenever it goes down a tree with #cache keys set, as we want to store that subtree isolated.

If you want to render early in isolation, you get back not only a string, but also a whole cacheable render array, which you can then stuff into another render array. (early rendering works again this way!)

==

The advantage of a scoped canvas approach is that certain data is only necessary for a subtree.

E.g. once a render context is finished we can remove all SafeMarkup that relates to building and rendering this fragment / part of the page.
Similar we don't need to keep track of CSRF tokens globally that don't apply to this rendering context.

The advantage is that we no longer have one global state, but a state that is purely determined by PHP's call stack.

===

How does that relate to CSRF tokens?

CSRF tokens need to be stored somewhere, they also need to get back to the render array for cacheability reasons.

As we currently cannot rely on the canvas / render stack being processed always, this issue is only partially solvable right now.

---

Theoretically we could add one global that we always empty when we enter a new Root call or returning from a #cache keys set entry, hence simulating what we should be doing, but I would prefer to wait for #2450993: Rendered Cache Metadata created during the main controller request gets lost as it is IMHO the only sane way to resolve this issue cleanly. I will ask catch if we want to postpone this one and make #2450993: Rendered Cache Metadata created during the main controller request gets lost a critical blocker for this one, or if we just add #2450993 as a priority without making it critical.

As a side effect we should be able to improve stack performance by at least 2-5% (I guess) as we only need to merge when we come back from a new 'render context'.

Fabianx’s picture

Title: Link CSRF tokens can be hijacked when cached with insufficient contexts » [PP-1] Link CSRF tokens can be hijacked when cached with insufficient contextshttps://www.drupal.org/node/2450993
Priority: Critical » Major
Issue summary: View changes
Status: Active » Postponed

As proposed in chat with catch, made #2450993: Rendered Cache Metadata created during the main controller request gets lost critical and demoted this one as major and postponed it on that issue.

Because #2450993: Rendered Cache Metadata created during the main controller request gets lost will solve it, but this issue should verify and likely use some placeholders for CSRF tokens after that issue has been done.

effulgentsia’s picture

Status: Postponed » Needs review
FileSize
654 bytes

+1 to #2450993: Rendered Cache Metadata created during the main controller request gets lost being very valuable to fix regardless of this issue. As to whether or not it should be critical, I'm not sure yet. I'm curious if this patch passes tests, and if so, whether something like it would be enough to then make the rest of this issue and that one both be not critical.

effulgentsia’s picture

Status: Needs work » Needs review
FileSize
892 bytes

Ok, that's not looking promising. How about this?

webchick’s picture

Title: [PP-1] Link CSRF tokens can be hijacked when cached with insufficient contextshttps://www.drupal.org/node/2450993 » [PP-1] Link CSRF tokens can be hijacked when cached with insufficient contexts

Assume that was a copy/pasta error.

effulgentsia’s picture

Status: Needs work » Needs review
FileSize
939 bytes
effulgentsia’s picture

Title: [PP-1] Link CSRF tokens can be hijacked when cached with insufficient contexts » Link CSRF tokens can be hijacked when cached with insufficient contexts
Assigned: Fabianx » Unassigned
Priority: Major » Critical
Issue tags: -Needs followup, -blocker +Needs tests

#90 is green, but needs new tests to prove it fixes the bug. @Fabianx: unassigning you in case someone else wants to work on those tests or otherwise comment on #90, but re-assign to yourself if you want to.

Raising priority to critical, because so far, I'm still unconvinced about #2450993: Rendered Cache Metadata created during the main controller request gets lost remaining critical, and if that ends up getting downgraded, this specific bug is still critical.

Now, #90 is definitely ugly, and could result in false positives (e.g., token parameters for something other than CSRF tokens resulting in not caching things that could be), but I'm curious what people think about this as an interim solution, and then removing it once there's a better fix, such as #2450993: Rendered Cache Metadata created during the main controller request gets lost, whether that happens before 8.0.0 or after. Despite this still needing tests, leaving at "Needs review" to gather that feedback.

Fabianx’s picture

#91: It is also potentially performance problematic to check all things again and again for tokens, however I agree its way better than doing nothing.

What about changing csrf-tokens to be prefixed with ?token=csrf-[hash] for now, that is an internal detail anyway, but it would reduce false-positives to 0.

Thanks for unassigning, yes lets have someone fix the test coverage for it.

dawehner’s picture

What about changing csrf-tokens to be prefixed with ?token=csrf-[hash] for now, that is an internal detail anyway, but it would reduce false-positives to 0.

Its odd in the beginning that we don't use token-csrf, given that it would semantically describe what its used for.

Wim Leers’s picture

While working on this, I ended up creating #2512132: Make CSRF links cacheable as a separate issue, because it only addresses the "CSRF links break cacheability" part, not the security part, which is why this particular issue is critical.

Wim Leers’s picture

Issue summary: View changes
Status: Needs review » Active
Issue tags: -Needs issue summary update

With #94 splitting off cacheability, we can get better focus in this issue. Also updated the IS. So far, we've been mixing the cacheability discussion with the security discussion. So let's restate what the problems are:

  1. If some code decides to show a URL with a CSRF token, but doesn't collect the cacheability metadata, i.e. calls either of \Drupal::url(), \Drupal::l(), Url::toString(), UrlGenerator::generateFromRoute(), etc. without setting the optional $collect_cacheability_metadata parameter to TRUE, then we don't get \Drupal\Core\Access\RouteProcessorCsrf's max-age = 0, and hence we end up caching the individual fragment of the page that this URL ends up in across users.
  2. Worse, even if you are opting in to collecting cacheability metadata, but you're doing this as part of your controller, i.e. if you're doing early rendering, then … we still lose that metadata, and we still end up caching across users.

Problem 2 is the part that #2450993: Rendered Cache Metadata created during the main controller request gets lost fixes. Yay!

But that still leaves us with point 1, which is a quite nasty problem as well. The reason that parameter is optional is simple: we didn't want to break BC for URL generation.

As far as I can tell, we have only two options for solving problem 1:

  1. Break BC for URL generation, and remove those optional parameters, set them to TRUE always.
  2. Have the URL generation system call out to the renderer, and if a render context is currently active, update the render context directly. I.e. something like RendererInterface::updateRenderContext(BubbleableMetadata). This violates quite a lot of principles, because it'll mean tying coupling the URL generation system with the render system.

P.S.: if the many ways to generate URLs make you sad, see #2491981: There are too many ways to generate URLs and links.


Also completely redid the IS, since it was mostly discussing cacheability, which is now handled by #2512132: Make CSRF links cacheable. Also migrated the relevant parts of the IS from here to there.

dawehner’s picture

Break BC for URL generation, and remove those optional parameters, set them to TRUE always.

Yeah mh, that sounds like a gigantic patch and tons of broken contrib for well, a case, which is in the majority not broken ... as the link just depends things which are part of the cache context anyway (remember, most URLs don't have CSRF checks, i mean you could pretty much limit those to entity operations and similar, places which are kinda controlled).

In case we really want to do that, I would vote for changing GeneratedUr to implement Psr\Http\Message\UriInterface
which then potentially makes us compatible again with over code out there.

Given that I fear that option 2) is kinda the more pratical one.

Wim Leers’s picture

Assigned: Unassigned » Wim Leers

Just had a call about this (#95) with effulgentsia.

He had an idea to make proposal 2 not disruptive at all, which we discussed and together ended up on this proposal:

  • Keep \Drupal\Core\Routing\UrlGenerator, unchanged, but make it a private service called e.g. url_generator.uncacheable
  • Add \Drupal\Core\Render\UrlGenerator, make it the new url_generator service
  • (This is similar to what we do with config.storage & config.storage.active.)
  • (This also happens to be exactly what Fabian and Crell concluded at #2450993-24: Rendered Cache Metadata created during the main controller request gets lost, point 6. :))
  • (Also: this is a great validation of the architecture: we have a flawed URL generation API, and the fix is filthy, but we can fix it by just swapping the service out for another implementation that thinly layers on top of the existing implementation, but also is coupled to the renderer. Isolated crappiness. Hurray! :))
  • Don't add new methods to Renderer, just use the same technique that ThemeManager uses to allow theme preprocess functions to specify $variables['#attached']: call Renderer::render() with an in-place generated render array that contains the cacheability metadata. That'll put it in the renderer's context for us :)
  • And in fact, the latter will also automatically cause exceptions (once #2450993: Rendered Cache Metadata created during the main controller request gets lost lands), complaining that no render context is active. And as of #2450993, that will only ever happen during cron runs generating e-mails or drush scripts. So in the exception, we can point those users to url_generator.uncacheable — which is also more optimal for those use cases, given they never care about caching anyway.

Did I capture that well, effulgentsia?

Fabianx’s picture

+1 to #97, sounds excellent. I love that proposal ...

dawehner’s picture

Nice!

Wim Leers’s picture

Assigned: Wim Leers » Unassigned

Unassigning in case somebody wants to take this on, I thought I was going to be able to start working on this today, but that won't happen. I'll probably start working on it on Monday or Tuesday.

Wim Leers’s picture

Issue summary: View changes

Given #97 was +1'd by both Fabian & dawehner, I think we have enough consensus to pursue this solution. Updated the IS.

catch’s picture

Isolated crappiness++

Wim Leers’s picture

Assigned: Unassigned » Wim Leers

Working on this now.

Wim Leers’s picture

Status: Active » Needs review
FileSize
4.46 KB

This implements #97.

Let's see what breaks.

dawehner’s picture

  1. +++ b/core/core.services.yml
    @@ -682,9 +682,15 @@ services:
    +  url_generator.uncacheable:
         class: Drupal\Core\Routing\UrlGenerator
         arguments: ['@router.route_provider', '@path_processor_manager', '@route_processor_manager', '@config.factory', '@request_stack']
    +    public: false
    

    Do we never want to allow to call it? Just curious

  2. +++ b/core/lib/Drupal/Core/Render/UrlGenerator.php
    @@ -0,0 +1,121 @@
    +   */
    +  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();
    +  }
    

    Doesn't bubbles this then two times, once in generateFromRoute and once in generate()?

Wim Leers’s picture

(Working on fixing the failures and addressing #106.)


Note that the failures in #104 reflect the cache contexts that are missing in HEAD, where our tests have the wrong expectations.

The first step is to make this patch green. Then this patch will be blocked on #2450993: Rendered Cache Metadata created during the main controller request gets lost. Once that lands, we'll need to re-test this patch, and more wrong test expectations (i.e. missing cache contexts) will be revealed.

In other words: by fixing the root cause, we're finding where our expectations have been wrong, and where we've overlooked the cacheability of links. This all yields quite interesting information (as you will see in my next comment, stay tuned!).

Wim Leers’s picture

While working on this, I found that a form's action attribute also uses a URL that is dynamically generated using <current>, hence making that per-route, and therefore effectively any form that doesn't really care about the current page ends up being cached for every page (well, route). See #2504115-2: AJAX forms should submit to $form['#action'] instead of <current>.

dawehner’s picture

I'm curious whether the form #action should be then interested using some form of placeholder?

Wim Leers’s picture

Wim Leers’s picture

This is turning out to be yet another treasure trove of hidden cacheability bugs!

CommentRssTest: new url.site cache context
The RSS feed is generated by views, which uses the render system. The feed contains a link to the site, which depends on url.site.
Updated the test expectations.
PageCacheTagsIntegrationTest: new route cache context
Because the route.* routes have been optimized to just route now that thanks to this patch something is adding the route cache context.
That something is the UserLoginBlock, which is calling getDestinationArray(), which calls generateFromRoute('<current>', …), which explains it.
Updated the test expectations.
ShortcutCacheTagsTest + ToolbarCacheContextsTest: new route cache context
shortcut_preprocess_page() is calling $query += \Drupal::destination()->getAsArray();, which calls generateFromRoute('<current>', …), which explains it. Except that that code should never have been executed, since it actually calls that regardless of whether it will be used. By simply moving that call into the place where it is actually being used.
Views with attached feeds (FrontPageTest): new url.site cache context
url.site from \Drupal\views\Plugin\views\display\Feed::attachTo() which builds an URL.
Updated the test expectations.
Pagers (FrontPageTest, PagerTest, ExposedFormTest): ne w route cache context
route from template_preprocess_pager(), which calls \Drupal::url('<current>'). I thought about changing <current> to <none>, because this really only needs a query string, not the underlying route. But <none> is only designed for fragment URLs, so, no luck.
But, it's easy enough to change it to ($options['query']) ? '?' . UrlHelper::buildQuery($options['query']) : '' — which hence makes the route cache context disappear. Which is a good thing; for example, otherwise any non-page View reused on many pages would need to be re-rendered for every single route it appears on.
Sorting (FieldWebTest, GlossaryTest): new route cache context.
Entirely analogous to pagers. Wanted to fix this analogously, but this one is not just a URL, but a link. So I can't use the same work-around. Instead, thsi should probably fix #2305013: Add a helper method to the Url class to render just the query string and fragment.

So, still expect the failures in FieldWebTest, GlossaryTest. If I didn't make mistakes, that should bring us down to 6 failures.

dawehner’s picture

route from template_preprocess_pager(), which calls \Drupal::url(''). I thought about changing to , because this really only needs a query string, not the underlying route. But is only designed for fragment URLs, so, no luck.
But, it's easy enough to change it to ($options['query']) ? '?' . UrlHelper::buildQuery($options['query']) : '' — which hence makes the route cache context disappear. Which is a good thing; for example, otherwise any non-page View reused on many pages would need to be re-rendered for every single route it appears on.

As said on IRC, this would be just wrong. You need to render all existing query parameters as well, to have something working.

Wim Leers’s picture

FileSize
15.11 KB
3.87 KB

As said on IRC, this would be just wrong. You need to render all existing query parameters as well, to have something working.

That's actually already working:

    $options = array(
      'query' => pager_query_add_page($parameters, $element, $pager_max - 1),
    );

That pager_query_add_page() calls adds to the existing query args.

BUT you're absolutely right that the corresponding cache context (url.query_args) is missing. So that was yet another hidden cacheability bug! Fixed.


This interdiff beautifully shows how problematic the very legacy implementation of the Pager component really is.

Wim Leers’s picture

Status: Needs work » Needs review

Such noob.

Berdir’s picture

Reminder: There's a @todo in UserLoginForm to enable caching for that once this landed.

Also, #2375695: Condition plugins should provide cache contexts AND cacheability metadata needs to be exposed will conflict with this on the page cache integration test.

plach’s picture

Assigned: Wim Leers » plach

@Wim:

Working a bit on this, feel free to claim it back any time.

plach’s picture

Assigned: plach » Unassigned

Not yet, sorry

dawehner’s picture

Status: Needs work » Needs review
FileSize
14.47 KB

I'm trying to understand some of the test failure ... for now this is a reroll in order to get it working locally.

dawehner’s picture

Status: Needs work » Needs review
FileSize
17.72 KB
4.7 KB

That was doable.

plach’s picture

+++ b/core/modules/config/src/Tests/ConfigEntityListTest.php
@@ -38,7 +38,7 @@ protected function setUp() {
+  function ptestList() {

@@ -151,7 +151,7 @@ function testList() {
+  function ptestListUI() {

@@ -254,6 +254,7 @@ public function testPager() {
+    debug($this->getUrl());

@@ -267,6 +268,7 @@ public function testPager() {
+    debug($this->getUrl());

Mmh

dawehner’s picture

Oh well, the actual patch does not have that any longer :)

plach’s picture

Assigned: Unassigned » plach

On this for reals

lauriii’s picture

+++ b/core/modules/simpletest/src/WebTestBase.php
@@ -2355,16 +2355,34 @@ protected function clickLink($label, $index = 0) {
+   *   A path from the internal browser content.T

Just a small nit but there is one extra T

dawehner’s picture

  1. +++ b/core/includes/pager.inc
    @@ -218,7 +218,7 @@ function template_preprocess_pager(&$variables) {
    -    $items['first']['href'] = \Drupal::url('<current>', [], $options);
    +    $items['first']['href'] = ($options['query']) ? '?' . UrlHelper::buildQuery($options['query']) : '';
    

    We clearly need to discuss whether changing this is right always. Are there usecases to embed Drupal HTML into other sites for example?

    We could also replace the implementation of ?

  2. +++ b/core/includes/pager.inc
    @@ -311,6 +311,12 @@ function pager_query_add_page(array $query, $element, $index) {
    +  drupal_render($build);
    

    Let's not use the global function

  3. +++ b/core/lib/Drupal/Core/Render/UrlGenerator.php
    @@ -0,0 +1,121 @@
    + * The cacheable URL generator; decorates the uncacheable URL generator.
    

    Well, its nat that the URL generator is cacheable, its rather a CacheableMetadataAwareUrlGenerator, right? We used to have done A LOT of work on the cacheable URL generator, which did some actual caching. This horrible that this was blocked

  4. +++ b/core/lib/Drupal/Core/Render/UrlGenerator.php
    @@ -0,0 +1,121 @@
    +    $options['absolute'] = $absolute;
    +    $generated_url = $this->generateFromRoute($name, $parameters, $options, TRUE);
    +    $this->bubble($generated_url);
    +    return $generated_url->getGeneratedUrl();
    ...
    +    $generated_url = $this->urlGenerator->generateFromRoute($name, $parameters, $options, TRUE);
    +    $this->bubble($generated_url);
    +    return $collect_cacheability_metadata ? $generated_url : $generated_url->getGeneratedUrl();
    

    I still don't get why we need to bubble twice

Wim Leers’s picture

#128

  1. We clearly need to discuss whether changing this is right always. Are there usecases to embed Drupal HTML into other sites for example?

    Yes, but since #2514136: Add default clickjacking defense to core you'll have to do extra work for that already anyway, so yes, overriding the default implementation would do the trick.

  2. Sure, let's use \Drupal::service('renderer')->render()
  3. Better nuance, yes. Let's do that.
  4. Indeed, we should NOT do:
    $this->bubble($generated_url);
    

    but instead:

    if (!$collect_cacheability_metadata) {
      $this->bubble($generated_url);
    }
    
dawehner’s picture

+++ b/core/includes/pager.inc
@@ -218,7 +218,7 @@ function template_preprocess_pager(&$variables) {
-    $items['first']['href'] = \Drupal::url('<current>', [], $options);
+    $items['first']['href'] = ($options['query']) ? '?' . UrlHelper::buildQuery($options['query']) : '';

What I think we should do is to use and fix url generator to also take query strings into account, not just fragment

Wim Leers’s picture

What I think we should do is to use and fix url generator to also take query strings into account, not just fragment

+1, but that can be a major follow-up I think.

plach’s picture

Status: Needs work » Needs review
FileSize
17.21 KB
31.95 KB

This just fixes some test failures, reviews above still to be addressed.

plach’s picture

Issue tags: +D8 Accelerate London

.

Fabianx’s picture

  1. +++ b/core/lib/Drupal/Core/Render/UrlGenerator.php
    @@ -0,0 +1,121 @@
    +  /**
    +   * The uncacheable URL generator.
    +   *
    +   * @var \Drupal\Core\Routing\UrlGeneratorInterface
    +   */
    +  protected $urlGenerator;
    

    This is a _really_ nice usage of the decorator pattern here.

  2. +++ b/core/lib/Drupal/Core/Render/UrlGenerator.php
    @@ -0,0 +1,121 @@
    +    $this->bubble($generated_url);
    +    return $collect_cacheability_metadata ? $generated_url : $generated_url->getGeneratedUrl();
    ...
    +    $this->bubble($generated_url);
    +    return $collect_cacheability_metadata ? $generated_url : $generated_url->getGeneratedUrl();
    

    I am not sure we need to do that when the caller collects cacheable_metadata.

    It won't hurt, but it is also not necessary.

    @Wim: Thoughts?

  3. +++ b/core/modules/views/src/Plugin/views/style/Table.php
    @@ -444,8 +444,8 @@ public function getCacheContexts() {
    -        $contexts[] = 'url.query_args:order';
    -        $contexts[] = 'url.query_args:sort';
    +        // This needs to play well with any other exposed item on the page.
    +        $contexts[] = 'url.query_args';
    

    That is just too bad, I will read up where this change was discussed.

plach’s picture

Status: Needs work » Needs review
FileSize
5.33 KB
36.35 KB

More test fixes

Wim Leers’s picture

#135.2: that's what I said in #129.4 already, so yes :)

dawehner’s picture

Nice progress!

+++ b/core/modules/views/src/Plugin/views/pager/SqlBase.php
@@ -382,14 +382,8 @@ public function isCacheable() {
+    // This needs to play well with any other exposed item on the page.
+    return ['url.query_args'];

+++ b/core/modules/views/src/Plugin/views/style/Table.php
@@ -444,8 +444,8 @@ public function getCacheContexts() {
+        // This needs to play well with any other exposed item on the page.
+        $contexts[] = 'url.query_args';

The rendered link needs to play well with any other query parameter used on the page, like pager and exposed filter.

  1. +++ b/core/modules/language/src/Tests/LanguageUrlRewritingTest.php
    @@ -59,7 +59,7 @@ protected function setUp() {
    -  function testUrlRewritingEdgeCases() {
    +  function atestUrlRewritingEdgeCases() {
    

    Cheater!

  2. +++ b/core/modules/views_ui/src/Tests/PreviewTest.php
    @@ -263,7 +263,8 @@ protected function clickPreviewLinkAJAX($url, $row_count) {
    +    $output = $this->drupalPost($url, 'application/vnd.drupal-ajax', $post);
    +    $result = Json::decode($output);
    

    Let's skip this change ...

  3. +++ b/core/tests/Drupal/Tests/Core/Routing/UrlGeneratorTest.php
    @@ -23,6 +23,7 @@
    + * @coversDefaultClass \Drupal\Core\Routing\UrlGenerator
      * @group Routing
    

    +1!!!

Status: Needs work » Needs review
Wim Leers’s picture

#2450993: Rendered Cache Metadata created during the main controller request gets lost just landed, which is why plach re-tested in #140. This is now completely unblocked :)

dawehner’s picture

FileSize
36.35 KB

Just reupload the patch and let's see what fails now.

plach’s picture

Status: Needs work » Needs review
FileSize
4.29 KB
38.34 KB

Let's get rid of a few failures

Wim Leers’s picture

+++ b/core/modules/simpletest/src/WebTestBase.php
@@ -954,7 +954,11 @@ protected function initKernel(Request $request) {
+    // Make sure we use the uncacheable URL generator in tests, since most of
+    // the URL generations will happen outside of a render context.
+    $container->set('url_generator', $container->get('url_generator.uncacheable'));
+    return $container;

Looks like this didn't actually help resolve the ~1000 Drupal\simpletest\WebTestBase->buildUrl('user/login', Array) -style exceptions.

I wonder why.

plach’s picture

Status: Needs work » Needs review
FileSize
38.34 KB

Just a reroll for now

plach’s picture

Status: Needs work » Needs review
FileSize
2.97 KB
41.3 KB

Fixed some failures

plach’s picture

#2491981: There are too many ways to generate URLs and links is really relevant to this: we are wondering whether it should become a prerequisite. Stay tuned.

plach’s picture

Status: Needs work » Needs review
FileSize
2.01 KB
40.08 KB

Fixed PHP unit and addressed part of #128.

plach’s picture

Status: Needs work » Needs review
FileSize
7.61 KB
38.63 KB

This patch introduces a way to detect whether we are in a render context, which allows the URL generator to bubble cacheable metadata only when it's the case. This should fix quite some failures.

plach’s picture

Status: Needs work » Needs review
FileSize
4.23 KB
39.01 KB

Addressed the remaining points of the review above and fixed more failures.

Fabianx’s picture

+++ b/core/lib/Drupal/Core/Render/UrlGenerator.php
@@ -91,9 +93,7 @@ public function generate($name, $parameters = array(), $absolute = FALSE) {
-    if (!$collect_cacheability_metadata) {
-      $this->bubble($generated_url);
-    }
+    $this->bubble($generated_url);

@@ -102,9 +102,7 @@ public function generateFromRoute($name, $parameters = array(), $options = array
-    if (!$collect_cacheability_metadata) {
-      $this->bubble($generated_url);
-    }
+    $this->bubble($generated_url);

There is no need to remove that.

If I collect cacheable metadata myself I probably have reasons for doing so.

Fabianx’s picture

+++ b/core/lib/Drupal/Core/Render/Renderer.php
@@ -538,7 +538,7 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
-    return isset(static::$context);
+    return isset(static::$context) && !static::$context->isEmpty();

That would be nice, but is unfortunately the wrong fix.

A render context will always be empty when a controller gets one.

plach’s picture

Status: Needs work » Needs review
FileSize
3.52 KB
40.42 KB

There is no need to remove that.
If I collect cacheable metadata myself I probably have reasons for doing so.

Yep, thanks, I had reverted that part unintentionally: #157 already re-included that.

That would be nice, but is unfortunately the wrong fix.

I suspected that, but I wanted to see what would happen. I'm going to figure out what's going on for the last failures tomorrow with @Wim.

The attached patch should fix the last failure, aside from those in https://qa.drupal.org/pifr/test/1088008.

Wim Leers’s picture

I suspected that, but I wanted to see what would happen. I'm going to figure out what's going on for the last failures tomorrow with @Wim.

+1

+++ b/core/includes/pager.inc
@@ -176,6 +176,7 @@ function template_preprocess_pager(&$variables) {
+  $route = !empty($variables['pager']['#route']) ? $variables['pager']['#route'] : '<none>';

s/#route/#route_name/, because "route" means "route name + parameters".

dawehner’s picture

It is great that its green

  1. +++ b/core/includes/pager.inc
    @@ -176,6 +176,7 @@ function template_preprocess_pager(&$variables) {
       $quantity = $variables['pager']['#quantity'];
    +  $route = !empty($variables['pager']['#route']) ? $variables['pager']['#route'] : '<none>';
       global $pager_page_array, $pager_total;
    

    As wim was saying, let's use #route_name. ... Do we have any place to document that? Maybe on \Drupal\Core\Render\Element\Pager would actually make sense.

  2. +++ b/core/includes/pager.inc
    @@ -311,6 +312,12 @@ function pager_query_add_page(array $query, $element, $index) {
    +
    +  // This is is based on the entire current query string. We need to ensure
    +  // cacheability is affected accordingly.
    +  $build = ['#cache' => ['contexts' => ['url.query_args']]];
    +  \Drupal::service('renderer')->render($build);
    +
    

    I think we should rather put $variables['#cache']['contexts'][] = 'url.query_args'; into template_preprocess_page() and template_preprocess_mini_pager().

  3. +++ b/core/includes/pager.inc
    @@ -311,6 +312,12 @@ function pager_query_add_page(array $query, $element, $index) {
    diff --git a/core/lib/Drupal/Core/EventSubscriber/DefaultExceptionHtmlSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/DefaultExceptionHtmlSubscriber.php
    
    diff --git a/core/lib/Drupal/Core/EventSubscriber/DefaultExceptionHtmlSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/DefaultExceptionHtmlSubscriber.php
    index e3ec321..c1e5a21 100644
    
    index e3ec321..c1e5a21 100644
    --- a/core/lib/Drupal/Core/EventSubscriber/DefaultExceptionHtmlSubscriber.php
    
    --- a/core/lib/Drupal/Core/EventSubscriber/DefaultExceptionHtmlSubscriber.php
    +++ b/core/lib/Drupal/Core/EventSubscriber/DefaultExceptionHtmlSubscriber.php
    
    +++ b/core/lib/Drupal/Core/EventSubscriber/DefaultExceptionHtmlSubscriber.php
    +++ b/core/lib/Drupal/Core/EventSubscriber/DefaultExceptionHtmlSubscriber.php
    @@ -15,8 +15,8 @@
    
    @@ -15,8 +15,8 @@
     use Symfony\Component\HttpFoundation\Request;
     use Symfony\Component\HttpFoundation\Response;
     use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;
    -use Symfony\Component\HttpKernel\HttpKernelInterface;
     use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
    +use Symfony\Component\HttpKernel\HttpKernelInterface;
     
     /**
      * Exception subscriber for handling core default HTML error pages.
    

    Seems entirely out of scope ...

  4. +++ b/core/lib/Drupal/Core/Render/CacheableMetadataAwareUrlGenerator.php
    @@ -0,0 +1,127 @@
    +/**
    + * The cacheable URL generator; decorates the uncacheable URL generator.
    + */
    +class CacheableMetadataAwareUrlGenerator implements UrlGeneratorInterface {
    

    See earlier comment, we should explain more and its not that the URL generator is cacheable.

  5. +++ b/core/lib/Drupal/Core/Render/CacheableMetadataAwareUrlGenerator.php
    @@ -0,0 +1,127 @@
    +    $generated_url = $this->generateFromRoute($name, $parameters, $options, TRUE);
    +    $this->bubble($generated_url);
    ...
    +    $generated_url = $this->urlGenerator->generateFromRoute($name, $parameters, $options, TRUE);
    +    if (!$collect_cacheability_metadata) {
    +      $this->bubble($generated_url);
    +    }
    +    return $collect_cacheability_metadata ? $generated_url : $generated_url->getGeneratedUrl();
    

    I still think its wrong to bubble twice

  6. +++ b/core/lib/Drupal/Core/Render/RendererInterface.php
    @@ -322,6 +322,14 @@ public function renderPlain(&$elements);
    +   * Checks whether a render context is active.
    +   *
    +   * @return bool
    +   *   TRUE if the renderer has a render context active, FALSE otherwise.
    +   */
    

    It should at least explain that this is rather @internal

  7. +++ b/core/modules/comment/src/Tests/CommentPagerTest.php
    @@ -381,7 +381,12 @@ protected function clickLinkWithXPath($xpath, $arguments = array(), $index = 0)
    +      $path = $urls[$index]['href'];
    +      // Support relative links: links that don't have a path component (and
    +      // hence only a fragment or querystring), use the path component of the
    +      // current page.
    +      $path = (isset(parse_url($path)['path'])) ? $path : parse_url($this->getUrl())['path'] . $path;
    +      $url_target = $this->getAbsoluteUrl($path);
    

    With the change to getAbsoluteUrl, I think this entire part is not needed.

  8. +++ b/core/modules/simpletest/tests/src/Unit/WebTestBaseTest.php
    index 87730ff..bd78ec6 100644
    --- a/core/modules/system/src/Tests/Pager/PagerTest.php
    
    --- a/core/modules/system/src/Tests/Pager/PagerTest.php
    +++ b/core/modules/system/src/Tests/Pager/PagerTest.php
    
    +++ b/core/modules/system/src/Tests/Pager/PagerTest.php
    @@ -65,7 +65,7 @@ function testActiveClass() {
    -    $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));
    
    @@ -77,18 +77,21 @@ protected function testPagerQueryParametersAndCacheContext() {
    -    $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->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->assertText(t('Pager calls: 2'), 'Second link call to pager shows 2 calls.');
    

    Can't we reuse getAbsoluteUrl?

  9. +++ b/core/modules/system/system.module
    @@ -658,7 +658,7 @@ function system_js_settings_alter(&$settings, AttachedAssetsInterface $assets) {
    -  Url::fromRoute('<front>', [], array('script' => &$scriptPath, 'prefix' => &$pathPrefix))->toString();
    +  Url::fromRoute('<front>', [], array('script' => &$scriptPath, 'prefix' => &$pathPrefix))->toString(TRUE);
    

    Opened up a follow up for this piece of shit: #2527848: Try to get rid of Url::fromRoute in system_js_settings_alter()

Wim Leers’s picture

Do we have any place to document that? Maybe on \Drupal\Core\Render\Element\Pager would actually make sense.

+1

I still think its wrong to bubble twice

The code you cited specifically prevents double bubbling.

@internal

+1

RE: follow-up-to-remove-that-piece-of-shit: +1000

catch’s picture

Title: Link CSRF tokens can be hijacked when cached with insufficient contexts » Url generation does not bubble cache contexts

Re-titling.

plach’s picture

FileSize
17.81 KB
53.58 KB

The comment-related test failures were caused by a sub-request being fired: @dawehner suggested to have a separate "global" render context for each request, which is what this patch is implementing. Let's see how many failures remain after reverting the wrong fix mentioned in #160. Reviews still to be addressed.

Wim Leers’s picture

Yep, so a render context per request; to allow subrequests to have entirely separate render contexts. Makes total sense. Before #2450993: Rendered Cache Metadata created during the main controller request gets lost + this patch, that bug was simply being masked. It's been there all along.
(If a service contains state, like the Renderer does, then it needs to ensure it is different per request.)


The interdiff looks great! Basically only nits. Curious how many failures we'll have left after this :)

  1. +++ b/core/lib/Drupal/Core/Render/Renderer.php
    @@ -22,6 +23,23 @@
    +   * @var \Drupal\Core\Render\RenderContext[]
    

    It's not an array, but an \SplObjectStorage.

  2. +++ b/core/lib/Drupal/Core/Render/Renderer.php
    @@ -22,6 +23,23 @@
    +  protected static $contextCollection;
    

    The patch looks strange because you moved this to the top.
    Can we move it to where protected static $context; used to be defined? That'd make the patch much more legible.

    Besides, we always list the injected services first, then the other protected class properties.

  3. +++ b/core/lib/Drupal/Core/Render/Renderer.php
    @@ -268,10 +289,10 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
    -        static::$context->update($elements);
    +        $context->update($elements);
    

    Nice :)

  4. +++ b/core/lib/Drupal/Core/Render/Renderer.php
    @@ -546,23 +568,51 @@ public function hasRenderContext() {
    +    catch (\UnexpectedValueException $e) {
    +      $context = NULL;
    +    }
    

    When can this happen? I don't understand this bit.

  5. +++ b/core/lib/Drupal/Core/Render/Renderer.php
    @@ -546,23 +568,51 @@ public function hasRenderContext() {
    +  /**
    +   * Sets the current render context.
    +   *
    +   * @return $this
    +   */
    +  protected function setCurrentRenderContext(RenderContext $context = NULL) {
    

    Incomplete docblock.

    Also, why is NULL an acceptable variable? The docblock should say so.

  6. +++ b/core/modules/views/tests/src/Unit/Controller/ViewAjaxControllerTest.php
    @@ -7,13 +7,15 @@
    -use Drupal\Tests\UnitTestCase;
    +  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;
     
    -/**
    +  /**
    

    Nit: a bunch of indentation problems. PHPStorm does this sometimes :(

plach’s picture

Status: Needs work » Needs review
FileSize
6.06 KB
57.97 KB

This should fix a few more failures by adding the UncacheableUrl class and using to fix REST entity canonical URIs.

Reviews not addressed yet, sorry, focusing on test failures for now.

Edit: Aw, I forgot some crap in PageCacheTest, I'll fix it in the next patch.

plach’s picture

plach’s picture

Status: Needs work » Needs review
FileSize
10.96 KB
66.29 KB

Hopefully more

plach’s picture

Status: Needs work » Needs review
FileSize
12.07 KB
74.95 KB

MOAR fixes

dawehner’s picture

plach’s picture

Status: Needs work » Needs review
FileSize
2.08 KB
76.32 KB

And a few more

dawehner’s picture

FileSize
73.25 KB
4.36 KB

Some cleanup in the meantime.

plach’s picture

FileSize
76.32 KB

Reuploading #178.

larowlan’s picture

  1. +++ b/core/includes/batch.inc
    @@ -33,61 +34,69 @@
     function _batch_page(Request $request) {
    -  $batch = &batch_get();
    +  /** @var \Drupal\Core\Render\RendererInterface $renderer */
    +  $renderer = \Drupal::service('renderer');
    

    This patch really highlights the dark corners of core. Batch API. Pager. Updates. Places we just didn't get time to modernize.

  2. +++ b/core/includes/pager.inc
    @@ -311,6 +312,12 @@ function pager_query_add_page(array $query, $element, $index) {
    +  $build = ['#cache' => ['contexts' => ['url.query_args']]];
    +  \Drupal::service('renderer')->render($build);
    

    Is this explicitly relying on the early rendering catch-all?

  3. +++ b/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php
    @@ -118,7 +119,7 @@ public function onController(FilterControllerEvent $event) {
    +    $context = new EarlyRenderContext(); // FIXME
    
    +++ b/core/lib/Drupal/Core/Render/CacheableMetadataAwareUrlGenerator.php
    @@ -0,0 +1,131 @@
    +    $early_render = \Drupal::service('renderer')->getCurrentRenderContext() instanceof \Drupal\Core\Render\EarlyRenderContext; // FIXME Remove this
    +    if ($early_render) {
    +      debug(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10));
    +    }
    

    Do we have a plan to fix this? Or is it just to find bits in core (like pager, batch api etc) that aren't doing things correctly?

  4. +++ b/core/lib/Drupal/Core/Utility/LinkGenerator.php
    @@ -81,8 +81,14 @@ public function generateFromLink(Link $link, $collect_cacheability_metadata = FA
    +    if ($url instanceof UncacheableUrl) {
    +      $collect_cacheability_metadata = FALSE;
    +    }
    +    else {
    +      $url->setUrlGenerator($this->urlGenerator);
    +    }
    

    Is there merit in injecting the uncacheable url generator and setting it on the uncacheable url too - to avoid container calls? Or are these in such minority that not worth it?

  5. +++ b/core/modules/simpletest/tests/src/Unit/WebTestBaseTest.php
    @@ -198,4 +198,41 @@ public function testClickLink($expected, $label, $index, $xpath_data) {
    +    $GLOBALS['base_url'] = 'http://example.com';
    +    $GLOBALS['base_path'] = 'drupal';
    

    Can this bleed into subsequent tests? Should we be marking this test as requiring an isolated process like we do for functional and plan to do for kernel tests?

plach’s picture

FileSize
76.75 KB
1.05 KB

This includes also the interdiff from #179.

@larowlan:

Thanks for the review, I will address it soon :)

This patch really highlights the dark corners of core. Batch API. Pager. Updates. Places we just didn't get time to modernize.

Well, not only them: also every controller dealing with non-HTML responses has issues. We'll have to create a follow-up to polish the way "rendering" (i.e. stringification) is applied to non-HTML stuff.

Is this explicitly relying on the early rendering catch-all?

Not sure actually, but in any case the response will be a render array so the metadata will be eventually bubbled.

Do we have a plan to fix this? Or is it just to find bits in core (like pager, batch api etc) that aren't doing things correctly?

Just debugging crap, not a good moment to review this code ;)

Is there merit in injecting the uncacheable url generator and setting it on the uncacheable url too - to avoid container calls? Or are these in such minority that not worth it?

We'd have to profile but the amount of uncacheable URLs should be fairly small.

plach’s picture

Status: Needs work » Needs review
plach’s picture

Status: Needs work » Needs review
FileSize
1.18 KB
79.85 KB

And more fixes, this might come back green. Addressing reviews now.

plach’s picture

Wrong interdiff, sorry.

plach’s picture

This should address #163 and #168. Working on test coverage now.

@larowlan:

Can this bleed into subsequent tests? Should we be marking this test as requiring an isolated process like we do for functional and plan to do for kernel tests?

I checked the PHPUnit docs and that should not be necessary:

By default, PHPUnit runs your tests in a way where changes to global and super-global variables ($GLOBALS, $_ENV, $_POST, $_GET, $_COOKIE, $_SERVER, $_FILES, $_REQUEST) do not affect other tests. Optionally, this isolation can be extended to static attributes of classes.

See https://phpunit.de/manual/current/en/fixtures.html#fixtures.global-state.

Wim Leers’s picture

Status: Needs review » Needs work
  1. +++ b/core/includes/batch.inc
    @@ -33,61 +34,69 @@
    +  $build = $renderer->executeInRenderContext($context, function() use ($request) {
    

    Can't we avoid changing _batch_page()?

    Looking at its callers:

    • BatchController already is executed in a render context because it's a regular controller.
    • DbUpdateController already is executed in a render context because it's a regular controller
    • authorize.php would need to execute it in a render context indeed
    • install.core.inc would need to execute it in a render context indeed

    I guess it makes kinda sense then, it could go either way.

    But this is *so* hard to review, so I'm assuming you purely indented and wrapped things.

  2. +++ b/core/includes/pager.inc
    @@ -311,6 +312,12 @@ function pager_query_add_page(array $query, $element, $index) {
    diff --git a/core/lib/Drupal/Core/EventSubscriber/DefaultExceptionHtmlSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/DefaultExceptionHtmlSubscriber.php
    

    Changes here can be reverted.

  3. +++ b/core/lib/Drupal/Core/Render/CacheableMetadataAwareUrlGenerator.php
    @@ -0,0 +1,131 @@
    + * The cacheable URL generator; decorates the uncacheable URL generator.
    ...
    +class CacheableMetadataAwareUrlGenerator implements UrlGeneratorInterface {
    

    Naming mismatch.

  4. +++ b/core/lib/Drupal/Core/Render/CacheableMetadataAwareUrlGenerator.php
    @@ -0,0 +1,131 @@
    +    if ($this->renderer->hasRenderContext()) {
    

    We should document why we only call this if there actually is a render context.

  5. +++ b/core/lib/Drupal/Core/Render/Element/FormElement.php
    @@ -114,8 +115,7 @@ public static function processAutocomplete(&$element, FormStateInterface $form_s
    -      $path = \Drupal::urlGenerator()->generate($element['#autocomplete_route_name'], $parameters);
    +      $path = UncacheableUrl::fromRoute($element['#autocomplete_route_name'], $parameters)->toString();
    

    Let's document why it's okay to use an uncacheable URL here.

    AFAICT this is NOT uncacheable, because $path is assigned to a data- attribute that makes it way into the form.

    Also: $access's cacheability metadata should also be applied. Technically out of scope here though.

  6. +++ b/core/lib/Drupal/Core/Render/Element/RenderElement.php
    @@ -248,7 +249,7 @@ public static function preRenderAjaxForm($element) {
    -        $settings['url'] = Url::fromRoute('<current>');
    +        $settings['url'] = UncacheableUrl::fromRoute('<current>');
    

    For similar reasons, this is not actually cacheable. This ends up in drupalSettings, and if drupalSettings varies per route, we need to know that.

    But, given that drupalSettings already vary per route (see system_js_settings_alter()), this would be okay to stay.

    This either needs docs explaining why this is okay, or it could just apply the cacheability metadata of the URL to $element, that's probably less work.

  7. +++ b/core/lib/Drupal/Core/Render/Renderer.php
    @@ -23,6 +23,23 @@
    +  protected static $contextCollection;
    
    @@ -58,24 +75,18 @@ class Renderer implements RendererInterface {
    -  protected static $context;
    

    Please see #168.2.

  8. +++ b/core/lib/Drupal/Core/UncacheableUrl.php
    @@ -0,0 +1,28 @@
    +class UncacheableUrl extends Url {
    +
    +  /**
    +   * {@inheritdoc}
    +   */
    +  protected function urlGenerator() {
    +    if (!$this->urlGenerator) {
    +      $this->urlGenerator = \Drupal::service('url_generator.uncacheable');
    +    }
    +    return $this->urlGenerator;
    +  }
    +
    +}
    

    Hrm, doesn't this mean you can still do ->setUrlGenerator(\Drupal::service('url_generator'));?

    Though I guess we can't protect against that.

  9. +++ b/core/lib/Drupal/Core/Utility/LinkGenerator.php
    @@ -81,8 +81,14 @@ public function generateFromLink(Link $link, $collect_cacheability_metadata = FA
    +    if ($url instanceof UncacheableUrl) {
    +      $collect_cacheability_metadata = FALSE;
    +    }
    +    else {
    +      $url->setUrlGenerator($this->urlGenerator);
    +    }
    

    I'm wondering if Url::hasUrlGenerator() would be better?

  10. +++ b/core/modules/filter/src/Element/TextFormat.php
    @@ -181,7 +182,7 @@ public static function processFormat(&$element, FormStateInterface $form_state,
    -      '#markup' => \Drupal::l(t('About text formats'), new Url('filter.tips_all', array(), array('attributes' => array('target' => '_blank')))),
    +      '#markup' => \Drupal::l(t('About text formats'), UncacheableUrl::fromRoute('filter.tips_all', [], ['attributes' => ['target' => '_blank']])),
    

    I don't think this is correct.

    Also, why not just change this to:

        $element['format']['help'] = array(
          '#type' => 'container',
          'about' => [
            '#type' => 'link',
            '#title' => t('About text formats'),
            '#url' => new Url('filter.tips_all', [], ['attributes' => ['target' => '_blank']]),
          ],
          '#attributes' => array('class' => array('filter-help')),
          '#weight' => 0,
        );
    
    
  11. +++ b/core/modules/filter/src/FilterPermissions.php
    @@ -58,8 +59,10 @@ public function permissions() {
    +        $url = UncacheableUrl::fromRoute($info->getRouteName(), $info->getRouteParameters())->toString();
    

    It seems this patch uses UncacheableUrl in admin-ish places. This here is clearly an admin route. The text format one (previous comment) is admin-ish, but can also appear on other places.

    What about just doing #2528598: Opt out of cacheablity protection on route level and getting rid of UncacheableUrl? That'd keep things much simpler!

  12. +++ b/core/modules/hal/src/Normalizer/ContentEntityNormalizer.php
    @@ -207,8 +208,8 @@ protected function getEntityUri(EntityInterface $entity) {
    +    return UncacheableUrl::fromRoute($url->getRouteName(), $url->getRouteParameters(), ['absolute' => TRUE])->toString();
    

    That would also solve this.

  13. +++ b/core/modules/language/src/Tests/LanguageUrlRewritingTest.php
    @@ -139,7 +139,7 @@ function testDomainNameNegotiationPort() {
    -    $url = Url::fromRoute('<none>', [], [
    +    $url = Url::fromRoute('<front>', [], [
    
    @@ -149,7 +149,7 @@ function testDomainNameNegotiationPort() {
    -    $url = Url::fromRoute('<none>', [], [
    +    $url = Url::fromRoute('<front>', [], [
    

    Why these changes?

  14. +++ b/core/modules/simpletest/src/WebTestBase.php
    @@ -32,6 +32,7 @@
    +use Zend\Diactoros\Uri;
    
    @@ -2355,16 +2360,35 @@ protected function clickLink($label, $index = 0) {
    +    // 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']);
    +    }
    

    Interesting :)

  15. +++ b/core/modules/simpletest/tests/src/Unit/WebTestBaseTest.php
    --- a/core/modules/system/src/Controller/DbUpdateController.php
    +++ b/core/modules/system/src/Controller/DbUpdateController.php
    

    I don't understand these changes; this is a controller that doesn't return responses other than RedirectResponse (for which exceptions are never thrown), isn't this already wrapped in a render context?

Wim Leers’s picture

Review of #190's interdiff.

+++ b/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php
@@ -119,7 +119,7 @@ public function onController(FilterControllerEvent $event) {
-    $context = new EarlyRenderContext(); // FIXME
+    $context = new EarlyRenderContext();

+++ b/core/lib/Drupal/Core/Render/EarlyRenderContext.php
@@ -7,7 +7,7 @@
- * TODO
+ * TODO Should we keep this?

This is just for debugging the patch, right?

EDIT: oh, I guess it's to make it clear while debugging code for the remainder of D8 cycle. That makes some sense. Semantics++

OTOH, the PHP call stack already shows that anyway. So I'm not sure if it's actually very valuable.

I could live with this I think, curious what Fabian/catch/dawehner think.

dawehner’s picture

Can't we avoid changing _batch_page()?

In that case I agree, opt out of batch on the routing level is fine.

For similar reasons, this is not actually cacheable. This ends up in drupalSettings, and if drupalSettings varies per route, we need to know that.

Given that this ends up on GET requests, we actually need to apply the cacheablity metadata, so let's do it correctly and apply the metadata
to the element.

Hrm, doesn't this mean you can still do ->setUrlGenerator(\Drupal::service('url_generator'));?
Though I guess we can't protect against that.

MH, we should actually. We should override set and check for the additional interface. Plach was suggesting

b/core/modules/filter/src/Element/TextFormat.php
@@ -181,7 +182,7 @@ public static function processFormat(&$element, FormStateInterface $form_state,
- '#markup' => \Drupal::l(t('About text formats'

I don't think this is correct.

Wlel, I think we should give a shit about filter tips, dont' we?

Continue later

plach’s picture

Status: Needs work » Needs review
FileSize
26.36 KB
72.4 KB

This should address the reviews above. A few replies to #191:

1: Implemented the fix suggested in #2528598-2: Opt out of cacheablity protection on route level and reverted the related changes.
2: Already done earlier
3: Also this one should be already fixed.
4: Done
5: Fixed
6: Fixed
7: I had moved the definition of the static property below the other ones, as before it was surrounded by non-static properties which was very confusing.
8: I discussed with @dawehner a way for the cacheable metadata aware decorator to identify uncacheable URLs and skip bubbling for them.
9: We are not checking whether the generator is set, HEAD does that unconditionally, we are just avoiding collecting metadata when the URL is uncacheable. Removed this in favor of #8.
10: Fixed.
11: Both @dawehner and I find the UncacheableUrl pattern very handy, we don't think getting rid of it will improve DX.
12: We don't want to disable caching for REST routes AFAICT, so in this case an uncacheable URL is the way to go IMHO, until we have a better handling of non-HTML routes.
13: The test was erroneously relying on the <none> route to generate a language-rewritten base url, relying on an implementation detail, because when the outbound language processor sets its own base url (domain-based language negotiation) the remaining URL processing is skipped.
15: Not sure which changes you were referring to, however any change to DbUpdateController is reverted.

@#192:

I got rid of all that debugging stuff, we can always get it in later if needed.

plach’s picture

Status: Needs work » Needs review
FileSize
72.58 KB

Rerolled

Wim Leers’s picture

#195.7: ok.
#195.10: that solution works as well :)
#195.11/12: hrm, I'm not entirely convinced yet. But let's go for it now. Let's see what other reviewers think about UncacheableUrl :)
#195.13: oh, wow :P

EDIT: oh, and thanks for addressing everything else!


  1. +++ b/core/includes/pager.inc
    @@ -317,6 +313,11 @@ function pager_query_add_page(array $query, $element, $index) {
    +  // This is is based on the entire current query string. We need to ensure
    +  // cacheability is affected accordingly.
    +  $build = ['#cache' => ['contexts' => ['url.query_args']]];
    +  \Drupal::service('renderer')->render($build);
    

    So now it's moved *back* here. Why?

  2. +++ b/core/lib/Drupal/Core/Render/Element/FormElement.php
    @@ -112,17 +113,24 @@ public static function validatePattern(&$element, FormStateInterface $form_state
    +      CacheableMetadata::createFromObject($url)->applyTo($element);
    +      CacheableMetadata::createFromObject($access)->applyTo($element);
    

    The first overwrites any pre-existing #cache metadata, and the second overwrites the first.

    Should be:

    CacheableMetadata::createFromRenderArray($element)
      ->merge($url)
      ->merge(CacheableMetadata::createFromObject($access))
      ->applyTo($element);
    
  3. +++ b/core/lib/Drupal/Core/Render/Element/RenderElement.php
    @@ -265,7 +265,9 @@ public static function preRenderAjaxForm($element) {
    +        CacheableMetadata::createFromObject($url)->applyTo($element);
    

    Similar overwriting problem.

plach’s picture

Status: Needs work » Needs review
FileSize
3.2 KB
72.65 KB

#199.1 still to be addressed, I want to try it in isolation.

Wim Leers’s picture

+++ b/core/lib/Drupal/Core/Render/Element/FormElement.php
@@ -124,13 +124,15 @@ public static function processAutocomplete(&$element, FormStateInterface $form_s
+        ->merge($access)

$access is not guaranteed to implement CacheableDependencyInterface, so it needs to be wrapped in CacheableMetadata::createFromObject().

plach’s picture

FileSize
74.85 KB
4.35 KB

Last stuff, on tests now. Hopefully I didn't introduce too many failures...

Wim Leers’s picture

#202's interdiff looks great!

Just one remark:

+++ b/core/modules/system/src/Tests/Pager/PagerTest.php
@@ -72,7 +72,7 @@ function testActiveClass() {
-  protected function testPagerQueryParametersAndCacheContext() {
+  public function testPagerQueryParametersAndCacheContext() {

Why?

plach’s picture

it bugged me, but I can revert it :)

plach’s picture

Status: Needs work » Needs review
Issue tags: -Needs tests
FileSize
2.74 KB
5.82 KB
77.27 KB

Here is some test coverage for the main issue plus some minor code clean-up, working on some unit tests now.

dawehner’s picture

  1. +++ b/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php
    @@ -127,8 +138,9 @@ protected function wrapControllerExecutionInRenderContext($controller, array $ar
    +    // not cacheable we simply discard it.
    +    if (!$context->isEmpty() && !$this->routeMatch->getRouteObject()->getOption('no_cache')) {
           // If a render array is returned by the controller, merge the "lost"
           // bubbleable metadata.
    

    Do yeah we should add test coverage for that.

  2. +++ b/core/lib/Drupal/Core/Render/CacheableMetadataAwareUrlGenerator.php
    @@ -0,0 +1,133 @@
    +/**
    + * Implements a decorator for the URL generator that allows to automatically
    + * collect and bubble up cache metadata attached to URLs. This approach helps
    + * keeping the render and the routing subsystems decoupled.
    + */
    +class CacheableMetadataAwareUrlGenerator implements UrlGeneratorInterface {
    

    Do we mind putting an @see to the UncachedUrl to make that more discoverage?

  3. +++ b/core/lib/Drupal/Core/Render/Renderer.php
    @@ -88,15 +99,23 @@ class Renderer implements RendererInterface {
    +    // Initialize the context collection if needed.
    +    if (!isset(static::$contextCollection)) {
    +      static::$contextCollection = new \SplObjectStorage();
    +    }
    

    I think its totally fine to create the object here. Passing that in is kinda like passing in an array, as its a "dependency".

  4. +++ b/core/lib/Drupal/Core/Render/Renderer.php
    @@ -538,25 +558,66 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
    +      $context = static::$contextCollection[$request];
    ...
    +    static::$contextCollection[$request] = $context;
    

    I think we should interact with the variable as if it would not static, so we might be able really easy to switch over in the future.

  5. +++ b/core/lib/Drupal/Core/Theme/ThemeManager.php
    @@ -302,14 +302,20 @@ protected function theme($hook, $variables = array()) {
    +        drupal_render($preprocess_bubbleable);
    

    Do you mind open a follow to change the drupal_render call?

  6. +++ b/core/modules/comment/src/Controller/CommentController.php
    @@ -129,7 +129,8 @@ public function commentPermalink(Request $request, CommentInterface $comment) {
    -      $redirect_request = Request::create($entity->url(), 'GET', $request->query->all(), $request->cookies->all(), array(), $request->server->all());
    +      $url = $entity->urlInfo()->toString(TRUE)->getGeneratedUrl();
    +      $redirect_request = Request::create($url, 'GET', $request->query->all(), $request->cookies->all(), array(), $request->server->all());
    
    +++ b/core/modules/filter/src/FilterPermissions.php
    @@ -58,8 +59,10 @@ public function permissions() {
    +        $info = $format->urlInfo();
    +        $url = UncacheableUrl::fromRoute($info->getRouteName(), $info->getRouteParameters())->toString();
    
    +++ b/core/modules/hal/src/Normalizer/ContentEntityNormalizer.php
    @@ -207,8 +208,8 @@ protected function getEntityUri(EntityInterface $entity) {
    -    $url = $entity->urlInfo('canonical', ['absolute' => TRUE]);
    -    return $url->setRouteParameter('_format', 'hal_json')->toString();
    +    $url = $entity->urlInfo('canonical')->setRouteParameter('_format', 'hal_json');
    +    return UncacheableUrl::fromRoute($url->getRouteName(), $url->getRouteParameters(), ['absolute' => TRUE])->toString();
    
    +++ b/core/modules/rest/src/Plugin/rest/resource/EntityResource.php
    @@ -109,7 +110,9 @@ public function post(EntityInterface $entity = NULL) {
    -      return new ResourceResponse(NULL, 201, array('Location' => $entity->url('canonical', ['absolute' => TRUE])));
    +      $info = $entity->urlInfo('canonical');
    +      $url = UncacheableUrl::fromRoute($info->getRouteName(), $info->getRouteParameters(), ['absolute' => TRUE])->toString();
    +      return new ResourceResponse(NULL, 201, ['Location' => $url]);
    

    What about UncacheableUrl::fromUrl()

  7. +++ b/core/modules/system/src/Tests/Pager/PagerTest.php
    @@ -65,30 +65,35 @@ function testActiveClass() {
    +    $this->drupalGet($this->getAbsoluteUrl($elements[0]['href']));
    +    $this->drupalGet($GLOBALS['base_root'] . parse_url($this->getUrl())['path'] . $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));
    

    The second drupalGet() seems pointless, let's just use $this->getAbsoluteUrl()

Fabianx’s picture

  1. +++ b/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php
    @@ -127,8 +138,9 @@ protected function wrapControllerExecutionInRenderContext($controller, array $ar
    +    if (!$context->isEmpty() && !$this->routeMatch->getRouteObject()->getOption('no_cache')) {
    

    I think we should ask for the page_cache_kill_switch service here and not special case routes?

    Hmmm, that won't work, but does that mean that when page_cache module is not loaded, that the no cache routes are cached by reverse proxies and potentially browsers???

    Uhm, will follow-up with Wim. @WimLeers: ^^

    Should add a @todo when that other issue is created.

  2. +++ b/core/lib/Drupal/Core/Render/CacheableMetadataAwareUrlGenerator.php
    @@ -0,0 +1,133 @@
    +    // Bubbling metadata makes sense only if inside a render context. All code
    +    // running outside controllers has no render context by default, so URLs
    +    // used there are not supposed to affected the response cacheability.
    

    That sentence needs a little work:

    - e.g. s/affected/affect/

  3. +++ b/core/lib/Drupal/Core/Render/CacheableMetadataAwareUrlGenerator.php
    @@ -0,0 +1,133 @@
    +    if (empty($options[UncacheableUrl::UNCACHEABLE_OPTION]) && $this->renderer->hasRenderContext()) {
    

    Can we check if generatedUrl->isEmpty() / isDefault() / isChanged()?

    Probably not helpful, because url.site will always be present.

  4. +++ b/core/lib/Drupal/Core/Render/Element/FormElement.php
    @@ -111,18 +113,26 @@ public static function validatePattern(&$element, FormStateInterface $form_state
    +      CacheableMetadata::createFromRenderArray($element)
    +        ->merge($url)
    +        ->merge(CacheableMetadata::createFromObject($access))
    +        ->applyTo($element);
    

    We need to always merge $access into $element.

    The information that access was denied also needs to be added.

    That is an unrelated bug though, but would be nice to fix here, as else it "looks" correct (deals with cacheability), but is not.

    More concreate:

    I propose to move this down one level and just have one $cacheable_metadata variable that is first assigned to $access, then if access is granted, update it with the generatedUrl.

  5. +++ b/core/lib/Drupal/Core/Render/Renderer.php
    @@ -538,25 +558,66 @@ protected function doRender(&$elements, $is_root_call = FALSE) {
    +    try {
    +      $request = $this->requestStack->getCurrentRequest();
    +      $context = static::$contextCollection[$request];
    +    }
    +    catch (\UnexpectedValueException $e) {
    +      // This can happen if we do not have a render context for the current
    +      // request yet.
    +      $context = NULL;
    +    }
    

    Should we not use isset()?

    Or is the try catch for the $request object?

    In that case I think its okay to have a NULL request be its own state - in case it exists?

    ---

    The below confirms, isset() should work.

  6. +++ b/core/lib/Drupal/Core/Render/RendererInterface.php
    @@ -322,6 +322,18 @@ public function renderPlain(&$elements);
    +   * 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 concern about this.
    

    s/to concern/to be concerned

  7. +++ b/core/lib/Drupal/Core/Theme/ThemeManager.php
    @@ -302,14 +302,20 @@ protected function theme($hook, $variables = array()) {
    +      // Allow theme preprocess functions to set $variables['#attached'] and
    +      // $variables['#cached'] and use them like the corresponding element
    

    s/#cached/#cache/

  8. +++ b/core/lib/Drupal/Core/Theme/ThemeManager.php
    @@ -302,14 +302,20 @@ protected function theme($hook, $variables = array()) {
    +      foreach (['#attached', '#cache'] as $key) {
    +        if (isset($variables[$key])) {
    +          $preprocess_bubbleable[$key] = $variables[$key];
    +        }
    

    Lets blacklist '#cache' 'keys' here, that would have very interesting consequences ...

  9. +++ b/core/lib/Drupal/Core/UncacheableUrl.php
    @@ -0,0 +1,72 @@
    + * Defines uncacheable URL.
    + *
    + * This should be used only to generate URLs that do not affect the response
    + * cacheability and only outside root rendering contexts.
    

    Still makes me a little uneasy, but way better than having url_generator.uncacheable be public, so

    reluctant +1

    given that Url:: should be our only API.

  10. +++ b/core/lib/Drupal/Core/UncacheableUrl.php
    @@ -0,0 +1,72 @@
    +  protected function urlGenerator() {
    +    if (!$this->urlGenerator) {
    +      $this->urlGenerator = \Drupal::service('url_generator.uncacheable');
    +    }
    +    return $this->urlGenerator;
    +  }
    

    I don't think we need this any longer with the UNCACHEABLE_OPTION.

    I would prefer it that service was private by default - if possible.

  11. +++ b/core/modules/comment/src/Controller/CommentController.php
    @@ -129,7 +129,8 @@ public function commentPermalink(Request $request, CommentInterface $comment) {
    +      $url = $entity->urlInfo()->toString(TRUE)->getGeneratedUrl();
    +      $redirect_request = Request::create($url, 'GET', $request->query->all(), $request->cookies->all(), array(), $request->server->all());
    

    Oh, wow ... I assume this is still necessary with the Renderer changes?

  12. +++ b/core/modules/filter/src/Element/TextFormat.php
    @@ -178,12 +180,16 @@ public static function processFormat(&$element, FormStateInterface $form_state,
    +      'about' => [
    +        '#type' => 'link',
    +        '#title' => t('About text formats'),
    +        '#url' => new Url('filter.tips_all', [], ['attributes' => ['target' => '_blank']]),
    +      ],
    

    Nice!

  13. +++ b/core/modules/filter/src/FilterPermissions.php
    @@ -58,8 +59,10 @@ public function permissions() {
    +        $info = $format->urlInfo();
    +        $url = UncacheableUrl::fromRoute($info->getRouteName(), $info->getRouteParameters())->toString();
             $permissions[$permission] = [
    -          'title' => $this->t('Use the <a href="@url">@label</a> text format', ['@url' => $format->url(), '@label' => $format->label()]),
    +          'title' => $this->t('Use the <a href="@url">@label</a> text format', ['@url' => $url, '@label' => $format->label()]),
    

    This uses uncacheable Url because this is just descriptive permissions callback text, okay got it.

    I think it would be okay to rather allow:

    $generated_url = $format->url(TRUE);

    @url => $generated_url->getGeneratedUrl();

    Plus a comment that generated cacheable metadata is discarded.

    On the other hand UncacheableUrl is much more explicit.

  14. +++ b/core/modules/simpletest/src/KernelTestBase.php
    @@ -207,6 +207,10 @@ protected function setUp() {
    +    // Make sure we use the uncacheable URL generator in tests, since most of
    +    // the URL generations will happen outside of a render context.
    +    $this->container->set('url_generator', $this->container->get('url_generator.uncacheable'));
    
    +++ b/core/modules/simpletest/src/WebTestBase.php
    @@ -32,6 +32,7 @@
    +use Zend\Diactoros\Uri;
    
    @@ -954,7 +955,11 @@ protected function initKernel(Request $request) {
    -    return $this->kernel->rebuildContainer();
    +    $container = $this->kernel->rebuildContainer();
    +    // Make sure we use the uncacheable URL generator in tests, since most of
    +    // the URL generations will happen outside of a render context.
    +    $container->set('url_generator', $container->get('url_generator.uncacheable'));
    +    return $container;
    

    This should no longer be needed now we check for hasRenderContext()

  15. +++ b/core/modules/system/system.routing.yml
    @@ -446,6 +446,7 @@ system.batch_page.html:
    +    no_cache: TRUE
    
    @@ -456,6 +457,7 @@ system.batch_page.json:
    +    no_cache: TRUE
    
    @@ -465,6 +467,8 @@ system.db_update:
    +  options:
    +    no_cache: TRUE
    

    Not opposed to it, but is it still needed with the hasRenderContext()?

  16. +++ b/core/modules/views/src/Controller/ViewAjaxController.php
    @@ -174,9 +176,18 @@ public function ajaxView(Request $request) {
    +          BubbleableMetadata::createFromRenderArray($preview)
    +            ->merge($bubbleable_metadata)
    +            ->applyTo($preview);
             }
    +        $response->addCommand(new ReplaceCommand(".js-view-dom-id-$dom_id", $preview));
    

    Huhhh,

    Edge Case:

    We will need to check if $preview correctly bubbles the metadata to the response.

    That is AjaxHandler and unrelated to this issue, but worth noting here.

    @WimLeers: ^^

  17. +++ b/core/modules/views/views.theme.inc
    @@ -494,7 +494,7 @@ function template_preprocess_views_view_table(&$variables) {
    -        $variables['header'][$field]['content'] = \Drupal::l($label, new Url('<current>', [], $link_options));
    +        $variables['header'][$field]['content'] = \Drupal::l($label, new Url('<none>', [], $link_options));
    

    We will carefully need to document the difference.

  18. +++ b/core/modules/views_ui/src/ViewPreviewForm.php
    @@ -65,9 +66,8 @@ public function form(array $form, FormStateInterface $form_state) {
    +    $url = $view->urlInfo('preview-form')->setRouteParameter('display_id', $this->displayID);
    +    $form['#action'] = UncacheableUrl::fromRoute($url->getRouteName(), $url->getRouteParameters())->toString();
    

    That DX still feels questionable.

Wim Leers’s picture

Hmmm, that won't work, but does that mean that when page_cache module is not loaded, that the no cache routes are cached by reverse proxies and potentially browsers???

So \Drupal\Core\PageCache\ResponsePolicy\DenyNoCacheRoutes is always used, because \Drupal\Core\EventSubscriber\FinishResponseSubscriber calls it, independently of the page cache module. Its logic:

    $is_cacheable = ($this->requestPolicy->check($request) === RequestPolicyInterface::ALLOW) && ($this->responsePolicy->check($response, $request) !== ResponsePolicyInterface::DENY);

    // Add headers necessary to specify whether the response should be cached by
    // proxies and/or the browser.
    if ($is_cacheable && $this->config->get('cache.page.max_age') > 0) {
      if (!$this->isCacheControlCustomized($response)) {
        // Only add the default Cache-Control header if the controller did not
        // specify one on the response.
        $this->setResponseCacheable($response, $request);
      }
    }
    else {
      // If either the policy forbids caching or the sites configuration does
      // not allow to add a max-age directive, then enforce a Cache-Control
      // header declaring the response as not cacheable.
      $this->setResponseNotCacheable($response, $request);
    }

Hence on no_cache: TRUE routes, $is_cacheable is always false, hence the latter branch is always entered, hence we ensure reverse proxies/CDNs don't cache them.

plach’s picture

Improved test coverage and addressed reviews. Replies coming soon.

plach’s picture

@#209:

3: I don't get whether that's supposed to be a +1
4: Storm complains about that

@#213:

Great review :)

1: Not qualified to answer about this, but I thought 'no_cache' would affect also logged users
3: No such methods on GeneratedUrl, I kept the original code
8: Hopefully I got it right :)
9: I agree Url should be our only API, however atm we have this "hybrid" cases where we have controllers doing early rendering and then not returning render arrays. This happens mostly with non HTML responses (mainly AJAX and REST). I really think we should mimic the HTML rendering pipeline and have non-HTML renderers: this would allow us to return data models similar to the render array and have, HalRenderer or AjaxRenderer kick-in and generate the response content. This should make dealing with cacheability of non-HTML responses way easier and more consistent with the HTML responses. We need a follow-up to discuss this stuff. At that point we could easily deprecate the UncacheableUrl object and remove any occurrence from core.
11: Well, it's necessary because code run in a controller always has a render context and REST code return resource responses...
13: I kept it as is, explicitness++
14: Not entirely sure about that due to #11, posted two patches to compare the test results
15: Yep, due to #11: updated/batch code run inside a controller but does return render arrays
18: Hopefully now it's better

plach’s picture

FileSize
2.13 KB
81.88 KB

Aw

plach’s picture

Let's try again without replacing the URL generator for tests

Wim Leers’s picture

  1. +++ b/core/lib/Drupal/Core/Render/HtmlResponse.php
    @@ -36,6 +36,7 @@ public function setContent($content) {
    +      $content += ['#attached' => ['html_response_placeholders' => []]];
    

    ?

  2. +++ b/core/lib/Drupal/Core/Render/Renderer.php
    @@ -590,16 +590,8 @@ public function executeInRenderContext(RenderContext $context, callable $callabl
    -    try {
    -      $request = $this->requestStack->getCurrentRequest();
    -      $context = static::$contextCollection[$request];
    -    }
    -    catch (\UnexpectedValueException $e) {
    -      // This can happen if we do not have a render context for the current
    -      // request yet.
    -      $context = NULL;
    -    }
    -    return $context;
    +    $request = $this->requestStack->getCurrentRequest();
    +    return isset(static::$contextCollection[$request]) ? static::$contextCollection[$request] : NULL;
    

    Yay :)

  3. +++ b/core/lib/Drupal/Core/Theme/ThemeManager.php
    @@ -314,7 +314,10 @@ protected function theme($hook, $variables = array()) {
    +      // We do not allow preprocess functions to define cacheable elements.
    +      unset($variables['#cache']['keys']);
           if ($preprocess_bubbleable) {
    +        // @todo Inject the Renderer in https://www.drupal.org/node/2529438.
             drupal_render($preprocess_bubbleable);
    

    Yay :)

  4. +++ b/core/lib/Drupal/Core/UncacheableUrl.php
    @@ -21,6 +21,21 @@ class UncacheableUrl extends Url {
    +    return $url->isRouted() ?
    +      UncacheableUrl::fromRoute($url->getRouteName(), $url->getRouteParameters(), $url->getOptions()) :
    +      UncacheableUrl::fromUri($url->getUri(), $url->getOptions());
    

    AFAICT you can simplify this to:

    return UncacheableUrl::fromUri($url->toUriString(), $url->getOptions());
    

    But whichever @dawehner prefers is fine.

  5. +++ b/core/modules/system/src/Tests/Render/UrlCacheableMetadataBubblingTest.php
    @@ -0,0 +1,62 @@
    +    $url = Url::fromRoute('cache_test.url_bubbling')->setAbsolute();
    ...
    +    $url = Url::fromRoute('cache_test.uncached_url_bubbling')->setAbsolute();
    ...
    +    $url = Url::fromRoute('cache_test.url_bubbling_no_cache_route')->setAbsolute();
    

    These don't need the ->setAbsolute() call before being passed to drupalGet().

    I think they're being set to absolute for the assertion after the drupalGet()?

    This is confusing.

  6. +++ b/core/modules/system/tests/modules/cache_test/cache_test.routing.yml
    @@ -4,3 +4,19 @@ cache_test.url_bubbling:
    +  path: '/cache-test/url-bubbling/no-cache-route'
    ...
    +    _controller: '\Drupal\cache_test\Controller\CacheTestController::urlBubblingNoCacheRoute'
    ...
    +    no_cache: TRUE
    

    No need to define a new controller; this can just reuse the CacheTestController::urlBubbling() controller. It's the additional route option that matters.

  7. +++ b/core/modules/system/tests/modules/cache_test/src/Controller/CacheTestController.php
    @@ -19,10 +21,37 @@ class CacheTestController {
    +      '#markup' => SafeMarkup::format('This URL is early-rendered: !url. However, it is uncacheable so is supposed to have no cacheable metadata attached. Btw, actually this is not the case as the URL is absolute.', ['!url' => $url->toString()])
    

    I don't get the "Btw, actually this is not the case as the URL is absolute." part of the string.

  8. +++ b/core/modules/system/tests/modules/cache_test/src/Controller/CacheTestController.php
    @@ -19,10 +21,37 @@ class CacheTestController {
    +   * Early renders a regular URL to check HTML responses behave correctly.
    ...
    +  public function urlBubblingNoCacheRoute() {
    

    Contradiction.

Fabianx’s picture

  1. +++ b/core/lib/Drupal/Core/Render/HtmlResponse.php
    @@ -36,6 +36,7 @@ public function setContent($content) {
         if (is_array($content) && (isset($content['#markup']))) {
    +      $content += ['#attached' => ['html_response_placeholders' => []]];
           $this->addCacheableDependency(CacheableMetadata::createFromRenderArray($content));
    

    Wow, why is that necessary?

  2. +++ b/core/lib/Drupal/Core/Theme/ThemeManager.php
    @@ -314,7 +314,10 @@ protected function theme($hook, $variables = array()) {
    +      // We do not allow preprocess functions to define cacheable elements.
    +      unset($variables['#cache']['keys']);
           if ($preprocess_bubbleable) {
    +        // @todo Inject the Renderer in https://www.drupal.org/node/2529438.
             drupal_render($preprocess_bubbleable);
    

    Should use $preprocess_bubbleable here instead of $variables ...

  3. +++ b/core/lib/Drupal/Core/UncacheableUrl.php
    @@ -21,6 +21,21 @@ class UncacheableUrl extends Url {
    +   * Instantiate an uncacheable URL from a regular one.
    +   *
    

    +100

dawehner’s picture

AFAICT you can simplify this to:

return UncacheableUrl::fromUri($url->toUriString(), $url->getOptions());

Mh, I think we should avoid processing power, if possible and so go with the route, if available.

+++ b/core/lib/Drupal/Core/Theme/ThemeManager.php
@@ -314,7 +314,10 @@ protected function theme($hook, $variables = array()) {
+      // We do not allow preprocess functions to define cacheable elements.
+      unset($variables['#cache']['keys']);

Does someone mind to explain why?

plach’s picture

This adds unit testing, addresses #221 and #213.14.

Hopefully it should be complete. Reviews welcome :)

Wim Leers’s picture

Mh, I think we should avoid processing power, if possible and so go with the route, if available.

Indeed; that was my thinking too. I just wanted to point out that it could be simplified.

Does someone mind to explain why?

See #222.2 for how this needs to be fixed/improved. But then we'll still be unsetting cache keys. Why? Because this is not rendering anything, hence also not render caching anything, hence cache keys don't make sense. So we just unset them if present. This is solely about bubbling cacheability metadata.

plach’s picture

Addressed #222.2, nice catch!

plach’s picture

FileSize
85.33 KB

The interdiff...

Wow, why is that necessary?

Because otherwise returning an HTML response without attachments will break, see CacheTestController.

plach’s picture

/me needs some rest

Wim Leers’s picture

I did one final complete review. This patch looks ready to be RTBC'd to me. Only small stuff. One bit of extra test coverage, and besides that, basically only nitpicks. And observations of particularly excellent parts of the patch :)

  1. +++ b/core/includes/pager.inc
    @@ -269,13 +270,17 @@ function template_preprocess_pager(&$variables) {
    +  $variables['#cache'] = ['contexts' => ['url.query_args']];
    

    Nit:

    $variables['#cache']['contexts'][] = 'url.query_args';
    
  2. +++ b/core/includes/pager.inc
    @@ -311,6 +316,7 @@ function pager_query_add_page(array $query, $element, $index) {
    +
       return $query;
    

    Nit: unnecessary change.

  3. +++ b/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php
    @@ -127,8 +138,9 @@ protected function wrapControllerExecutionInRenderContext($controller, array $ar
    +    // for that is stored in the current render context. If the current route is
    +    // not cacheable we simply discard it.
    +    if (!$context->isEmpty() && !$this->routeMatch->getRouteObject()->getOption('no_cache')) {
    

    I think this means we also want to update \Drupal\system\Tests\Common\EarlyRenderingControllerTest::testEarlyRendering().

    For the ones where we do an assertResponse(500), duplicate their routes, suffix their route names with _no_cache and just add the no_cache: TRUE route option.

  4. +++ b/core/lib/Drupal/Core/Render/CacheableMetadataAwareUrlGenerator.php
    @@ -0,0 +1,138 @@
    +class CacheableMetadataAwareUrlGenerator implements UrlGeneratorInterface {
    

    I think this looks great now. It represents the best possible compromise of strictness and DX, thanks to its automatic detecting of a RenderContext being present, and UncacheableUrl.

    It's unfortunate complexity, but it's isolated crappiness (see #97 + #10). Without huge additional amounts of refactoring in the URL generation APIs, this feels like a great compromise.

    (For those wanting to help make things better, please help out in #2491981: There are too many ways to generate URLs and links.)

  5. +++ b/core/lib/Drupal/Core/Render/HtmlResponse.php
    @@ -36,6 +36,7 @@ public function setContent($content) {
    +      $content += ['#attached' => ['html_response_placeholders' => []]];
    

    Explained in #227 and confirmed in manual testing.

    I first wanted to fix this in what seemed the proper place: HtmlResponseAttachmentsProcessor.

    but then I noticed that in fact we still had a failure, because HtmlResponse::setContent() always calls $this->setAttachments($content['#attached']);.

    So, I think this change is actually fine.

  6. +++ b/core/modules/views/src/Plugin/views/pager/SqlBase.php
    @@ -382,14 +382,9 @@ public function isCacheable() {
    -    $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 pager and exposed filter.
    +    return ['url.query_args'];
    
    +++ b/core/modules/views/src/Plugin/views/style/Table.php
    @@ -444,8 +444,9 @@ public function getCacheContexts() {
    -        $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';
    

    Great call! And thanks for documenting these!

  7. +++ b/core/modules/views/views.theme.inc
    @@ -1050,6 +1052,10 @@ function template_preprocess_views_mini_pager(&$variables) {
    +  $variables['#cache'] = ['contexts' => ['url.query_args']];
    

    Same as first point.

  8. +++ b/core/tests/Drupal/Tests/Core/Render/CacheableMetadataAwareUrlGeneratorTest.php
    @@ -0,0 +1,87 @@
    +  /**
    +   * Tests bubbling of cacheable metadata for URLs.
    +   *
    +   * @param bool $collect_cacheability_metadata
    +   *   Whether cacheability metadata should be collected.
    +   * @param $invocations
    +   *   The expected amount of invocations for the ::bubble() method.
    +   * @param $options
    +   *   The URL options.
    +   *
    +   * @covers ::bubble
    +   *
    +   * @dataProvider providerUrlCacheableMetadataBubbling
    +   */
    +  public function testUrlCacheableMetadataBubbling($collect_cacheability_metadata, $invocations, $options) {
    

    I'm pretty sure this is the best documented unit test method!

    plach++

  9. +++ b/core/tests/Drupal/Tests/Core/Render/CacheableMetadataAwareUrlGeneratorTest.php
    @@ -0,0 +1,87 @@
    +  public function providerUrlCacheableMetadataBubbling() {
    +    return [
    +      // No bubbling when cacheable metadata is collected.
    +      [TRUE, 0, []],
    +      // Bubbling when cacheable metadata is not collected.
    +      [FALSE, 1, []],
    +      // No bubbling when an uncacheable URL is being generated, regardless of
    +      // whether cacheability metadata is being collected.
    +      [TRUE, 0, [UncacheableUrl::UNCACHEABLE_OPTION => TRUE]],
    +      [FALSE, 0, [UncacheableUrl::UNCACHEABLE_OPTION => TRUE]],
    +    ];
    +  }
    

    And likewise, this is is extremely clear. Wonderful!

  10. +++ b/core/tests/Drupal/Tests/Core/UncacheableUrlTest.php
    @@ -0,0 +1,58 @@
    +   * Test that the uncacheable URL option is readonly.
    

    Nit: s/readonly/read-only/

effulgentsia’s picture

+++ b/core/lib/Drupal/Core/Render/CacheableMetadataAwareUrlGenerator.php
@@ -0,0 +1,138 @@
+    if (empty($options[UncacheableUrl::UNCACHEABLE_OPTION]) && $this->renderer->hasRenderContext()) {
...
+++ b/core/lib/Drupal/Core/UncacheableUrl.php
@@ -0,0 +1,77 @@
+ * This should be used only to generate URLs that do not affect the response
+ * cacheability and only outside root rendering contexts.

The if statement means we don't bubble when we're outside rendering context, so I don't understand why the UncacheableUrl docs is telling me to use it outside of rendering context. Maybe the next question will help clarify.

+++ b/core/modules/comment/src/Controller/CommentController.php
@@ -129,7 +130,8 @@ public function commentPermalink(Request $request, CommentInterface $comment) {
+      $url = UncacheableUrl::fromUrl($entity->urlInfo())->toString();
...
+++ b/core/modules/views_ui/src/ViewEditForm.php
@@ -1091,7 +1092,7 @@ public function getFormBucket(ViewUI $view, $type, $display) {
-      $build['fields'][$id]['#link'] = $this->l($link_text, new Url('views_ui.form_handler', array(
+      $build['fields'][$id]['#link'] = $this->l($link_text, new UncacheableUrl('views_ui.form_handler', array(

Why are we using UncacheableUrl in these two places?

plach’s picture

Status: Needs work » Needs review
FileSize
91.97 KB
10.06 KB

Addressed #232 and fixed the CacheableMetadataAwareUrlGeneratorTest definition.

@effulgentsia:

The if statement means we don't bubble when we're outside rendering context, so I don't understand why the UncacheableUrl docs is telling me to use it outside of rendering context.

::hasRenderContext() will return TRUE if we are inside any render context, including the early one. The comment is talking about the root rendering context, which means uncacheable URLs should be used only in the early render context (or outside controllers).

Why are we using UncacheableUrl in these two places?

The comment controller initiates a subrequest (comment/3 -> node/1#comment-3), thus all cacheable metadata are already collected during that one. The idea is that we don't need to bubble cacheability metadata in the main request.

The View edit form is an administrative route and we don't care about caching there.

plach’s picture

Btw, it seems UncaughtExceptionTest is failing randomly...

plach’s picture

Status: Needs work » Needs review
plach’s picture

Assigned: plach » Unassigned

Done for tonight

effulgentsia’s picture

The comment controller initiates a subrequest (comment/3 -> node/1#comment-3), thus all cacheable metadata are already collected during that one. The idea is that we don't need to bubble cacheability metadata in the main request.

The View edit form is an administrative route and we don't care about caching there.

I still don't get it, so opened #2529714: Test helper for 2351015, which reverts those two hunks, to see if I can understand from the tests that fail.

effulgentsia’s picture

Here's in-progress work on removing all usages of UncachableUrl. I suggest removing the class entirely.

effulgentsia’s picture

+++ b/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php
@@ -141,25 +141,35 @@
-      // If a 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.
-      elseif ($response instanceof AttachmentsInterface || $response instanceof CacheableResponseInterface || $response instanceof CacheableDependencyInterface) {
+
+      // If a Response object is returned, and it cares about cacheability, then
+      // add the dependency.
+      if ($response instanceof CacheableResponseInterface) {
+        $response->addCacheableDependency($early_rendering_bubbleable_metadata);
+      }
+      // If a domain object is returned, and it cares about cacheability, then
+      // throw an exception: early rendering is not permitted in that case. It
+      // is the developer's responsibility to not use early rendering.
+      elseif ($response instanceof CacheableDependencyInterface) {
         throw new \LogicException(sprintf('The controller result claims to be providing relevant cache metadata, but leaked metadata was detected. Please ensure you are not rendering content too early. Returned object class: %s.', get_class($response)));
       }
-      else {
-        // A Response or domain object is returned that does not care about
-        // attachments nor cacheability. E.g. a RedirectResponse. It is safe to
-        // discard any early rendering metadata.
+
+      // If a Response or domain object is returned, and it cares about
+      // attachments, and early rendering required additional attachments then
+      // throw an exception: early rendering is not permitted in that case. It
+      // is the developer's responsibility to not use early rendering.
+      elseif ($response instanceof AttachmentsInterface && $early_rendering_bubbleable_metadata->getAttachments()) {
+        throw new \LogicException(sprintf('The controller result claims to be providing relevant attachments, but leaked attachments were detected. Please ensure you are not rendering content too early. Returned object class: %s.', get_class($response)));
       }

I don't know what the original reasoning was for throwing an exception rather than automatically adding dependencies to CacheableResponseInterface objects returned by controllers, but I think this issue provides justification to rethink that and do the automatic dependency addition, per above hunk, since CacheableResponseInterface provides the interface to do so, so why not use it. REST and EntityNormalizer are a great example for needing it: REST controllers should be able to return cacheable response objects, but not have to themselves care about normalizers outputting URLs that require special caching considerations.

effulgentsia’s picture

Note that some of the usages of UncacheableUrl in #234 were incorrect. EntityNormalizer results in the URL being output in the body of a cacheable response. Even for comment permalink, the subrequest could have logic that depends on the URL (e.g., block visibility conditions), so if the generated URL changes, the permalink's cached response needs to be invalidated. That's why I don't think we should provide the class: it has few if any legitimate use cases, and way too easy to use incorrectly.

Wim Leers’s picture

+++ b/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php
@@ -141,25 +141,35 @@
+      if ($response instanceof CacheableResponseInterface) {
+        $response->addCacheableDependency($early_rendering_bubbleable_metadata);
+      }

Strong -1. That's exactly what we avoided in #2450993: Rendered Cache Metadata created during the main controller request gets lost! Please read the documentation on EarlyRenderingControllerWrapperSubscriber for the reasoning. For more detail, see the mentioned issue, specifically #2450993-114: Rendered Cache Metadata created during the main controller request gets lost (which has a detailed explanation + a table), and #2450993-123: Rendered Cache Metadata created during the main controller request gets lost & subsequent comments for more nuance.

dawehner’s picture

Note that some of the usages of UncacheableUrl in #234 were incorrect.

Well, what means incorrect here. The path to an entity does not change, nor will ever contain CSRF tokens, so UncachedUrl is primarily a developer tool, a tool
to not scare people off.

effulgentsia’s picture

The path to an entity does not change

The URL can. Who knows what crazy, dynamic outbound path processors people install.

dawehner’s picture

Now we are overthinking, IMHO.

plach’s picture

Well, if there's a way to get URL strings during early rendering without discarding their cacheable metadata and still return a non-HTML response, I'm fine with removing UncacheableUrl. However non-HTML responses do not handle early render cacheability metadata automatically as HTML responses do, so IMHO the real fix is a better handling of non-HTML responses. The UncacheableUrl class is a way to deal with edge cases in these contexts, for URLs that are very unlikely to change, until a proper fix is implemented. At that point we can safely deprecate it and remove every usage from core. See also #216.9.

effulgentsia’s picture

Status: Needs work » Needs review
FileSize
86.35 KB
10.75 KB

So, #244 says that we want to maintain the strictness of the early rendering subscriber to throw an exception for any early rendering done by a controller that doesn't return a render array.

But, #248 says that generating a URL is not really early rendering, especially for the URLs that don't need to bubble anything, such as entity URLs, if people do what is sane and don't implement volatile path processors on entity URLs.

So, here's an approach that reverts the subscriber change, but changes CacheableMetadataAwareUrlGenerator::bubble() to only ->render() when there is something that needs to bubble. I discussed this with Wim, and he wasn't completely happy with it, because it means you might have a working site until you enable a certain path processing module, and then start getting exceptions with that module enabled. But, I think we can have non-critical followups to try to improve on that. I don't think that's a completely unacceptable situation: we can tell people that yes, volatile path processors are incompatible with controllers that render URLs directly into a Response object. There are numerous ways that contrib could work around such a conflict, should it occur in practice.

effulgentsia’s picture

Status: Needs work » Needs review
FileSize
86.66 KB
1.66 KB

Ok, easing up on the exception throwing when not in a render context.

effulgentsia’s picture

Assigned: Unassigned » effulgentsia

I changed my mind on a couple things and want to try something different.

effulgentsia’s picture

Status: Needs work » Needs review
FileSize
91.97 KB

Ok, bear with me. I'm going to do this in a few steps. First, starting over at #234, so reuploading that for clarity.

effulgentsia’s picture

This reverts/removes all traces of UncacheableUrl, but doesn't change anything else, so will fail some tests. Will soon post fixes to that.

effulgentsia’s picture

Assigned: effulgentsia » Unassigned
Status: Needs work » Needs review
FileSize
83.29 KB
6.65 KB

This adds AjaxResponse as a type of controller result that EarlyRenderingControllerWrapperSubscriber can autofix. I discussed that with Wim and he was okay with that, given that AjaxResponse is just an OOified version of a D7 concept, and one that a decent amount of contrib modules use, and it involves a fair amount of rendering. I think it's important to do this, because telling contrib that they can't call url() within a controller that returns an AjaxResponse seems pretty disruptive.

I'm ok though with Wim's insistence that we keep being strict about controllers that return other kinds of Response objects not doing any early rendering, including with this issue, calling url() (without collecting their own cacheability metadata). In core, this only seems to affect the Comment Permalink and REST controllers, so this patch fixes those two to collect and use the cache metadata.

Now we are overthinking, IMHO....The UncacheableUrl class is a way to deal with edge cases in these contexts, for URLs that are very unlikely to change

In Drupal, we don't have any URLs that are very unlikely to change, because even seemingly stable ones, like entity URLs, still run through the language outbound processor which adds some cache metadata. And so if REST responses include those, then the corresponding cache contexts and tags from language negotiation need to get added to the response as well. I think we have to accept that generating any Drupal URL carries with it cache metadata that we shouldn't discard, but I think this patch now handles that pretty well.

dawehner’s picture

+++ b/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php
@@ -141,15 +144,25 @@
+          $response->addCacheableDependency($early_rendering_bubbleable_metadata);
+        }
+        if ($attachments = $early_rendering_bubbleable_metadata->getAttachments()) {
+          $response->addAttachments($attachments);
+        }
+      }

I'm curious, why does getAttachments not always return an empty array? This if seems to be a bit redundant.

Wim Leers’s picture

+++ b/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php
@@ -141,15 +144,25 @@
-      // If a Response or domain object is returned, and it cares about
+      elseif ($response instanceof AjaxResponse) {
+        if ($response instanceof CacheableResponseInterface) {
+          $response->addCacheableDependency($early_rendering_bubbleable_metadata);
+        }
+        if ($attachments = $early_rendering_bubbleable_metadata->getAttachments()) {
+          $response->addAttachments($attachments);
+        }
+      }
+      // If a non-Ajax Response or domain object is returned and it cares about

+1, for the reasons stated in #260.

But I find the first if-test (the one for CacheableResponseInterface) very interesting. Because AjaxResponse doesn't currently implement that. Is this just safeguarding a future where it will? Or is it safeguarding a future where we'll have a subclass of AjaxResponse that implements it?

In fact, why not just make AjaxResponse implement CacheableResponseInterface here, or at least add a @todo to a follow-up issue?


I'm curious, why does getAttachments not always return an empty array? This if seems to be a bit redundant.

It does default to the empty array. So yes, that could be simplified to just:

$response->addAttachments($early_rendering_bubbleable_metadata->getAttachments());
effulgentsia’s picture

Status: Needs work » Needs review
FileSize
85.12 KB
3.32 KB

Fixes remaining failures and addresses #262 and #263.

Wim Leers’s picture

Issue summary: View changes

IS updated.


#264 interdiff review:

  1. +++ b/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php
    @@ -155,12 +155,12 @@
    +        // @todo Make AjaxResponse cacheable in https://www.drupal.org/node/956186.
    

    I like how you linked to the "AJAX GET requests" issue :)

  2. +++ b/core/modules/rest/src/Plugin/rest/resource/EntityResource.php
    @@ -68,7 +69,7 @@ public function get(EntityInterface $entity) {
    -   * @return \Drupal\rest\ResourceResponse
    +   * @return \Symfony\Component\HttpFoundation\RedirectResponse
    
    @@ -107,9 +108,7 @@ public function post(EntityInterface $entity = NULL) {
    -      // 201 Created responses have an empty body.
    -      return new ResourceResponse(NULL, 201, array('Location' => $entity->url('canonical', ['absolute' => TRUE])));
    +      return new RedirectResponse($entity->url('canonical', ['absolute' => TRUE]), 201);
    

    Is this actually correct?


Entire patch review:

  1. +++ b/core/core.services.yml
    @@ -689,9 +689,15 @@ services:
    +  url_generator.uncacheable:
    

    Actually, I think uncacheable is wrong: the existing UrlGenerator already allows you to collect cacheability metadata.

    What is different, is that HEAD's URL generator doesn't bubble cacheability metadata.

    So what about s/uncacheble/non_bubbling/ ?

  2. +++ b/core/lib/Drupal/Core/Render/CacheableMetadataAwareUrlGenerator.php
    @@ -0,0 +1,135 @@
    +class CacheableMetadataAwareUrlGenerator implements UrlGeneratorInterface {
    

    This this would become CacheabilityBubblingUrlGenerator, or perhaps even just BubblingUrlGenerator (though that sounds like it's actually just an overengineered bubble bath).

I wasn't able to find anything else! :) Updating the IS also helped me get a better/complete overview, hoping it'll help other reviewed (and the committer) too!

plach’s picture

I think I can live with the requirement of manually dealing with URL cacheability for the advanced use cases, at least for now. In fact I still think we should aim for stopping to generate strings in controllers, just pass around domain objects representing our data model, and "render" them as a final step. For what this issue is concerned, this would mean passing around Url objects instead of converting them to strings. IMHO we should really try to get #2491981: There are too many ways to generate URLs and links done before the D8 release, as a pre-requisite.

+++ b/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php
@@ -141,15 +144,25 @@
+      elseif ($response instanceof AjaxResponse) {
+        if ($response instanceof CacheableResponseInterface) {
+          $response->addCacheableDependency($early_rendering_bubbleable_metadata);
+        }
+        if ($attachments = $early_rendering_bubbleable_metadata->getAttachments()) {
+          $response->addAttachments($attachments);
+        }
+      }

+++ b/core/modules/comment/src/Controller/CommentController.php
@@ -137,7 +139,11 @@ public function commentPermalink(Request $request, CommentInterface $comment) {
+      $response = $this->httpKernel->handle($redirect_request, HttpKernelInterface::SUB_REQUEST);
+      if ($response instanceof CacheableResponseInterface) {
+        $response->addCacheableDependency($subrequest_url);
+      }
+      return $response;
     }

+++ b/core/modules/rest/src/RequestHandler.php
@@ -105,8 +106,20 @@ public function handle(RouteMatchInterface $route_match, Request $request) {
+      $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());
+      }

It would be nice to add some explicit test coverage for the cacheability of these (maybe as a follow-up?), right now we only know that they are not blowing up, I think :)

+++ b/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php
@@ -155,12 +155,12 @@
+        // @todo Make AjaxResponse cacheable in https://www.drupal.org/node/956186.

80 chars

effulgentsia’s picture

Assigned: Unassigned » effulgentsia

Working on #265 and #266.

effulgentsia’s picture

Assigned: effulgentsia » Unassigned
FileSize
87.74 KB
12.07 KB

Incorporated all the suggestions from those comments.

Is this actually correct?

You're right. RedirectResponse adds a message body that's appropriate for 301 and 302, but not for 201, so fixed that.

This this would become CacheabilityBubblingUrlGenerator, or perhaps even just BubblingUrlGenerator (though that sounds like it's actually just an overengineered bubble bath).

I went with MetadataBubblingUrlGenerator.

plach’s picture

This looks ready to me, fixed a couple of nits.

  1. +++ b/core/modules/rest/src/Plugin/rest/resource/EntityResource.php
    @@ -108,7 +107,12 @@ public function post(EntityInterface $entity = NULL) {
    +      $response = new ResourceResponse(NULL, 201, array('Location' => $url->getGeneratedUrl()));
    
    +++ b/core/modules/rest/src/RequestHandler.php
    +++ b/core/modules/rest/src/RequestHandler.php
    @@ -110,6 +110,8 @@ public function handle(RouteMatchInterface $route_match, Request $request) {
    

    Old array syntax.

  2. +++ b/core/modules/filter/src/Element/TextFormat.php
    @@ -178,12 +178,16 @@ public static function processFormat(&$element, FormStateInterface $form_state,
    -    $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,
    -    );
    +    ];
     
    

    Beautiful, this is how things should always work :)

  3. +++ b/core/modules/simpletest/src/WebTestBase.php
    @@ -2355,16 +2356,35 @@ protected function clickLink($label, $index = 0) {
    +   *   A path from the internal browser content.T
    

    Bogus trailing T

Wim Leers’s picture

Status: Needs review » Reviewed & tested by the community

In fact I still think we should aim for stopping to generate strings in controllers, just pass around domain objects representing our data model, and "render" them as a final step. For what this issue is concerned, this would mean passing around Url objects instead of converting them to strings.

+1!

I also think this is ready now. Given that I've done an extensive review again at #265 and the interdiffs since then are small, plus the fact that @plach also thinks this is ready: RTBC!

Wim Leers’s picture

Title: Url generation does not bubble cache contexts » URL generation does not bubble cache contexts
plach’s picture

Status: Reviewed & tested by the community » Needs review
FileSize
716 bytes
87.7 KB

Even better :)

plach’s picture

Status: Needs review » Reviewed & tested by the community

The last change is minor to say the least, back to RTBC.

Fabianx’s picture

Issue summary: View changes

RTBC + 1 - This patch now really really truly looks awesome. The below is only things that I especially liked (and in one case a ask for documentation).


  1. +++ b/core/modules/rest/src/Plugin/rest/resource/EntityResource.php
    @@ -109,7 +109,10 @@ public function post(EntityInterface $entity = NULL) {
    -      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;
    

    This is so much nicer, exactly how it should be.

  2. +++ b/core/modules/rest/src/RequestHandler.php
    @@ -103,10 +104,23 @@ public function handle(RouteMatchInterface $route_match, Request $request) {
    +      $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());
    +      }
    

    That makes a lot of sense! :)

  3. +++ b/core/modules/simpletest/src/WebTestBase.php
    @@ -32,6 +32,7 @@
    +use Zend\Diactoros\Uri;
    
    @@ -2392,16 +2393,35 @@ protected function clickLinkHelper($label, $index, $pattern) {
    +    if (array_keys($parts) === ['query']) {
    +      $current_uri = new Uri($this->getUrl());
    +      return (string) $current_uri->withQuery($parts['query']);
    +    }
    

    Oh, nice to use PSR-7 parts here. (unless that existed already before).

  4. +++ b/core/modules/system/system.routing.yml
    @@ -394,7 +394,7 @@ system.theme_settings_theme:
    -    _only_fragment: TRUE
    +    _no_path: TRUE
    

    +1 much nicer.

  5. +++ b/core/modules/system/system.routing.yml
    @@ -456,6 +457,7 @@ system.batch_page.json:
    +    no_cache: TRUE
    

    We should probably document somewhere that no_cache has the side effect of removing the merge-in / bubble requirement. (no idea where though)


Proposed commit message:

Issue #2351015 by plach, effulgentsia, Wim Leers, dawehner, martin107, damiankloip, cilefen, Fabianx, catch, pwolanin, Damien Tournoud, znerol, YesCT, larowlan: URL generation does not bubble cache contexts
catch’s picture

Status: Reviewed & tested by the community » Needs work

Overall this looks great. It's a real shame we didn't get the URL generation APIs sorted out to support this properly, on the other hand it's great that render contexts added elsewhere makes this possible with minimal changes at this stage.

Found several minor things:

  1. +++ b/core/includes/pager.inc
    @@ -269,13 +270,17 @@ function template_preprocess_pager(&$variables) {
    +
    +  // This is is based on the entire current query string. We need to ensure
    +  // cacheability is affected accordingly.
    +  $variables['#cache']['contexts'][] = 'url.query_args';
    

    I think this needs to explain how it differs from/augments the cacheability metadata we already added to #type pager in #2433599: Ensure every (non-views) pager automatically associates a matching cache context.

  2. +++ b/core/lib/Drupal/Core/Render/Element/FormElement.php
    @@ -111,18 +113,29 @@ public static function validatePattern(&$element, FormStateInterface $form_state
    +      $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 = CacheableMetadata::createFromRenderArray($element);
    +      if ($access->isAllowed()) {
    +        $element['#attributes']['class'][] = 'form-autocomplete';
    

    This passes $return_as_object as a parameter, which means $access will always evaluate to TRUE. We should remove the if ($access).

  3. +++ b/core/lib/Drupal/Core/Render/Element/Pager.php
    @@ -34,12 +34,22 @@ public function getInfo() {
    +   * string. Therefore pager_query_add_page() adds the 'url.query_args' cache
    

    Here it says pager_query_add_page() adds the cache context, but pretty sure above template_preprocess_pager() itself does? Also this is more or less the docs I was expecting to see above. I think this should if anything be an inline comment in the function, not in the docblock. The whole point of adding the cacheabiity metadata here is the person calling these functions doesn't need to do anything much. If they override, they'll probably look at the existing functions and see the inline docs.

  4. +++ b/core/lib/Drupal/Core/Render/MetadataBubblingUrlGenerator.php
    @@ -0,0 +1,135 @@
    +/**
    + * Implements a decorator for the URL generator that allows to automatically
    + * collect and bubble up cache metadata attached to URLs. This approach helps
    + * keeping the render and the routing subsystems decoupled.
    + */
    

    Nit: shouldn't the summary be one line?

  5. +++ b/core/lib/Drupal/Core/Render/Renderer.php
    @@ -58,7 +58,25 @@ class Renderer implements RendererInterface {
    -   * The render context.
    +   * Whether we're currently in a ::renderRoot() call.
    +   *
    +   * @var bool
    +   */
    +  protected $isRenderingRoot = FALSE;
    +
    

    Not isRenderRoot? I can see arguments for either, just looks a bit odd to me.

  6. +++ b/core/lib/Drupal/Core/Routing/UrlGenerator.php
    @@ -291,6 +287,23 @@ public function generateFromRoute($name, $parameters = array(), $options = array
    +    if ($route->getOption('_no_path')) {
    

    This is new and only makes sense for <current> right? Do we need to document that somewhere? And I'm wondering a bit if the generic property is really useful.
    (see below)

  7. +++ b/core/modules/simpletest/src/WebTestBase.php
    @@ -2392,16 +2393,35 @@ protected function clickLinkHelper($label, $index, $pattern) {
    +   * This method is implemented in the way how browsers work, see
    

    Nit: "in the way that"

  8. +++ b/core/modules/system/system.routing.yml
    @@ -394,7 +394,7 @@ system.theme_settings_theme:
       options:
    -    _only_fragment: TRUE
    +    _no_path: TRUE
       requirements:
    

    OK so I see what we've done. Ignore the comment above - _no_path is just more generic than _no_fragment since we support query args too.

  9. +++ b/core/modules/views/src/Plugin/views/pager/SqlBase.php
    @@ -382,14 +382,9 @@ public function isCacheable() {
    +    return ['url.query_args'];
    

    Does this mean different pagers to this one? It looks like a self-reference at the moment.

Wim Leers’s picture

5. is pre-existing; this patch just moves it a bit. To answer the actual question: the current wording makes sense IMO: because it's also about render() calls *inside* renderRoot(), not only the call to renderRoot() itself.

plach’s picture

I will take care of #274 and #275 tonight if none beats me to it.

alexpott’s picture

Issue tags: +Needs change record
+++ b/core/lib/Drupal/Core/Routing/UrlGenerator.php
@@ -291,6 +287,23 @@ public function generateFromRoute($name, $parameters = array(), $options = array
+    if ($route->getOption('_no_path')) {

@@ -328,11 +334,6 @@ public function generateFromRoute($name, $parameters = array(), $options = array
-      if ($route->getOption('_only_fragment')) {

I think we need a CR for this. Are there any other parts of this change that need a CR?

catch’s picture

Sorry I have another question that's less minor.

the _no_cache option on the route means it won't throw an exception if there's early-rendered content, that bit is OK for smart cache.

However, setting _no_cache on a route does not disable render caching as such. This means that any code that's executed on the _no_cache route (say a block, but potentially within controller code) is 1. still render cached 2. will need the cacheable metadata of early-rendered URLs applied.

I think this works with the patch - you'd only need that metadata if you call render a cacheable render array within the controller, which in turn triggers additional early rendering/url() calls that don't bubble. However:

#1 it be good to double check that the above is really true, ideally with test coverage.

#2 I think people are going to see a route option like '_no_cache' and think it disables render caching altogether, which it currently doesn't do and likely shouldn't. I don't have good ideas for what to call this though instead.

@alexpott I thought about that when reviewing, but I'm not sure that specific change needs a change record. The only route that possibly has a use case for these is <none> - since how many routes without a path do you need?

dawehner’s picture

I think we need a CR for this. Are there any other parts of this change that need a CR?

Well, I think this will always just have one usecase the <none> route, anyway here is one: https://www.drupal.org/node/2532218

Working on other feedback starting from the top now.

dawehner’s picture

I think this needs to explain how it differs from/augments the cacheability metadata we already added to #type pager in #2433599: Ensure every (non-views) pager automatically associates a matching cache context.

Improved the docs

This passes $return_as_object as a parameter, which means $access will always evaluate to TRUE. We should remove the if ($access).

Its not always true ..., just in case $element['#autocomplete_route_name'] was defined.

Does this mean different pagers to this one? It looks like a self-reference at the moment.

Expanded the docs a bit.

#1 it be good to double check that the above is really true, ideally with test coverage.

Are you thinking of having two pagers both cached on the same page and ensure that they work independent from each other?

Fabianx’s picture

#279: Yes, _no_cache currently only disables page cache and cache headers via a ResponsePolicy().

plach’s picture

Status: Needs work » Needs review
catch’s picture

@Fabianx well now it also disables early-rendered metadata bubbling, do we need a different route option for that then?

Fabianx’s picture

#284:

Disclaimer: I was and am still a little against this change.

Reasoning:

This only bites a controller that returns a CacheableResponseInterface or a AttachmentsResponseInterface, but at the same time sets no_cache => TRUE.

- For attachments it makes no sense to disable early rendered metadata bubbling on no_cache - that points indeed to a new option.
- For cacheable it makes kinda sense, but why would you return a cacheable response if you don't want it cached?

The use case was that ResourceResponse implements CacheableResponseInterface and you could early render something within REST serializing and then still not want to cache it.

@plach / #283: If the patch would pass with that check removed, would you be okay to remove it for now and push the discussion to the follow-up already created?

If not, could we rename to:

_disable_metadata_bubbling or _override_metadata_bubbling_check?

webchick’s picture

Issue tags: +beta target

Since this issue is likely to be disruptive for contrib, tagging as a "beta target" for one to try and get in before the next beta (tentatively July 22).

plach’s picture

If the patch would pass with that check removed, would you be okay to remove it for now and push the discussion to the follow-up already created?

Of course, we already did way more so far wrt my latest patches ;)

effulgentsia’s picture

Since this issue is likely to be disruptive for contrib, tagging as a "beta target" for one to try and get in before the next beta (tentatively July 22).

To clarify, ideally, this issue will only be disruptive to contrib modules that implement controllers that return something other than a render array or AjaxResponse, and url() is called during that controller's execution, and there probably aren't too many of those. Once this patch lands, those modules will start triggering exceptions and need to be fixed. But additionally, production sites may experience some interesting side effects, such as new cache contexts added to some cached items (if that happens, probably a good thing, as the prior lack of them was probably a bug) and/or other performance/memory impacts, or even something due to a bug in this patch's logic that we all missed. So, for all those reasons, I agree with making this a beta target, even if the number of affected contrib modules ends up being low.

martin107’s picture

I did not tackle the failing tests... yet

but while reading the patch over I picked out a couple of nits
The only one worth speaking about which is - locking the $option array to be an array.

 class MetadataBubblingUrlGeneratorTest extends UrlGeneratorTest {
@@ -54,7 +55,7 @@ protected function setUp() {
    *
    * @dataProvider providerUrlCacheableMetadataBubbling
    */
-  public function testUrlCacheableMetadataBubbling($collect_cacheability_metadata, $invocations, $options) {
+  public function testUrlCacheableMetadataBubbling($collect_cacheability_metadata, $invocations, array $options) {
     $self = $this;
 
     $this->renderer->expects($this->exactly($invocations))
martin107’s picture

Status: Needs work » Needs review
Wim Leers’s picture

#278

I think we need a CR for this. Are there any other parts of this change that need a CR?

I somewhat disagree; it's a pure internal API change. OTOH, the most esoteric contrib code may also use this. But given that it's prefixed with an underscore, we already indicate it may change at any time, don't we?


[…] However, setting _no_cache on a route does not disable render caching as such. […]

It is a route option. Therefore, it applies to the route's controller, which generates an entire response, and not to the fragments that that response is made of.
So I think that's perfectly logical & valid.

This means that any code that's executed on the _no_cache route (say a block, but potentially within controller code) is 1. still render cached 2. will need the cacheable metadata of early-rendered URLs applied.

I think this works with the patch - you'd only need that metadata if you call render a cacheable render array within the controller, which in turn triggers additional early rendering/url() calls that don't bubble. However:

#1 it be good to double check that the above is really true, ideally with test coverage.

#2 I think people are going to see a route option like '_no_cache' and think it disables render caching altogether, which it currently doesn't do and likely shouldn't. I don't have good ideas for what to call this though instead.

  1. +1. I think your overall reasoning is correct. Would be good to prove that with a test indeed.
  2. Such a route option is follow-up material IMO. Perhaps no_fragment_cache: TRUE makes more sense, to make it not render array-specific.

#285:

Disclaimer: I was and am still a little against this change.

Against

+++ b/core/lib/Drupal/Core/EventSubscriber/EarlyRenderingControllerWrapperSubscriber.php
@@ -127,17 +141,29 @@ protected function wrapControllerExecutionInRenderContext($controller, array $ar
-    if (!$context->isEmpty()) {
...
+    if (!$context->isEmpty() && !$this->routeMatch->getRouteObject()->getOption('no_cache')) {

this, right? I agree it seems annoying/pointless for the common case, but it actually does make sense for routes like system.batch_page.html, system.batch_page.json and system.db_update. Those are the only 3 places in core that actually put this change in EarlyRenderingControllerWrapperSubscriber to use. If we didn't have that, we'd have to untangle quite significant messes in there.

Given that, I think the biggest criticism/concern in #285 is:

[…] why would you return a cacheable response if you don't want it cached?

You usually would not. But perhaps you're modifying the result of another controller, or reusing big chunks of functionality from another module, and those happen to return CacheableResponseInterface response objects… in those situations it does become feasible to want to not cache those responses, but just because of code reuse you're technically working with cacheable responses.
Finally, and this may be the strongest justification: you are building a site that has a few highly sensitive routes. So you set the no_cache: TRUE option, because due to their high sensitivity, you don't want to care about caching at all; you want to calculate everything, always. Note that this may also be the case for routes that were originally designed to be cached, but e.g. due to business requirements or super crazy advanced access requirements, you want to still mark an existing route as no_cache: TRUE. I think it's fair for those routes to still set no_cache: TRUE despite that not being entirely sensible; from those developers' POV, it totally is sensible!

Fabianx’s picture

#293: Lets move that discussion to a follow-up, plach wanted to check if its possible without that by now.

catch’s picture

It is a route option. Therefore, it applies to the route's controller, which generates an entire response, and not to the fragments that that response is made of.
So I think that's perfectly logical & valid.

Controllers don't only generate entire responses though, they generate render arrays which are converted to responses.

dawehner’s picture

I somewhat disagree; it's a pure internal API change. OTOH, the most esoteric contrib code may also use this. But given that it's prefixed with an underscore, we already indicate it may change at any time, don't we?

Well, we need to tell people how to generate URLs on non render API requests. Seriously, it is not trivial, so providing some information in a CR is not a bad idea at all.

Wim Leers’s picture

Assigned: Unassigned » Wim Leers
Status: Needs work » Needs review
FileSize
88.05 KB
2.72 KB

#2512132: Make CSRF links cacheable just landed; straight reroll. Next: updating accordingly, and working on fixing the failures in #290.

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
88.31 KB
7.27 KB

As you can see, the exact same failures after #2512132: Make CSRF links cacheable; further showing that #2512132 was not disruptive.

Nevertheless, we should update relevant parts of this patch now that #2512132 has landed: we now deal with bubbleable metadata, not just cacheability metadata.

Wim Leers’s picture

Assigned: Wim Leers » Unassigned
FileSize
90.3 KB
1.99 KB

And this fixes those two failures that we saw in #281, #290, and my patches in #297 and (if all goes well) in #299.

Wim Leers’s picture

So, to get this issue back on track:

@alexpott/@dawehner: #278 + #293 + #296: CR for the _only_fragment -> _no_path change: exists at https://www.drupal.org/node/2532218. (I said I didn't think it was really necessary, but I'm of course okay with having it.) So this is addressed AFAICT.

@Fabianx/@plach/@catch: #285: I answered to this in #293, then you briefly answered to that in #294. I'm not sure what #294 means though.
But #285 also asked for that "let EarlyRenderingControllerWrapperSubscriber NOT special case no_cache in this issue, leave that for a follow-up", which @plach +1'd in #287.
So… can all three of you please confirm whether you'd like to see a patch with that removed? Would be happy to roll that. (Having deciphered the fragmented discussion in writing this, it seems that that is indeed what all of you would like to see.) The problem with that approach is (quoting myself from #293):

I agree it seems annoying/pointless for the common case, but it actually does make sense for routes like system.batch_page.html, system.batch_page.json and system.db_update. Those are the only 3 places in core that actually put this change in EarlyRenderingControllerWrapperSubscriber to use. If we didn't have that, we'd have to untangle quite significant messes in there.

So, I'm fine with removing that no_cache stuff (it was added in #195 btw), but how do you then propose to handle those cases elegantly?

(EDIT: the last link pointed to #191, it should've been #195. Fixed.)

plach’s picture

I think the proposal was to remove the no_cache check only if that didn't introduce new test failures. If we want to do that anyway, I'd just wrap the various controllers in an ::executeInRenderContext() invocation and discard their metadata for now.

Wim Leers’s picture

Assigned: Unassigned » Wim Leers

That's very fair. Doing that.

Wim Leers’s picture

Assigned: Wim Leers » Unassigned
FileSize
92.68 KB
8.31 KB

Done. I tried not to miss anything, let's see what testbot says.

(This removes the no_cache route option exclusion logic. And instead fixes the places that cause exceptions; they're all places where cacheability makes no sense at all — all batch stuff.)

Wim Leers’s picture

Status: Needs work » Needs review
FileSize
85.66 KB
9.69 KB

The failures were in now-obsolete tests, that were specifically for the no_cache stuff.

So, given that, I think the answer is clear: yes, we can do this patch without the no_cache exclusion. Hurray!

Wim Leers’s picture

I did one more round of reviews, and found a few spots I missed in #299.


I've been posting patches in the last two days, but: #297 + #299 + #300 were just for chasing HEAD after #2512132: Make CSRF links cacheable landed (and were 99% string replacement), and #305 + #307 were almost trivial rerolls to address the feedback since this was un-RTBC'd in #275.

Given that, I think I still qualify to review this issue. I now agree with Fabianx'/catch's concerns about no_cache. I think this patch is ready now. I'll leave it to others to RTBC though.

plach’s picture

Status: Needs review » Reviewed & tested by the community

Interdiffs look good to me: since this was already RTBC and I didn't work on it after that, it should be fine for me to RTBC it again.

Fabianx’s picture

RTBC + 1 - It is great that that other issue landed first! :)

catch’s picture

Status: Reviewed & tested by the community » Fixed

All the interdiffs look a lot better.

With render contexts I'm wondering a bit whether we actually should be supporting $variables['#attached'] etc. or whether we should encourage people to do $variables['actual_variable']['#attached'] instead (so that attachments only get applied when the variable is printed), but that's more an discussion for #2528314: template_preprocess_node() does not add cacheability metadata than here.

Committed/pushed to 8.0.x, thanks!

  • catch committed 20f1c99 on 8.0.x
    Issue #2351015 by plach, effulgentsia, Wim Leers, dawehner, martin107,...
Wim Leers’s picture

mikeker’s picture

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.