Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Entirety of speculation rules should be filterable, not just the paths to exclude #1156

Open
westonruter opened this issue Apr 16, 2024 · 26 comments
Labels
[Plugin] Speculative Loading Issues for the Speculative Loading plugin (formerly Speculation Rules) [Type] Enhancement A suggestion for improvement of an existing feature

Comments

@westonruter
Copy link
Member

Feature Description

Currently the Speculative Loading plugin provides a plsr_speculation_rules_href_exclude_paths filter to exclude URLs (and URL patterns) from being speculatively loaded. However, two situations came up recently where this was not sufficient.

  1. In Products added to cart in WooCommerce without clicking on Add to Cart button when Speculative Loading active #1140, WooCommerce was found to create non-idempotent add-to-cart links which add products to cart when prefetched/prerendered. WooCommerce seems to try to get around the non-idempotency by adding rel=nofollow to the links. However, there is no way to exclude links via such an attribute without manually modifying the default rules, which was done in Exclude rel=nofollow links from prefetch/prerender #1142. (This was to avoid having to add a WooCommerce-specific URLPattern to exclude URLs with an add-to-cart query param, since excluding links with rel=nofollow may be generally advisable: Should we exclude rel=nofollow by default? WICG/nav-speculation#309).
  2. In a comment on the aforementioned issue, a user wanted to customize the speculation rules so that specific URLs were opted-in for eager prerendering. That is, instead of specifying a list of URLs/URLPatterns to exclude, they wanted to provide a list of URLs/URLPatterns to include. This is not possible at present either.

To account for these two use cases, I suggest that the entire set of speculation rules (speculation ruleset?) be filterable, doing something like this:

--- a/plugins/speculation-rules/helper.php
+++ b/plugins/speculation-rules/helper.php
@@ -109,5 +109,13 @@ function plsr_get_speculation_rules() {
 		);
 	}
 
-	return array( $mode => $rules );
+	$ruleset = array( $mode => $rules );
+
+	/**
+	 * Filters the entire speculation ruleset.
+	 *
+	 * @param array  $ruleset Ruleset.
+	 * @param string $mode    Mode used to apply speculative prerendering. Either 'prefetch' or 'prerender'.
+	 */
+	return (array) apply_filters( 'plsr_speculation_ruleset', $ruleset, $mode );
 }

Also, @swissspidy suggested in #1144 (comment) that a JSON Schema could be written which could validate whatever is being returned by the filter. If not valid, it could trigger a _doing_it_wrong().

@westonruter westonruter added [Type] Feature A new feature within an existing module [Plugin] Speculative Loading Issues for the Speculative Loading plugin (formerly Speculation Rules) labels Apr 16, 2024
@westonruter
Copy link
Member Author

I had thought that only one speculationrules is allowed on page, making it more important to allow the entire single ruleset to be filterable. But @tunetheweb corrected me by pointing out that Multiple speculation rules are indeed allowed. They all get merged together. So this lessens the need for this plugin's speculation rules to be filterable.

@masvil
Copy link

masvil commented Apr 17, 2024

