-
Notifications
You must be signed in to change notification settings - Fork 109
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
Facilitate embedding Speculative Loading in other plugins/themes #1159
Conversation
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.
To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Note: Whatever ends up getting approved and merged here I intend to also implement in Optimization Detective and Embed Optimizer among other plugins to facilitate embedding so that we can increase the reach of the plugins. |
'plsr_pending_plugin_info', | ||
'1.2.1', | ||
static function ( string $version ) { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With whitespace ignored, note the remaining lines in this file are largely unchanged.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a clever way of handling possible plugin conflicts and looks like it would work, but encapsulating all of these function and global definitions inside anonymously executed static functions like this is not super intuitive.
A few minor concerns with this approach:
- The plugin version number needs to be updated as part of the args of the closure, rather than a constant or a variable this is easy to identify
- The first version of the plugin to define the bootstrap callback and register hooks will always be used, which is probably fine, but is a future-compat concern if for some reason we would want to update the bootstrap logic.
- Storing the callback that will be executed by
call_user_func
in a global creates the opportunity for tampering so any code that updates that global would be able to execute arbitrary code.
I don't have an immediately better idea, but wonder if we could conditionally load an autoloader that we bundle in each plugin or conditionally load a bootstrap library that each plugin would register itself to so that we could use OOP to keep things like version number, registration callback, etc. encapsulated inside plugin classes, rather than global variables.
Yes, although this is done intentionally in order for there to be no collisions with other potential copies of the same plugin.
A constant cannot be used since it would conflict with other copies. A variable at the top of the closure could be used instead. I was intending for that closure to be some boilerplate code which could be copied among plugins, so that's why I went with the version being passed as an argument to the closure.
That's right.
If some other plugin is manipulating the
The WooCommerce implementation does go with a more OOP approach. But Also, with OOP there is an even greater risk of your 2nd concern above. In the action-scheduler example, the first copy of the plugin gets its copy of |
$GLOBALS[ $global_var_name ]['load'] instanceof Closure | ||
) { | ||
call_user_func( $GLOBALS[ $global_var_name ]['load'] ); | ||
unset( $GLOBALS[ $global_var_name ] ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, I just realized that this should not be unset. It should rather set a flag to say that the callback has been fired. Otherwise, if another copy of the plugin is loaded later (e.g. in a theme) then it would attempt to load it again, which could cause an error. An error doesn't happen in Speculative Loading currently because our load logic includes this check:
if ( defined( 'SPECULATION_RULES_VERSION' ) ) {
return;
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You may also be able to just set $GLOBALS[ $global_var_name ]['load']
to null
so it won't fire again.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In 7b3ee06 I opted to set a loaded
flag on the array instead. The problem with setting $GLOBALS[ $global_var_name ]['load']
to null
is that isset()
would treat it the same as if $GLOBALS[ $global_var_name ]['load']
was not set at all.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is obsolete with moving to after_setup_theme
so this is undone in 27284ab.
Overall this looks good, I'm going to test loading it multiple ways to validate functionality. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've been thinking more about this, and I still have reservations about relying on anonymous closures and globals but this is a pretty low-risk MVP for how we could handle this across several different plugins, so I'm ok for us to go this direction for now.
As an experiment, I spent some time working up what an OOP version of this could look like in order to keep everything encapsulated in class properties rather than in the global space. We could do something simple like this:
<?php
/**
* Plugin Loader
*
* @package speculation-rules
*/
if ( ! class_exists( 'PLSR_Plugin_Loader' ) ) {
/**
* Class PLSR_Plugin_Loader
*/
class PLSR_Plugin_Loader {
/**
* The plugin instance.
*
* @var PLSR_Plugin_Loader
*/
private static $instance;
/**
* The plugin version.
*
* @var string
*/
public static $version;
/**
* The plugin loader.
*
* @var callable
*/
public static $loader;
/**
* PLSR_Plugin_Loader constructor.
*/
public function __construct() {
if ( isset( self::$instance ) ) {
return;
}
self::$instance = $this;
$this->register_hooks();
}
/**
* Register hooks.
*/
private function register_hooks() {
// Handle either where the plugin is installed as a regular plugin or is embedded in another plugin or in a theme.
if ( ! did_action( 'plugins_loaded' ) ) {
add_action( 'plugins_loaded', array( __CLASS__, 'load' ), 0 );
}
// Handle case where plugin is embedded in a theme.
add_action( 'after_setup_theme', array( __CLASS__, 'load' ), 0 );
}
/**
* Register a plugin.
*
* @param string $version The plugin version.
* @param callable $loader The plugin loader.
*/
public static function register( string $version, callable $loader ) {
if (
// Register this copy if none has been registered yet.
! isset( self::$version ) ||
// Or register this copy if the version greater than what is currently registered.
version_compare( $version, self::$version, '>' )
||
// Otherwise, register this copy if it is actually the one installed in the directory for plugins.
rtrim( WP_PLUGIN_DIR, '/' ) === dirname( __DIR__ )
) {
self::$version = $version;
self::$loader = $loader;
}
}
/**
* Load the plugin.
*/
public static function load() {
if ( self::$loader instanceof Closure ) {
call_user_func( self::$loader );
self::$loader = null;
}
}
}
new PLSR_Plugin_Loader();
}
And call it from the main plugin file like this:
<?php
/**
* Plugin Name: Speculative Loading
* Plugin URI: https://github.com/WordPress/performance/tree/trunk/plugins/speculation-rules
* Description: Enables browsers to speculatively prerender or prefetch pages when hovering over links.
* Requires at least: 6.4
* Requires PHP: 7.2
* Version: 1.2.1
* Author: WordPress Performance Team
* Author URI: https://make.wordpress.org/performance/
* License: GPLv2 or later
* License URI: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
* Text Domain: speculation-rules
*
* @package speculation-rules
*/
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Load the plugin loader.
require_once __DIR__ . '/class-perflab-plugin-loader.php';
PLSR_Plugin_Loader::register(
'1.2.1',
static function () {
// Define the constant.
if ( defined( 'SPECULATION_RULES_VERSION' ) ) {
return;
}
define( 'SPECULATION_RULES_VERSION', PLSR_Plugin_Loader::$version );
define( 'SPECULATION_RULES_MAIN_FILE', plugin_basename( __FILE__ ) );
require_once __DIR__ . '/class-plsr-url-pattern-prefixer.php';
require_once __DIR__ . '/helper.php';
require_once __DIR__ . '/hooks.php';
require_once __DIR__ . '/settings.php';
}
);
This example would be limited to just one plugin, but it could be enhanced to support multiple different plugins by either defining a plugin class that is passed into the loader or by updating the register method of this loader class to accept a plugin slug and list of args that accept the version and loading callback.
$GLOBALS[ $global_var_name ]['load'] instanceof Closure | ||
) { | ||
call_user_func( $GLOBALS[ $global_var_name ]['load'] ); | ||
unset( $GLOBALS[ $global_var_name ] ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You may also be able to just set $GLOBALS[ $global_var_name ]['load']
to null
so it won't fire again.
plugins/speculation-rules/load.php
Outdated
|
||
define( 'SPECULATION_RULES_VERSION', '1.2.1' ); | ||
define( 'SPECULATION_RULES_MAIN_FILE', plugin_basename( __FILE__ ) ); | ||
define( 'SPECULATION_RULES_VERSION', '1.2.1' ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be great if we could avoid having to modify the version number in more than one place. Could this be defined from the global?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Like I removed in bdf96a6? I thought maybe this was overly fancy and made it more difficult to reason about. Since there's already two instances of the version in the file, a third instance doesn't seem terrible. They should all probably get updated programmatically anyway.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Alternatively we could do something like this:
--- a/plugins/speculation-rules/load.php
+++ b/plugins/speculation-rules/load.php
@@ -20,6 +20,8 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
+require_once ABSPATH . 'wp-admin/includes/plugin.php'; // Note: Removed in build step.
+
(
/**
* Register this copy of the plugin among other potential copies.
@@ -62,7 +64,7 @@ if ( ! defined( 'ABSPATH' ) ) {
}
)(
'plsr_pending_plugin_info',
- '1.2.2',
+ get_plugin_data( __FILE__ )['Version'],
static function () {
// Define the constant.
@@ -70,7 +72,7 @@ if ( ! defined( 'ABSPATH' ) ) {
return;
}
- define( 'SPECULATION_RULES_VERSION', '1.2.1' );
+ define( 'SPECULATION_RULES_VERSION', get_plugin_data( __FILE__ )['Version'] );
define( 'SPECULATION_RULES_MAIN_FILE', plugin_basename( __FILE__ ) );
require_once __DIR__ . '/class-plsr-url-pattern-prefixer.php';
And the build step could remove those calls with the actual version. But since this requires requiring a file from wp-admin/includes
this is dangerous as we could assume the functions in there are present only to have them throw a fatal when called in a production build.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Being able to get the version number directly from the file header would be ideal. However, since the version number is being stored as a global, you still shouldn't need to pass the global value to the callback like was happening prior to bdf96a6. You should be able to access the global from within the callback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@westonruter when I tested this as it currently stands it did not appear to work correctly, maybe because of the one change you noted is needed above. Also, possibly I am not testing correctly - here are the steps I took to try to reproduce:
Check the version that was loaded by checking the global late: add_action( 'wp_loaded', function() {
error_log( SPECULATION_RULES_VERSION );
} ); Expected results:
Actual results:
|
This is to be expected based on how this was implemented. See this comment from @westonruter in the description:
I think you would need to test a version embedded in a separate plugin against one embedded in the theme. |
Ok good, then working as expected! I'll test including from another plugin. |
Same results when included from another plugin and a theme - when that plugin is active, its version is used instead of the theme version. maybe that is expected @westonruter? I also tested including from two separate plugins. In this case the code worked as I expected it to, the newer version was always used between the two plugins. When I activated the actual "Speculative Loading" PL plugin, that version was used. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good, one question about functionality expectations (above)
This sounds like a bug. Could be because the bootstrap callback unsets the global before the theme has a chance to register its loader? |
@adamsilverstein The order of precedence currently would be this:
When embedded in a plugin, it's not waiting until |
… add/speculation-rules-embeddability * 'trunk' of https://github.com/WordPress/performance: (50 commits) Add changelog Bump version to 1.2.2 Add missing trailing slash for dotorg link Improve test docs Prevent re-prefixing base exclude hrefs Use home context for wpnonce URLs Add failing test case for differing home and site URLs Separate custom params from other params Align params in phpdoc Add filter explicitly to test with bogus keys for exclude paths Eliminate passing base exclude paths to the filter Ensure that exclude paths is sequential array Add failing test case for exclude-paths filter Apply suggestions from code review Correct the od_buffer_output() param docs Specify value type for returned arrays Specify param type value in arrays for PHPStan Remove erroneous null default Add return types for void and nullable Update load.php ...
I'd opt for consistency over trying to adjust this algorithm depending on which plugin is using something like this. I think our goal here should be to try to find a reusable solution that can be extended to any/all of our features. |
@joemcgill Since none of our plugins need to hook in before
|
Thanks @westonruter, I think delaying the initialization of this functionality to |
@joemcgill Yeah, perhaps. But 3 of our plugins are already doing their own initialization at the I'll note too that the Action Scheduler plugin boots its logic just before // Support usage in themes - load this version if no plugin has loaded a version yet.
if ( did_action( 'plugins_loaded' ) && ! doing_action( 'plugins_loaded' ) && ! class_exists( 'ActionScheduler', false ) ) {
action_scheduler_initialize_3_dot_7_dot_3(); // WRCS: DEFINED_VERSION.
do_action( 'action_scheduler_pre_theme_init' );
ActionScheduler_Versions::initialize_latest_version();
} However, it is initializing at if ( ! class_exists( 'ActionScheduler_Versions', false ) ) {
require_once __DIR__ . '/classes/ActionScheduler_Versions.php';
add_action( 'plugins_loaded', array( 'ActionScheduler_Versions', 'initialize_latest_version' ), 1, 0 );
} What this means is that a newer version of Action Scheduler embedded in a theme cannot take precedence over an older version embedded in a plugin, because the plugin-embedded version will initialize at All that to say, I think using Update: Done in 224a471. |
version_compare( $version, $GLOBALS[ $global_var_name ]['version'], '>' ) | ||
|| | ||
// Otherwise, register this copy if it is actually the one installed in the directory for plugins. | ||
rtrim( WP_PLUGIN_DIR, '/' ) === dirname( __DIR__ ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We may need to think about systems that install a copy of this as an MU Plugin. Should we allow a plugin with a newer version of the API to override the MU Plugin copy?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think so because only plugins in the plugins
directory can be updated.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Only if you're talking about updating from the .org plugins directory. Lots of platforms require updating via deployments where you would update the mu-plugin in similar ways to any other plugin. Regardless, you're probably right that we should leave handling of MU Plugins to the people managing those systems.
The scenario that came to mind was when a platform intentionally installed the Speculation Rules plugin as part of an MU plugin setup, but allowed some other plugin to be installed that happened to bundle a newer version of the feature. As someone managing a platform, I would want the MU Plugin version to take priority, but I could write code to ensure that happens and load it as part of whatever loader file I've placed in the mu-plugins directory.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The other reason for why I think a regular plugin should take precedence over an mu-plugin is the same reason as I outlined in the description:
This is because there is a Settings link in the plugin row actions, and when something is misbehaving with the plugin it would be confusing for them to report an issue for the plugin which is active but isn't actually loaded.
If someone has the Speculative Loading plugin installed and they can see it in their plugins list as active, then if something goes awry with the plugin and they report a bug to the support forum, they should be able to share the version as listed in the plugins list page. (Granted, they could look for the generator meta tag, but users would be more likely to use the version listed in the plugin list table when reporting a bug.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All that to say, I think using after_setup_theme, with a comment explaining why that action is used, will give us the most flexibility.
This sounds good to me. I think this is worth trying out as a first pass.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good!
This ports over the demonstrated in the plugin bootstrap file of the woocommerce/action-scheduler plugin. It facilitates multiple copies of the plugin to be installed on a site:
In each case, the logic in this PR ensures that only one copy is loaded. If it is embedded in multiple plugins, then the version of Speculation Rules which has the highest version is chosen. If it was not embedded in a plugin and isn't active as a standalone plugin but is embedded in a theme instead, then that is the copy which will load.
In contrast with the action scheduler plugin, I decided that if a copy of the plugin is installed in the plugins directory, then it should win out over other copies. This is because there is a Settings link in the plugin row actions, and when something is misbehaving with the plugin it would be confusing for them to report an issue for the plugin which is active but isn't actually loaded.
See #1081.