PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 All 33 releases
← All changes | includes/class-plugin.php +247 -18 1.1.4 → 1.3.7 View file →
@@ -14,9 +14,9 @@
14 14 /**
15 15 * Data-schema version for one-time migrations, independent of the
16 16 * plugin version header. Bump when adding a step to maybe_upgrade().
17 17 */
18 - public const DATA_VERSION = '1.1.4';
18 + public const DATA_VERSION = '1.3.6';
19 19
20 20 private static $instance = null;
21 21
22 22 /** @var Usage_Tracker|null */
@@ -76,13 +76,40 @@
76 76 // Per-post cache rules (Phase 3.4) — registers postmeta with
77 77 // REST + meta box on edit screens.
78 78 Cache_Meta_Box::boot();
79 79
80 + // Single-URL purge entry points — row actions, the edit-screen
81 + // button and the admin-post handler the admin-bar item also uses.
82 + Purge_Ui::boot();
83 +
80 84 // Phase 0 architecture — managers + Free modules. v1 services
81 85 // (Cache/Minifier/Gzip) are NOT yet Modules; they'll be refactored
82 86 // in a follow-up PR with parity tests.
83 87 Conflict_Registry::boot();
84 88
89 + // Read-only page-cache ownership evidence, behind the same
90 + // activation/deactivation invalidation as the conflict matrix.
91 + Page_Cache_Detector::boot();
92 +
93 + // Integrations that clear OTHER plugins' caches of rendered output
94 + // (Elementor's element cache + generated CSS). Registers a listener
95 + // only; nothing runs until Cache::purge_render_caches() asks.
96 + Render_Caches::boot();
97 + // Forwarding needs no boot(): Cache::dispatch_purge_event() calls
98 + // Server_Caches::forward() directly so a throwing third-party listener
99 + // cannot skip it. Both built-in server-cache adapters live behind it —
100 + // LiteSpeed, and the nginx FastCGI cache reached through the host's
101 + // Nginx Helper install (Host_Page_Caches), which is why neither is
102 + // booted here. This boot() registers one listener only: the single
103 + // purge that runs after an import, which the nginx adapter's import
104 + // gate stands down in favour of.
105 + Server_Caches::boot();
106 +
107 + // Tell WP Statistics and Slimstat not to count the warmer, the
108 + // benchmark and the verifier as visitors. Record-time filters only;
109 + // see the class for why the tracking snippet itself is left alone.
110 + Self_Traffic::boot();
111 +
85 112 // Register Free modules via the same action xspeed-pro uses, so
86 113 // the bootstrap path is symmetric across tiers.
87 114 add_action( 'xspeed_register_modules', array( $this, 'register_free_modules' ) );
88 115
@@ -107,8 +134,38 @@
107 134 }
108 135 }
109 136
110 137 /**
138 + * Add the 1.3.6 tracking params to a saved ignored-params list.
139 + *
140 + * Only those names, and only when missing: anything else the site
141 + * removed stays removed. A site with no saved list already reads the new
142 + * defaults.
143 + */
144 + public static function add_new_ignored_params(): void {
145 + $option = Settings_Manager::OPTION_PREFIX . 'cache';
146 + $stored = get_option( $option, array() );
147 + if ( ! is_array( $stored ) || ! is_array( $stored['ignored_query_params'] ?? null ) ) {
148 + return;
149 + }
150 + $list = $stored['ignored_query_params'];
151 + // An empty list is a choice: every query string bypasses the cache.
152 + if ( empty( $list ) ) {
153 + return;
154 + }
155 + $missing = array_values( array_diff( \XSpeed\Modules\Cache\CacheModule::TRACKING_PARAMS_1_3_6, $list ) );
156 + if ( empty( $missing ) ) {
157 + return;
158 + }
159 + $stored['ignored_query_params'] = array_merge( $list, $missing );
160 + update_option( $option, $stored );
161 + // The drop-in keeps its own copy of the list next to the cache.
162 + if ( defined( 'XSPEED_CACHE_DIR' ) ) {
163 + Cache::sync_query_allowlist();
164 + }
165 + }
166 +
167 + /**
111 168 * Run version-gated data migrations exactly once per upgrade.
112 169 *
113 170 * Keyed on `xspeed_data_version` rather than the plugin version header
114 171 * so a migration can be added without forcing a release bump.
@@ -118,18 +175,45 @@
118 175 if ( version_compare( $current, self::DATA_VERSION, '>=' ) ) {
119 176 return;
120 177 }
121 178
179 + // Each step runs once, for the version that needs it. They used to
180 + // run on every bump, which would purge every site's static tree again
181 + // for a migration that has nothing to do with it.
182 +
122 183 // 1.1.2 — strip credential values recorded by earlier versions'
123 184 // settings change annotations (they're served by the trend endpoints).
124 - Activity_Log::redact_legacy_secrets();
185 + if ( version_compare( $current, '1.1.2', '<' ) ) {
186 + Activity_Log::redact_legacy_secrets();
187 + }
125 188
126 189 // 1.1.4 — earlier versions cached a failed loopback as "gzip is not
127 190 // active" for an hour, which showed up as a bogus server-config
128 191 // warning. Drop the stale answer so the fixed probe re-runs instead
129 192 // of the wrong verdict living on past the update (issue #18).
130 - delete_transient( 'xspeed_gzip_active' );
193 + if ( version_compare( $current, '1.1.4', '<' ) ) {
194 + delete_transient( 'xspeed_gzip_active' );
195 + }
131 196
197 + // 1.1.6 — a `/?s=<term>` request used to write its results page into
198 + // the static tree under the *searched-from* path, which for the usual
199 + // query-form search is `/`. The web server then served that results
200 + // page as the homepage to every visitor. The write is fixed in
201 + // Cache::store_static(), but an entry poisoned before the update
202 + // outlives it: nothing purges on upgrade, and the static serve path
203 + // never revalidates. Clear the tree once. The flat cache is keyed
204 + // correctly and is deliberately left alone. (issue #191)
205 + if ( version_compare( $current, '1.1.6', '<' ) ) {
206 + Cache::purge_static_tree();
207 + }
208 +
209 + // 1.3.6 — new click and campaign IDs in the default ignored list.
210 + // A site that ever saved the Cache panel has its own copy of the list,
211 + // which the new defaults never reach, so add them to it.
212 + if ( version_compare( $current, '1.3.6', '<' ) ) {
213 + self::add_new_ignored_params();
214 + }
215 +
132 216 update_option( 'xspeed_data_version', self::DATA_VERSION, false );
133 217 }
134 218
135 219 /**
@@ -140,8 +224,63 @@
140 224 * Runs at plugins_loaded(20) so every add-on that hooks at any
141 225 * priority < 20 has time to register first.
142 226 */
143 227 public function fire_module_lifecycle(): void {
228 + $this->ensure_modules_registered();
229 +
230 + Module_Registry::boot_all();
231 + }
232 +
233 + /**
234 + * Fire `xspeed_register_modules` if this request has not yet, hooking
235 + * Free's own registration first when init() never got the chance.
236 + *
237 + * The activation request is the case that matters. activate_plugin()
238 + * includes the plugin file long after `plugins_loaded` has fired, so the
239 + * `plugins_loaded` callback init() would have added never runs, and
240 + * neither does the add_action() inside it that puts register_free_modules
241 + * on the action. Firing the action from activate() then registered
242 + * nothing: Settings::conflict_safe_profile() composed from an empty
243 + * registry, and a site with WP Super Cache came up with lazy-load,
244 + * resource hints, font swapping and preloading switched on — only the
245 + * four settings the registry-independent fallback names were held down
246 + * (PR #295 review). Module_Registry::activate_all() has been a no-op on
247 + * the same request for the same reason.
248 + *
249 + * On the activation request that means Free only: an add-on cannot have
250 + * hooked yet, because xspeed-pro bails when Free's classes are absent and
251 + * only hooks the action (at priority 20, from plugins_loaded(15)) once
252 + * Free is active. On an ordinary request fire_module_lifecycle() reaches
253 + * this at plugins_loaded(20) with every add-on already hooked. Do not
254 + * call this from anything that can run in between: the action fires
255 + * once, and an add-on that has not hooked yet stays unregistered for the
256 + * whole request.
257 + * did_action() keeps the action to one firing per request, so an
258 + * activation that ran first does not make plugins_loaded(20) register
259 + * every module a second time.
260 + */
261 + public function ensure_modules_registered(): void {
262 + if ( did_action( 'xspeed_register_modules' ) ) {
263 + return;
264 + }
265 +
266 + /*
267 + * register_free_modules() does an unconditional `new` on every module
268 + * class. On an ordinary request xspeed.php's integrity check refuses
269 + * to boot before that can fatal and explains itself in an admin
270 + * notice; the activation hook is registered outside that check, so
271 + * an install missing a module file (truncated zip, a security
272 + * plugin's quarantine, a half-applied update) would fatal here with
273 + * no notice and no active plugin. Same answer as boot: do nothing.
274 + */
275 + if ( function_exists( 'xspeed_missing_core_classes' ) && ! empty( xspeed_missing_core_classes() ) ) {
276 + return;
277 + }
278 +
279 + if ( ! has_action( 'xspeed_register_modules', array( $this, 'register_free_modules' ) ) ) {
280 + add_action( 'xspeed_register_modules', array( $this, 'register_free_modules' ) );
281 + }
282 +
144 283 /**
145 284 * Action: xspeed_register_modules
146 285 *
147 286 * Free modules register at priority 10; xspeed-pro at priority
@@ -148,10 +287,8 @@
148 287 * 20; site code can hook in between to inject custom modules.
149 288 * Fires exactly once per request.
150 289 */
151 290 do_action( 'xspeed_register_modules' );
152 -
153 - Module_Registry::boot_all();
154 291 }
155 292
156 293 /**
157 294 * Register the Free Modules shipped in this plugin. Add new module
@@ -159,8 +296,13 @@
159 296 */
160 297 public function register_free_modules(): void {
161 298 Module_Registry::register( new \XSpeed\Modules\Cache\CacheModule() );
162 299 Module_Registry::register( new \XSpeed\Modules\Health\HealthModule() );
300 + // Settings — owns no settings itself; it's the CLI/MCP surface over
301 + // Settings_Manager. Registering it is what makes `xspeed settings`
302 + // exist, which is what keeps the curated get_settings/update_settings
303 + // tools in the MCP catalog. (#149/#153)
304 + Module_Registry::register( new \XSpeed\Modules\Settings\SettingsModule() );
163 305 // External performance scores (PSI / GTmetrix) — Free, off by
164 306 // default. Rendered inside the Health host page's PageSpeed tab, so
165 307 // it has no sidebar row of its own.
166 308 Module_Registry::register( new \XSpeed\Modules\Score\ScoreModule() );
@@ -179,8 +321,9 @@
179 321 // cache-coverage features (404 / search / feed / REST / rules /
180 322 // maintenance) into one sidebar sub-item (FBS-83633).
181 323 Module_Registry::register( new \XSpeed\Modules\CacheCoverage\CacheCoverageModule() );
182 324 Module_Registry::register( new \XSpeed\Modules\Fonts\FontsModule() );
325 + Module_Registry::register( new \XSpeed\Modules\TurboRender\TurboRenderModule() );
183 326 Module_Registry::register( new \XSpeed\Modules\ResourceHints\ResourceHintsModule() );
184 327 // AI Privacy (GDPR off-switch) ships in Free even though every AI
185 328 // *feature* is Pro — privacy is a right, not a paid tier. FEATURES.md
186 329 // §AI row 6 mandates it. Without this registration the module was dead
@@ -197,8 +340,12 @@
197 340 // snapshot is onboarding/diagnostics, not a paid value-add, so every
198 341 // user gets it. The snapshot degrades gracefully without Pro (Pro
199 342 // version/license fields fall back to defaults via defined()/get_option).
200 343 Module_Registry::register( new \XSpeed\Modules\Support\SupportModule() );
344 + // The dashboard control for usage-analytics consent. Consent used to
345 + // be collectable only in the wizard and withdrawable nowhere, while
346 + // the wizard and readme both promised a dashboard switch. (#437)
347 + Module_Registry::register( new \XSpeed\Modules\Privacy\PrivacyModule() );
201 348 // MCP remote control (AI assistants) — Free. The plugin serves the
202 349 // MCP protocol at the site's own /xspeed/mcp URL; the only gate is
203 350 // the per-site connection token an admin mints via Connect. No
204 351 // license, no hosted infra. See IMPLEMENTATION.md §17.
@@ -213,10 +360,11 @@
213 360 public function start_plugin_tracking(): void {
214 361 $this->usage_tracker = Usage_Tracker::get_instance(
215 362 XSPEED_FILE,
216 363 array(
217 - 'opt_in' => true,
218 - 'item_id' => defined( 'XSPEED_INSIGHTS_ITEM_ID' ) ? XSPEED_INSIGHTS_ITEM_ID : false,
364 + 'opt_in' => true,
365 + 'email_marketing' => true,
366 + 'item_id' => defined( 'XSPEED_INSIGHTS_ITEM_ID' ) ? XSPEED_INSIGHTS_ITEM_ID : false,
219 367 )
220 368 );
221 369 $this->usage_tracker->init();
222 370 }
@@ -229,10 +377,16 @@
229 377 return $this->usage_tracker;
230 378 }
231 379
232 380 public static function activate() {
233 - Settings::set_defaults();
381 + // Nothing below can see a module the registry does not hold — the
382 + // conflict-safe profile Settings::set_defaults() may pick is composed
383 + // from it, and activate_all() walks it. See ensure_modules_registered()
384 + // for why the registry is empty on this request without this call.
385 + self::instance()->ensure_modules_registered();
234 386
387 + $profile = Settings::set_defaults();
388 +
235 389 // Score history table. Also called on admin_init (see init()) because
236 390 // activation does not fire for a site added to a multisite network
237 391 // later, nor after an update that ships a new schema version.
238 392 Score_Store::maybe_install();
@@ -241,12 +395,62 @@
241 395 wp_mkdir_p( XSPEED_CACHE_DIR );
242 396 }
243 397 Cache::write_silence( XSPEED_CACHE_DIR );
244 398
245 - // Caching is only ever ENABLED from the admin UI — see Cache::toggle()
246 - // and Rest_Api::toggle_cache(). A fresh install therefore gets no
247 - // drop-in and no wp-config.php edit here: cache_enabled is unset, so
248 - // the call below is a no-op.
399 + /*
400 + * One exception to "caching is only ever enabled from the admin UI":
401 + * a fresh install another plugin performed on the user's behalf.
402 + *
403 + * That plugin asked the user for site performance and installed us to
404 + * provide it; making them go and find a second switch afterwards is
405 + * a step nobody wants. It is also what the copy-vendored Setup::finish()
406 + * did, so hosts migrating off it keep the behaviour they have.
407 + *
408 + * PROFILE_HOST_PAGE_CACHE is the whole condition, and it means three
409 + * things at once: the install was genuinely fresh, nothing else owns
410 + * the page cache, and another plugin claimed the install. Every other
411 + * fresh install — including a user's own on a clear site — waits for
412 + * the wizard. Every OTHER feature is off in this profile; the cache is
413 + * the one thing a host may assume, because it is what it installed us
414 + * for. And toggle() runs its own ownership transaction, so a
415 + * competitor appearing between the two checks loses the race rather
416 + * than being overwritten.
417 + */
418 + if ( Settings::PROFILE_HOST_PAGE_CACHE === $profile ) {
419 + $state = Cache::toggle( true );
420 + if ( ! empty( $state['blocked'] ) ) {
421 + /*
422 + * Refused after all — a competitor that appeared between the
423 + * profile decision and the write, or a drop-in we could not
424 + * install. The settings are identical either way (everything
425 + * off), so the honest record is the one that does not claim a
426 + * cache: conflict-safe is what the site actually got.
427 + */
428 + $profile = Settings::PROFILE_CONFLICT_SAFE;
429 + update_option( 'xspeed_install_profile', $profile, false );
430 + }
431 + }
432 +
433 + /*
434 + * Read the claim BEFORE spending it. consume_installed_by() records
435 + * the installer only for a FRESH install — on a re-activation over
436 + * settings that are already there it deletes the arming option and
437 + * keeps nothing — so asking again afterwards answered "the user did
438 + * it" about an install a host had just claimed, and the wizard opened
439 + * over the host's own flow. The claim decides who to tell and whether
440 + * to open the wizard; whether it is worth RECORDING is a separate
441 + * question, and only the recording depends on the install being fresh.
442 + */
443 + $installed_by = Settings::installed_by();
444 +
445 + // Spend the arming option now the profile is settled. It changes what
446 + // activation does, so it may not survive into the next one.
447 + Settings::consume_installed_by( $profile );
448 +
449 + // Caching is otherwise only ENABLED from the admin UI — see
450 + // Cache::toggle() and Rest_Api::toggle_cache(). A fresh install
451 + // therefore gets no drop-in and no wp-config.php edit here:
452 + // cache_enabled is unset, so the call below is a no-op.
249 453 //
250 454 // It is NOT a no-op during an upgrade. WordPress runs an update as
251 455 // deactivate → wipe files → install → activate, which deletes
252 456 // advanced-cache.php while cache_enabled stays true. Restoring it
@@ -253,16 +457,41 @@
253 457 // here closes the window in which the site silently serves uncached
254 458 // (auto_heal() alone only fires on the next wp-admin page load).
255 459 Cache::restore_dropin_if_enabled();
256 460
257 - // First-run wizard: flag a one-time redirect for the activating user.
258 - // Suppressed for bulk activations / already-completed sites in
259 - // Onboarding::maybe_redirect().
260 - Onboarding::flag_redirect();
461 + /*
462 + * First-run wizard: flag a one-time redirect for the activating user.
463 + * Suppressed for bulk activations / already-completed sites in
464 + * Onboarding::maybe_redirect() — and here for an install another plugin
465 + * performed, which has an onboarding flow of its own. The install runs
466 + * over AJAX, so our redirect would fire on that admin's NEXT page load
467 + * and pull them out of the middle of the host's wizard; finishing ours
468 + * would then overwrite the deliberately all-off profile they never
469 + * asked to change.
470 + */
471 + if ( '' === $installed_by ) {
472 + Onboarding::flag_redirect();
473 + }
261 474
262 - // Propagate activation to every registered Module. Activation
263 - // happens after plugins_loaded → modules are already registered.
475 + // Propagate activation to every registered Module — registered by
476 + // ensure_modules_registered() at the top, not by plugins_loaded,
477 + // which fired before this file was even included.
264 478 Module_Registry::activate_all();
479 +
480 + /**
481 + * Fires at the end of activation, once the settings profile is decided.
482 + *
483 + * The other half of the host-plugin contract: a plugin that installed
484 + * xSpeed for the user writes `xspeed_installed_by` before activating
485 + * and listens here to find out how it came up. It carries no return
486 + * value and nothing branches on it — a host that ignores it changes
487 + * nothing about the install.
488 + *
489 + * @param string $installed_by Host slug, or '' when the user did it.
490 + * @param string $profile Settings::PROFILE_* — which profile a fresh
491 + * install came up with, '' if not fresh.
492 + */
493 + do_action( 'xspeed_activated', $installed_by, $profile );
265 494 }
266 495
267 496 /**
268 497 * Restore the cache drop-in right after THIS plugin is updated.