It would be great to have the ability to set a limit when using wildcards. For example, eagerly prerender /product/* up to 8. In this case, given the limit of 10, we would still have 2 available on hover "slots" provided by the Moderate setting.

@swissspidy
Copy link
Member

Also, @swissspidy suggested in #1144 (comment) that a JSON Schema could be written which could validate whatever is being returned by the filter. If not valid, it could trigger a _doing_it_wrong().

For context, my original use case was validation in unit tests, but runtime validation is interesting too. To make sure that's not too slow, I'd transform the JSON schema into a PHP file and then use that for the validation. Then there's no need to do any JSON parsing.


It would be great to have the ability to set a limit when using wildcards. For example, eagerly prerender /product/* up to 8. In this case, given the limit of 10, we would still have 2 available on hover "slots" provided by the Moderate setting.

This sounds like a feature request for https://github.com/WICG/nav-speculation/issues.

@tunetheweb
Copy link
Contributor

It would be great to have the ability to set a limit when using wildcards. For example, eagerly prerender /product/* up to 8. In this case, given the limit of 10, we would still have 2 available on hover "slots" provided by the Moderate setting.

This sounds like a feature request for https://github.com/WICG/nav-speculation/issues.

These are already separate limits. so you can prerender up to 10 eagerly, and 2 moderately.

@masvil
Copy link

masvil commented Apr 24, 2024

These are already separate limits. so you can prerender up to 10 eagerly, and 2 moderately.

Check https://altsetup.com. I have:

image

and

<script type="speculationrules">
{
  "prerender": [{
    "where": {
      "and": [
        { "href_matches": "/*" },
        { "not": {"selector_matches": ".do-not-prerender"}},
        { "not": {"selector_matches": "[rel=nofollow]"}}
      ]
    },
    "eagerness": "eager"
  }]
}
</script>

The 10 eager prerenders happen, but the 2 moderate ones (on hover) don't. Why?

@westonruter
Copy link
Member Author

I believe because Chrome has a limit of 10 prerenders.

@masvil
Copy link

masvil commented Apr 24, 2024

I believe because Chrome has a limit of 10 prerenders.

According to @tunetheweb, the limits are 10 eagerly + 2 moderately, and I can confirm it works like that on another site I'm testing it on. The issue is on https://altsetup.com and some other sites having similar stack. I can provide admin access to check it.

EDIT
It's like if an eager prerender is not performed ("failure" state) because the initiating page already has too many prerenders ongoing, then it is not a candidate anymore for the moderate prerendering on hover. It happens in case of href_matches": "/*"

@westonruter
Copy link
Member Author

I see Chrome is reporting this error with the prerenders:

Failure - The prerender whose eagerness is "eager" was not performed because the initiating page already has too many prerenders ongoing. Remove other speculation rules with "eager" to enable further prerendering.

I see there is only one URL which is "Not triggered", and that is the Contact page:

image

However, that /contact/ URL was also triggered and failed. So yeah, it seems that prevents it from being attempted.

Did you try excluding /contact/ from the eager rules? I believe this is it:

<script type="speculationrules">
{
  "prerender": [{
    "where": {
      "and": [
        { "href_matches": "/*" },
        { "not": { "href_matches": "/contact/" }},
        { "not": {"selector_matches": ".do-not-prerender"}},
        { "not": {"selector_matches": "[rel=nofollow]"}}
      ]
    },
    "eagerness": "eager"
  }]
}
</script>

@masvil
Copy link

masvil commented Apr 24, 2024

I see there is only one URL which is "Not triggered", and that is the Contact page:

Actually all URLs having failure state are not eligible anymore to be triggered on hover. It's more clear if you navigate on any post page, where there are related posts URLs on the bottom. All those are not triggered on hover because excluded by the initial 10 eager prerenders without being excluded.

Did you try excluding /contact/ from the eager rules? I believe this is it:

I just replaced the script with yours, and now /contact/ prerender is triggered on hover, as you can see.

@tunetheweb
Copy link
Contributor

tunetheweb commented Apr 24, 2024

This is basically an extension of this bug: https://issues.chromium.org/issues/335277576. Once a speculation fails it is not retried.

But to be honest a high eagerness is best used with a targeted list of URLs where you KNOW there is a high probability the link will be used. The lower eagerness (moderate or conservative) is for URLs where this cannot be predicted.

The scattergun approach of trying to eagerly fetch ALL URLs, with the low eagerness backup for the same URLs is not recommended. Speculating 12 URLs per page load is a LOT of potential wastage which will affect both your hosting costs (every visitor is now effectively 12 visitors) and, more importantly, your visitors bandwidth and CPU resources. This is precisely why we have this 10 URL limit. And it be honest I think that's a little high and may be lowered if we see a lot of this scattergun approach.

I would suggest a more targeted approach for eagerly fetched URLs, or just using less eagerly fetched URLs.

@sstopfer sstopfer moved this to Not Started/Backlog 📆 in WP Performance 2024 May 26, 2024
@swissspidy
Copy link
Member

What's the status here, are we planning on adding a filter at least in WordPress/wordpress-develop#7860? It would really be a best practice to give developers full control.

@westonruter
Copy link
Member Author

cc @felixarntz

@felixarntz
Copy link
Member

felixarntz commented Jan 16, 2025

@swissspidy @westonruter I agree a filter for the entirety of rules would be useful. However, the shape of that data is quite complex due to the deep nesting, and dealing with this in raw array shape is quite error-prone.

If we want to expose a filter for the entire thing, I think we should introduce some helpers to make the filtering a bit more intuitive.

For example:

  • Introduce a simple value class like WP_Speculation_Rule with getters and setters for source, where eagerness, etc. It could have a to_array() method that turns it into the final shape needed for the JSON.
    • Once we explore this further, potentially we might find that it would be good to have a few different variants, maybe we'll end up with WP_URL_Speculation_Rule and WP_Document_Speculation_Rule (based on the different source values). Not sure right now whether that would be useful or overkill, but just throwing it out there as a possibility.
  • Apply a filter on the overall array of all speculation rules. That array can have keys prefetch and/or prerender, and the values for either of them would be of type WP_Speculation_Rule[]. The filter could provide the site's configuration (whether it should use prefetch or prerender, plus the eagerness) as additional parameters.

Alternatively, we could have two separate filters for the prefetch and prerender array. That would make the filters a bit simpler, but I think it could be confusing because it may falsely suggest that both prefetch and prerender rules are equally relevant, when in reality, the site's configuration should be respected:

  • If the site is set to use prefetch, nothing should use prerender.
  • If the site is set to use prerender, generally URLs should be prerendered, but it's still okay to also include prefetch rules (for scenarios where prerendering would cause issues, but prefetching is safe).

So in simplistic code this could be something like this:

$speculation_rules = array(
	$mode => array(
		new WP_Speculation_Rule( /* ... */ ), // The one and only speculation rule that WP Core would add by default, with all the exclusions from the existing filter.
	),
);
$speculation_rules = apply_filters( 'wp_speculation_rules', $speculation_rules, $mode, $eagerness );

Obviously all of the above uses the WP_ prefix, so if we implement this in the plugin, we would go with PLSR_.

WDYT?

@felixarntz
Copy link
Member

felixarntz commented Jan 16, 2025

Just for illustrative purposes, my alternative idea from above with the 2 filters would look something like this:

$speculation_rules = array_filter(
	array(
		$mode       => apply_filters( "wp_speculation_rules_{$mode}", array(
			new WP_Speculation_Rule( /* ... */ ), // The one and only speculation rule that WP Core would add by default, with all the exclusions from the existing filter.
		), $mode, $eagerness ),
		$other_mode => apply_filters( "wp_speculation_rules_{$other_mode}", array(), $mode, $eagerness ),
	)
);

@swissspidy
Copy link
Member

Hmm that seems a bit overly complex at first glance.

While I didn't originally propose this filter, I would assume it could be useful for performance plugins who want complete control over the configuration. Sure, a raw array is error prone there, but that shouldn't be core's concern. And if we're concerned about error proneness, then schema validation would be an interesting path to explore.

If we want to expose a filter for the entire thing, I think we should introduce some helpers to make the filtering a bit more intuitive.

Overall I'd say before going down this route I would rather not add such a filter in 6.8. Let's ship the feature as-is and then iterate in 6.9 if there is a need. Otherwise it looks like a solution searching for a problem.

@felixarntz
Copy link
Member

Sure, a raw array is error prone there, but that shouldn't be core's concern.

Why not? Shouldn't core's APIs be intuitive to use?

Overall I'd say before going down this route I would rather not add such a filter in 6.8. Let's ship the feature as-is and then iterate in 6.9 if there is a need.

I'm on board with that. I'm anyway a strong proponent of adding filters based on demand, not make everything filterable just because we can without knowing whether it's useful. Every filter is an API that makes changing things more difficult, so I think we should add them when we know they're useful.

@tunetheweb
Copy link
Contributor

I wonder if this could be implemented more easily with a more complex rule and the use of classes, that the site owner should use to decorate their links with.

For example, the current prerender rule is basically this:

{
    "prerender": [
        {
            "source": "document",
            "where": {
                "and": [
                    {
                        "href_matches": "\/*"
                    },
                    {
                        "not": {
                            "href_matches": [
                                "\/wp-login.php",
                                "\/wp-admin\/*",
                                "\/*\\?*(^|&)_wpnonce=*",
                                "\/wp-content\/uploads\/*",
                                "\/wp-content\/*",
                                "\/wp-content\/plugins\/*",
                                "\/wp-content\/themes\/dynamik-gen\/*",
                                "\/wp-content\/themes\/genesis\/*"
                            ]
                        }
                    },
                    {
                        "not": {
                            "selector_matches": "a[rel~=\"nofollow\"]"
                        }
                    },
                    {
                        "not": {
                            "selector_matches": ".no-prerender"
                        }
                    }
                ]
            },
            "eagerness": "moderate"
        }
    ]
}

Something like adding an additional document where rule at the top:

{
    "prerender": [
        {
            "where": {"selector_matches": ".prerender-eagerly-block a"},
            "eagerness": "eager",
        },
        {
            "source": "document",
            "where": {
                "and": [
                    {
                        "href_matches": "\/*"
... rest of the old rule as per above

The prerender-eagerly-block class could be added with by adding additional classes to CSS blocks (or with HTML editing or a custom script with for more advanced WordPress users), or to a theme.

This could potentially be much more powerful and maintainable than static lists of URLs that differ by page, or will get out of date.

@felixarntz
Copy link
Member

felixarntz commented Jan 27, 2025

@tunetheweb That's an interesting idea for sure. I definitely like relying on classes to decide how to prefetch or prerender a resource. We already have the no-prefetch and no-prerender classes to opt out, so we could also have classes to opt in.

From the perspective of WordPress Core's philosophy of "decisions, not options" one could argue that relying on classes specifically on the block (rather than on the actual a tag) may be a bit of a stretch, since we would only do that so that users could specify the class in the UI. But at the same time I consider it an elegant solution, as it would allow advanced users to have that control without actually exposing a dedicated UI control for it (which would be a no-go).

Let's think about how this could work. The current no-prefetch and no-prerender classes are supposed to be used directly on the a tag. I think it still makes sense to support this since for a developer touching code it is more intuitive to annotate the link itself, instead of a parent container like a WordPress block would need to do. So maybe we support both?

How about this:

  • In addition to .no-prefetch and .no-prerender, also opt out .no-prefetch a and .no-prerender a.
    • The second two would enable support to opt out at the block level.
  • Based on your suggestion, the following sets of classes could be used to prefetch or prerender links in a certain way (each set is one selector for the link itself and another one for e.g. block level):
    • .eager-prefetch, .eager-prefetch a
    • .moderate-prefetch, .moderate-prefetch a
    • .conservative-prefetch, .conservative-prefetch a
    • .eager-prerender, .eager-prerender a
    • .moderate-prerender, .moderate-prerender a
    • .conservative-prerender, .conservative-prerender a

Question: How would the main rule generated by WordPress need to cater for these? This is partially a question of how the spec works, and based on that how can we avoid conflicts. For example, if your WordPress site is configured to prerender with moderate eagerness, its main rule will moderately prerender pretty much any links it encounters. If a specific link then has .conservative-prefetch, how do we deal with it? In this particular case it's more of an opt-out (of moderate prerendering) than an opt-in.

  1. If we don't change anything and just use separate rules for all of these classes, what would be chosen based on the spec? Both rules would apply, so what would the browser do?
  2. Based on the answer to that question, would we need to amend the main rule to exclude any links with any of those special classes from the default behavior?

@tunetheweb
Copy link
Contributor

Basically the a matching rule will cause a speculation. And the API has deduplication logic so if you eager prerender a link, and then hovering it also causes a prerender, then it won’t prerender again (unless the previous prerender has been cancelled or failed).

Additionally, it makes full use of the HTTP Cache. So if a link has been prerendered before, and then that prerender is cancelled, and then it’s re-prerendered then it will use the HTTP Cache, so with the right cache-control
headers potentially nothing needs to be re-fetched from the network (nor cause additional load on the server), but if course does need some local CPU time to render the page from those cached resources. And of course if you don’t allow caching, then a re-prerender will need to go back to the network.

The main thing to be aware of the Chrome limits (10 prerenders for eager, 2 for moderate and conservative). And also to write the rule (or rules - multiple rules per page are supported) to ensure, for example, no-prerender classes as excluded in every rule (so the rule cannot match these).

To give some examples:

  1. let’s say you prerender 11 links eagerly (too many IMHO btw!, but go with me here as an example) then Chrome will prerender the first 10 links (but fail the 11th as limit reached). If you hover over the 11th link and that rule also matches, then it will allow the 11th link to prerender (using the 2 limit for non-eager), but then it obviously has less of a head start than an eager prerender.
  2. another example is prerendering 3 links on hover. The first two will prerender, then the 3rd will prerender and kick out the oldest one. Hovering the oldest link will allow that to re-prerender (kicking out the next oldest).
  3. a third example is a common pattern where some sites eagerly prefetch (which has higher limits btw as less resource-intensive — at least for the client), but then prerender moderately. As that’s not a dupe, the prerender will use the prefetched page and basically upgrade it to a full prerender. The prefetch just gives it a bit of a head start as it has the document HTML to hand already.

We’d need to test eagerly prerendering and moderately prefetching (especially without no caching). In theory it shouldn’t prefetch (as it’s already got an active prerender speculation), but not tried that to confirm. But, as I said in example 3, I have done the opposite and that’s a common pattern I see.

@felixarntz
Copy link
Member

Thanks @tunetheweb for the detailed explanation, the examples help a lot.

One of my previous points I'm still not clear about though, I think we'll need to carefully think about what should happen when multiple rules apply. Consider this example:

  • Site is configured to generally prerender with moderate eagerness.
  • One specific a tag uses eager-prefetch.

How do we interpret that eager-prefetch class's presence? Do we consider that purely an opt-in to eager prefetching? Or at the same time an opt out of moderate prerendering?

If the first, then the site would eagerly prefetch the URL and later potentially upgrade it to a prerender. This is like in the example you mentioned.

But if the latter, then the site would eagerly prefetch the URL, but do nothing else. In order to technically achieve that, the general rule would need to have the eager-prefetch class in an exclude pattern.

My question here is: We could make both approaches work - but which one makes most sense from a DX perspective?

@tunetheweb
Copy link
Contributor

I would consider this and opt-in to both eagerly prefetch and then upgrade to prerender on hover. I don’t see those as conflicting instructions. As I say that’s a fairly common pattern.

The only conflicts I see are if you say to prerender but also not to prerender (or similar to prefetch but also not to prefetch). In which case the “not” so take precedence. This is easily achieved with a rule that always includes a not clause for the explicit opt outs.

@felixarntz
Copy link
Member

Makes sense. So in other words, going back to my example: If someone wants to have a link to be prefetched eagerly, but opt out from prerendering, they would need to use both classes .eager-prefetch.no-prerender. If we consider this the less likely scenario than eager-prefetch plus whatever the site's default behavior is, then it's okay that this is a bit more involved (requiring 2 classes).

@tunetheweb
Copy link
Contributor

Correct.

If the default behaviour is to prerender and they want to do what you want, then to me they are asking for two extra things from the default:

  1. prefetch eagerly
  2. opt-out of their default to prerender.

Hence two classes. I don't think we need to create one class for all combinations just to avoid the need for two classes as they'll just get confusing.

@felixarntz
Copy link
Member

Thanks!

Regarding the implementation of this, I think we should implement support for these classes as a follow-up enhancement via separate WordPress Trac ticket, once the initial feature has been merged. That keeps PRs manageable, and since this works well as a standalone enhancement, it makes sense. It could still be merged into the same WordPress Core release.

@tunetheweb
Copy link
Contributor

SGTM. We could also experiment with it in the Plugin in the meantime if we think that would be useful and/or the core implementation is still some way off.

@westonruter westonruter added [Type] Enhancement A suggestion for improvement of an existing feature and removed [Type] Feature A new feature within an existing module labels Jan 28, 2025
@felixarntz
Copy link
Member

felixarntz commented Jan 30, 2025

@tunetheweb I agree with starting this as an experiment in the plugin, let's do that.

If we want to do that, we will need to be able to modify the overall speculation rules once speculative loading is in WordPress Core, so that brings me back to the original purpose of this issue.

For the previously mentioned reasons in #1156 (comment) I don't think a simple filter for the entire complex array is the right approach. Thinking about this further, I think a more explicit API would help reduce that complexity and also guide it to do the right thing. Instead of a filter, we could fire an action that receives a WP_Speculation_Rules object (based on a PHP class) to modify.

This action could be used something like this (taking one of the examples discussed above):

add_action(
	'wp_set_speculation_rules',
	function ( WP_Speculation_Rules $speculation_rules ) {
		$speculation_rules->add_rule(
			// The `$mode` parameter would be either 'prefetch' or 'prerender'.
			'prefetch',
			// The `$rule` parameter would be an array representing a single rule.
			array(
				'source'    => 'document',
				'where'     => array(
					'selector_matches' => '.eager-prefetch, .eager-prefetch a',
				),
				'eagerness' => 'eager',
			)
		);
	}
);

The WP_Speculation_Rules class could be very simple as a starting point:

  • two properties storing the array of rules, one for prefetch, one for prerender
  • method add_rule( string $mode, array $rule ) to add a new rule
  • method to_array() to return the entire speculation rules array representation
  • optional: helper methods e.g. to check for presence of a specific rule or to see whether there are any rules set for prefetch or prender specifically
  • more methods could be added in the future as needed, but the above would be enough for an MVP

WDYT about this idea @swissspidy @westonruter?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Plugin] Speculative Loading Issues for the Speculative Loading plugin (formerly Speculation Rules) [Type] Enhancement A suggestion for improvement of an existing feature
Projects
Status: Not Started/Backlog 📆
Development

No branches or pull requests

5 participants