PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 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 All 35 releases
← All changes | includes/class-plugin.php +435 -17 1.0.8 → 1.4.0 View file →
@@ -10,8 +10,14 @@
10 10 defined( 'ABSPATH' ) || exit;
11 11
12 12 class Plugin {
13 13
14 + /**
15 + * Data-schema version for one-time migrations, independent of the
16 + * plugin version header. Bump when adding a step to maybe_upgrade().
17 + */
18 + public const DATA_VERSION = '1.3.6';
19 +
14 20 private static $instance = null;
15 21
16 22 /** @var Usage_Tracker|null */
17 23 private $usage_tracker = null;
@@ -32,8 +38,15 @@
32 38 new Cache();
33 39 new Rest_Cache();
34 40 new Onboarding();
35 41
42 + // Optional deactivation feedback survey on the Plugins screen. Admin
43 + // context only (its hooks are admin_enqueue_scripts / admin_footer /
44 + // wp_ajax). Sends nothing unless the user clicks "Submit & Deactivate".
45 + if ( is_admin() ) {
46 + new Deactivation_Feedback();
47 + }
48 +
36 49 // Opt-in usage analytics. Instantiating + init() only registers the
37 50 // cron callback; NOTHING is collected or sent until the admin opts in
38 51 // from the setup wizard (Onboarding wires the consent toggle to
39 52 // Usage_Tracker::opt_in()). See class-usage-tracker.php privacy contract.
@@ -46,18 +59,64 @@
46 59 // this is a consistency check, not a new install path. First
47 60 // admin page load after a plugin upgrade restores the state;
48 61 // front-end then serves from cache on the next request.
49 62 add_action( 'admin_init', array( Cache::class, 'auto_heal' ) );
63 + // Cheap: returns immediately unless the stored schema version is
64 + // behind. Covers updates and multisite, where activate() never runs.
65 + add_action( 'admin_init', array( Score_Store::class, 'maybe_install' ) );
50 66
67 + // Secondary net: restore as soon as an update completes, for the
68 + // cases where activate() does not re-run (bulk updates, auto-updates,
69 + // some host updaters). Best-effort by nature — this callback is only
70 + // registered when we were loaded in the request performing the
71 + // update, which is not guaranteed while WE are the plugin being
72 + // replaced. The restore in activate() is the primary guarantee;
73 + // auto_heal() on admin_init remains the backstop.
74 + add_action( 'upgrader_process_complete', array( $this, 'maybe_restore_after_update' ), 10, 2 );
75 +
51 76 // Per-post cache rules (Phase 3.4) — registers postmeta with
52 77 // REST + meta box on edit screens.
53 78 Cache_Meta_Box::boot();
54 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 +
55 84 // Phase 0 architecture — managers + Free modules. v1 services
56 85 // (Cache/Minifier/Gzip) are NOT yet Modules; they'll be refactored
57 86 // in a follow-up PR with parity tests.
58 87 Conflict_Registry::boot();
59 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 + // Remembers the terms a post had before a save, so a narrow purge
108 + // can clear the category it left as well as the one it joined.
109 + Affected_Pages::boot();
110 + // Records which pages run a post list of their own (a page builder
111 + // grid, a related-posts block), so a narrow purge clears them too.
112 + Listing_Pages::boot();
113 +
114 + // Tell WP Statistics and Slimstat not to count the warmer, the
115 + // benchmark and the verifier as visitors. Record-time filters only;
116 + // see the class for why the tracking snippet itself is left alone.
117 + Self_Traffic::boot();
118 +
60 119 // Register Free modules via the same action xspeed-pro uses, so
61 120 // the bootstrap path is symmetric across tiers.
62 121 add_action( 'xspeed_register_modules', array( $this, 'register_free_modules' ) );
63 122
@@ -73,11 +132,130 @@
73 132 // its register_pro_modules callback AFTER the action had
74 133 // already fired — Pro modules never appeared in the registry.
75 134 // Caught by the ProStatus sentinel module's integration test.
76 135 add_action( 'plugins_loaded', array( $this, 'fire_module_lifecycle' ), 20 );
136 +
137 + // One-time data migrations keyed on the stored version. Runs in
138 + // admin only — nothing here needs to touch a front-end request.
139 + if ( is_admin() ) {
140 + add_action( 'plugins_loaded', array( $this, 'maybe_upgrade' ), 21 );
141 + }
77 142 }
78 143
79 144 /**
145 + * Add the 1.3.6 tracking params to a saved ignored-params list.
146 + *
147 + * Only those names, and only when missing: anything else the site
148 + * removed stays removed. A site with no saved list already reads the new
149 + * defaults.
150 + */
151 + public static function add_new_ignored_params(): void {
152 + $option = Settings_Manager::OPTION_PREFIX . 'cache';
153 + $stored = get_option( $option, array() );
154 + if ( ! is_array( $stored ) || ! is_array( $stored['ignored_query_params'] ?? null ) ) {
155 + return;
156 + }
157 + $list = $stored['ignored_query_params'];
158 + // An empty list is a choice: every query string bypasses the cache.
159 + if ( empty( $list ) ) {
160 + return;
161 + }
162 + $missing = array_values( array_diff( \XSpeed\Modules\Cache\CacheModule::TRACKING_PARAMS_1_3_6, $list ) );
163 + if ( empty( $missing ) ) {
164 + return;
165 + }
166 + $stored['ignored_query_params'] = array_merge( $list, $missing );
167 + update_option( $option, $stored );
168 + // The drop-in keeps its own copy of the list next to the cache.
169 + if ( defined( 'XSPEED_CACHE_DIR' ) ) {
170 + Cache::sync_query_allowlist();
171 + }
172 + }
173 +
174 + /**
175 + * Run version-gated data migrations exactly once per upgrade.
176 + *
177 + * Keyed on `xspeed_data_version` rather than the plugin version header
178 + * so a migration can be added without forcing a release bump.
179 + */
180 + public function maybe_upgrade(): void {
181 + $current = (string) get_option( 'xspeed_data_version', '0' );
182 + if ( version_compare( $current, self::DATA_VERSION, '>=' ) ) {
183 + return;
184 + }
185 +
186 + // Each step runs once, for the version that needs it. They used to
187 + // run on every bump, which would purge every site's static tree again
188 + // for a migration that has nothing to do with it.
189 +
190 + // 1.1.2 — strip credential values recorded by earlier versions'
191 + // settings change annotations (they're served by the trend endpoints).
192 + if ( version_compare( $current, '1.1.2', '<' ) ) {
193 + Activity_Log::redact_legacy_secrets();
194 + }
195 +
196 + // 1.1.4 — earlier versions cached a failed loopback as "gzip is not
197 + // active" for an hour, which showed up as a bogus server-config
198 + // warning. Drop the stale answer so the fixed probe re-runs instead
199 + // of the wrong verdict living on past the update (issue #18).
200 + if ( version_compare( $current, '1.1.4', '<' ) ) {
201 + delete_transient( 'xspeed_gzip_active' );
202 + }
203 +
204 + // 1.1.6 — a `/?s=<term>` request used to write its results page into
205 + // the static tree under the *searched-from* path, which for the usual
206 + // query-form search is `/`. The web server then served that results
207 + // page as the homepage to every visitor. The write is fixed in
208 + // Cache::store_static(), but an entry poisoned before the update
209 + // outlives it: nothing purges on upgrade, and the static serve path
210 + // never revalidates. Clear the tree once. The flat cache is keyed
211 + // correctly and is deliberately left alone. (issue #191)
212 + if ( version_compare( $current, '1.1.6', '<' ) ) {
213 + $removed = Cache::purge_static_tree();
214 +
215 + /*
216 + * And tell anything caching in front of us, because the poisoned
217 + * entry is exactly the kind that reaches an edge: the web server
218 + * served it straight off disk as the homepage, so a CDN in front had
219 + * every opportunity to store it. Deleting our copy leaves that one
220 + * untouched, and the edge's lifetime is the longer of the two.
221 + *
222 + * Announced on `init` rather than here. This runs at
223 + * `plugins_loaded` 21, before an add-on has wired its purge
224 + * listeners, so firing it inline would announce to an empty room.
225 + * Priority 99 puts it after any reasonable `init` registration.
226 + *
227 + * Once per site, on the upgrade that clears the tree — never again,
228 + * since the data version is written immediately below.
229 + *
230 + * Through `Cache::announce_purge()` rather than a bare `do_action`,
231 + * because the purge-event contract (#348) is more than the payload:
232 + * it forwards to the server caches by direct call before the public
233 + * action runs, and it isolates listeners so one throwing add-on
234 + * cannot fatal the first admin request after an update. A raw
235 + * publish here would skip both and hand listeners the pre-contract
236 + * payload shape.
237 + */
238 + add_action(
239 + 'init',
240 + static function () use ( $removed ) {
241 + Cache::announce_purge( 'static cache repaired on upgrade', $removed );
242 + },
243 + 99
244 + );
245 + }
246 +
247 + // 1.3.6 — new click and campaign IDs in the default ignored list.
248 + // A site that ever saved the Cache panel has its own copy of the list,
249 + // which the new defaults never reach, so add them to it.
250 + if ( version_compare( $current, '1.3.6', '<' ) ) {
251 + self::add_new_ignored_params();
252 + }
253 +
254 + update_option( 'xspeed_data_version', self::DATA_VERSION, false );
255 + }
256 +
257 + /**
80 258 * Phase 2 of plugin init: fire the registration action (collecting
81 259 * Free + Pro + any third-party modules hooked into
82 260 * `xspeed_register_modules`) and boot the registry.
83 261 *
@@ -84,8 +262,63 @@
84 262 * Runs at plugins_loaded(20) so every add-on that hooks at any
85 263 * priority < 20 has time to register first.
86 264 */
87 265 public function fire_module_lifecycle(): void {
266 + $this->ensure_modules_registered();
267 +
268 + Module_Registry::boot_all();
269 + }
270 +
271 + /**
272 + * Fire `xspeed_register_modules` if this request has not yet, hooking
273 + * Free's own registration first when init() never got the chance.
274 + *
275 + * The activation request is the case that matters. activate_plugin()
276 + * includes the plugin file long after `plugins_loaded` has fired, so the
277 + * `plugins_loaded` callback init() would have added never runs, and
278 + * neither does the add_action() inside it that puts register_free_modules
279 + * on the action. Firing the action from activate() then registered
280 + * nothing: Settings::conflict_safe_profile() composed from an empty
281 + * registry, and a site with WP Super Cache came up with lazy-load,
282 + * resource hints, font swapping and preloading switched on — only the
283 + * four settings the registry-independent fallback names were held down
284 + * (PR #295 review). Module_Registry::activate_all() has been a no-op on
285 + * the same request for the same reason.
286 + *
287 + * On the activation request that means Free only: an add-on cannot have
288 + * hooked yet, because xspeed-pro bails when Free's classes are absent and
289 + * only hooks the action (at priority 20, from plugins_loaded(15)) once
290 + * Free is active. On an ordinary request fire_module_lifecycle() reaches
291 + * this at plugins_loaded(20) with every add-on already hooked. Do not
292 + * call this from anything that can run in between: the action fires
293 + * once, and an add-on that has not hooked yet stays unregistered for the
294 + * whole request.
295 + * did_action() keeps the action to one firing per request, so an
296 + * activation that ran first does not make plugins_loaded(20) register
297 + * every module a second time.
298 + */
299 + public function ensure_modules_registered(): void {
300 + if ( did_action( 'xspeed_register_modules' ) ) {
301 + return;
302 + }
303 +
304 + /*
305 + * register_free_modules() does an unconditional `new` on every module
306 + * class. On an ordinary request xspeed.php's integrity check refuses
307 + * to boot before that can fatal and explains itself in an admin
308 + * notice; the activation hook is registered outside that check, so
309 + * an install missing a module file (truncated zip, a security
310 + * plugin's quarantine, a half-applied update) would fatal here with
311 + * no notice and no active plugin. Same answer as boot: do nothing.
312 + */
313 + if ( function_exists( 'xspeed_missing_core_classes' ) && ! empty( xspeed_missing_core_classes() ) ) {
314 + return;
315 + }
316 +
317 + if ( ! has_action( 'xspeed_register_modules', array( $this, 'register_free_modules' ) ) ) {
318 + add_action( 'xspeed_register_modules', array( $this, 'register_free_modules' ) );
319 + }
320 +
88 321 /**
89 322 * Action: xspeed_register_modules
90 323 *
91 324 * Free modules register at priority 10; xspeed-pro at priority
@@ -92,10 +325,8 @@
92 325 * 20; site code can hook in between to inject custom modules.
93 326 * Fires exactly once per request.
94 327 */
95 328 do_action( 'xspeed_register_modules' );
96 -
97 - Module_Registry::boot_all();
98 329 }
99 330
100 331 /**
101 332 * Register the Free Modules shipped in this plugin. Add new module
@@ -103,8 +334,17 @@
103 334 */
104 335 public function register_free_modules(): void {
105 336 Module_Registry::register( new \XSpeed\Modules\Cache\CacheModule() );
106 337 Module_Registry::register( new \XSpeed\Modules\Health\HealthModule() );
338 + // Settings — owns no settings itself; it's the CLI/MCP surface over
339 + // Settings_Manager. Registering it is what makes `xspeed settings`
340 + // exist, which is what keeps the curated get_settings/update_settings
341 + // tools in the MCP catalog. (#149/#153)
342 + Module_Registry::register( new \XSpeed\Modules\Settings\SettingsModule() );
343 + // External performance scores (PSI / GTmetrix) — Free, off by
344 + // default. Rendered inside the Health host page's PageSpeed tab, so
345 + // it has no sidebar row of its own.
346 + Module_Registry::register( new \XSpeed\Modules\Score\ScoreModule() );
107 347 Module_Registry::register( new \XSpeed\Modules\Preloader\PreloaderModule() );
108 348 Module_Registry::register( new \XSpeed\Modules\Heartbeat\HeartbeatModule() );
109 349 Module_Registry::register( new \XSpeed\Modules\Minify\MinifyModule() );
110 350 Module_Registry::register( new \XSpeed\Modules\Gzip\GzipModule() );
@@ -114,10 +354,22 @@
114 354 Module_Registry::register( new \XSpeed\Modules\Cdn\CdnModule() );
115 355 Module_Registry::register( new \XSpeed\Modules\Cloudflare\CloudflareModule() );
116 356 Module_Registry::register( new \XSpeed\Modules\ObjectCache\ObjectCacheModule() );
117 357 Module_Registry::register( new \XSpeed\Modules\BrowserCache\BrowserCacheModule() );
358 + // Advanced Cache — a Free container row that gathers the Pro
359 + // cache-coverage features (404 / search / feed / REST / rules /
360 + // maintenance) into one sidebar sub-item (FBS-83633).
361 + Module_Registry::register( new \XSpeed\Modules\CacheCoverage\CacheCoverageModule() );
118 362 Module_Registry::register( new \XSpeed\Modules\Fonts\FontsModule() );
363 + Module_Registry::register( new \XSpeed\Modules\TurboRender\TurboRenderModule() );
119 364 Module_Registry::register( new \XSpeed\Modules\ResourceHints\ResourceHintsModule() );
365 + // AI Privacy (GDPR off-switch) ships in Free even though every AI
366 + // *feature* is Pro — privacy is a right, not a paid tier. FEATURES.md
367 + // §AI row 6 mandates it. Without this registration the module was dead
368 + // code: no REST/settings surface, the promised off-switch unreachable
369 + // (FBS-83633 Bug 1). It carries its own cli_commands() so it satisfies
370 + // the CLI/MCP coverage guard once registered.
371 + Module_Registry::register( new \XSpeed\Modules\AIPrivacy\AIPrivacyModule() );
120 372 // Migration moved Pro → Free: it's an acquisition/onboarding feature
121 373 // (detect a competing caching plugin, import its settings, switch over),
122 374 // so it must work without a Pro license. Agency-scale extras (profiles,
123 375 // bulk multisite, host presets) remain Pro.
@@ -126,8 +378,12 @@
126 378 // snapshot is onboarding/diagnostics, not a paid value-add, so every
127 379 // user gets it. The snapshot degrades gracefully without Pro (Pro
128 380 // version/license fields fall back to defaults via defined()/get_option).
129 381 Module_Registry::register( new \XSpeed\Modules\Support\SupportModule() );
382 + // The dashboard control for usage-analytics consent. Consent used to
383 + // be collectable only in the wizard and withdrawable nowhere, while
384 + // the wizard and readme both promised a dashboard switch. (#437)
385 + Module_Registry::register( new \XSpeed\Modules\Privacy\PrivacyModule() );
130 386 // MCP remote control (AI assistants) — Free. The plugin serves the
131 387 // MCP protocol at the site's own /xspeed/mcp URL; the only gate is
132 388 // the per-site connection token an admin mints via Connect. No
133 389 // license, no hosted infra. See IMPLEMENTATION.md §17.
@@ -142,10 +398,11 @@
142 398 public function start_plugin_tracking(): void {
143 399 $this->usage_tracker = Usage_Tracker::get_instance(
144 400 XSPEED_FILE,
145 401 array(
146 - 'opt_in' => true,
147 - 'item_id' => defined( 'XSPEED_INSIGHTS_ITEM_ID' ) ? XSPEED_INSIGHTS_ITEM_ID : false,
402 + 'opt_in' => true,
403 + 'email_marketing' => true,
404 + 'item_id' => defined( 'XSPEED_INSIGHTS_ITEM_ID' ) ? XSPEED_INSIGHTS_ITEM_ID : false,
148 405 )
149 406 );
150 407 $this->usage_tracker->init();
151 408 }
@@ -158,31 +415,185 @@
158 415 return $this->usage_tracker;
159 416 }
160 417
161 418 public static function activate() {
162 - Settings::set_defaults();
419 + // Nothing below can see a module the registry does not hold — the
420 + // conflict-safe profile Settings::set_defaults() may pick is composed
421 + // from it, and activate_all() walks it. See ensure_modules_registered()
422 + // for why the registry is empty on this request without this call.
423 + self::instance()->ensure_modules_registered();
163 424
425 + $profile = Settings::set_defaults();
426 +
427 + // Score history table. Also called on admin_init (see init()) because
428 + // activation does not fire for a site added to a multisite network
429 + // later, nor after an update that ships a new schema version.
430 + Score_Store::maybe_install();
431 +
164 432 if ( ! file_exists( XSPEED_CACHE_DIR ) ) {
165 433 wp_mkdir_p( XSPEED_CACHE_DIR );
166 434 }
167 435 Cache::write_silence( XSPEED_CACHE_DIR );
168 436
169 - // Drop-in installation (advanced-cache.php) and the WP_CACHE constant
170 - // edit happen only when the user explicitly enables caching from the
171 - // admin UI — never on activation. See Cache::toggle() and
172 - // Rest_Api::toggle_cache(). This is a WordPress.org review requirement.
437 + /*
438 + * One exception to "caching is only ever enabled from the admin UI":
439 + * a fresh install another plugin performed on the user's behalf.
440 + *
441 + * That plugin asked the user for site performance and installed us to
442 + * provide it; making them go and find a second switch afterwards is
443 + * a step nobody wants. It is also what the copy-vendored Setup::finish()
444 + * did, so hosts migrating off it keep the behaviour they have.
445 + *
446 + * PROFILE_HOST_PAGE_CACHE is the whole condition, and it means three
447 + * things at once: the install was genuinely fresh, nothing else owns
448 + * the page cache, and another plugin claimed the install. Every other
449 + * fresh install — including a user's own on a clear site — waits for
450 + * the wizard. Every OTHER feature is off in this profile; the cache is
451 + * the one thing a host may assume, because it is what it installed us
452 + * for. And toggle() runs its own ownership transaction, so a
453 + * competitor appearing between the two checks loses the race rather
454 + * than being overwritten.
455 + */
456 + if ( Settings::PROFILE_HOST_PAGE_CACHE === $profile ) {
457 + $state = Cache::toggle( true );
458 + if ( ! empty( $state['blocked'] ) ) {
459 + /*
460 + * Refused after all — a competitor that appeared between the
461 + * profile decision and the write, or a drop-in we could not
462 + * install. The settings are identical either way (everything
463 + * off), so the honest record is the one that does not claim a
464 + * cache: conflict-safe is what the site actually got.
465 + */
466 + $profile = Settings::PROFILE_CONFLICT_SAFE;
467 + update_option( 'xspeed_install_profile', $profile, false );
468 + }
469 + }
173 470
174 - // First-run wizard: flag a one-time redirect for the activating user.
175 - // Suppressed for bulk activations / already-completed sites in
176 - // Onboarding::maybe_redirect().
177 - Onboarding::flag_redirect();
471 + /*
472 + * Read the claim BEFORE spending it. consume_installed_by() records
473 + * the installer only for a FRESH install — on a re-activation over
474 + * settings that are already there it deletes the arming option and
475 + * keeps nothing — so asking again afterwards answered "the user did
476 + * it" about an install a host had just claimed, and the wizard opened
477 + * over the host's own flow. The claim decides who to tell and whether
478 + * to open the wizard; whether it is worth RECORDING is a separate
479 + * question, and only the recording depends on the install being fresh.
480 + */
481 + $installed_by = Settings::installed_by();
178 482
179 - // Propagate activation to every registered Module. Activation
180 - // happens after plugins_loaded → modules are already registered.
483 + // Spend the arming option now the profile is settled. It changes what
484 + // activation does, so it may not survive into the next one.
485 + Settings::consume_installed_by( $profile );
486 +
487 + // Caching is otherwise only ENABLED from the admin UI — see
488 + // Cache::toggle() and Rest_Api::toggle_cache(). A fresh install
489 + // therefore gets no drop-in and no wp-config.php edit here:
490 + // cache_enabled is unset, so the call below is a no-op.
491 + //
492 + // It is NOT a no-op during an upgrade. WordPress runs an update as
493 + // deactivate → wipe files → install → activate, which deletes
494 + // advanced-cache.php while cache_enabled stays true. Restoring it
495 + // here closes the window in which the site silently serves uncached
496 + // (auto_heal() alone only fires on the next wp-admin page load).
497 + Cache::restore_dropin_if_enabled();
498 +
499 + /*
500 + * First-run wizard: flag a one-time redirect for the activating user.
501 + * Suppressed for bulk activations / already-completed sites in
502 + * Onboarding::maybe_redirect() — and here for an install another plugin
503 + * performed, which has an onboarding flow of its own. The install runs
504 + * over AJAX, so our redirect would fire on that admin's NEXT page load
505 + * and pull them out of the middle of the host's wizard; finishing ours
506 + * would then overwrite the deliberately all-off profile they never
507 + * asked to change.
508 + */
509 + if ( '' === $installed_by ) {
510 + Onboarding::flag_redirect();
511 + }
512 +
513 + // Propagate activation to every registered Module — registered by
514 + // ensure_modules_registered() at the top, not by plugins_loaded,
515 + // which fired before this file was even included.
181 516 Module_Registry::activate_all();
517 +
518 + /**
519 + * Fires at the end of activation, once the settings profile is decided.
520 + *
521 + * The other half of the host-plugin contract: a plugin that installed
522 + * xSpeed for the user writes `xspeed_installed_by` before activating
523 + * and listens here to find out how it came up. It carries no return
524 + * value and nothing branches on it — a host that ignores it changes
525 + * nothing about the install.
526 + *
527 + * @param string $installed_by Host slug, or '' when the user did it.
528 + * @param string $profile Settings::PROFILE_* — which profile a fresh
529 + * install came up with, '' if not fresh.
530 + */
531 + do_action( 'xspeed_activated', $installed_by, $profile );
532 +
533 + /*
534 + * Announce the change to anything caching in front of us.
535 + *
536 + * Activation writes the default settings and activates modules, so
537 + * every URL on the site starts returning different HTML. A CDN
538 + * holding renders from before goes on serving them for their whole
539 + * lifetime, and nothing told it.
540 + *
541 + * Cheap on a fresh install — there is nothing to sweep — and the
542 + * case it exists for is reactivation on a site that has been running
543 + * for months behind an edge.
544 + */
545 + Cache::purge_all( 'plugin activated' );
182 546 }
183 547
184 - public static function deactivate() {
548 + /**
549 + * Restore the cache drop-in right after THIS plugin is updated.
550 + *
551 + * Bound to upgrader_process_complete. Bulk updates, auto-updates and
552 + * host-level updaters finish without re-running activate(), so this is
553 + * the only hook that repairs the drop-in before the next wp-admin page
554 + * load. Narrow by design: bails unless the completed action was a
555 + * plugin update whose payload actually includes xspeed.
556 + *
557 + * @param \WP_Upgrader $upgrader Upgrader instance (unused).
558 + * @param array $hook_extra Contextual data about the update.
559 + * @return void
560 + */
561 + public function maybe_restore_after_update( $upgrader, $hook_extra ) {
562 + unset( $upgrader );
563 +
564 + if ( ! is_array( $hook_extra ) ) {
565 + return;
566 + }
567 + if ( ! isset( $hook_extra['type'], $hook_extra['action'] ) ) {
568 + return;
569 + }
570 + if ( 'plugin' !== $hook_extra['type'] || 'update' !== $hook_extra['action'] ) {
571 + return;
572 + }
573 +
574 + // Single update uses 'plugin'; bulk uses 'plugins'.
575 + $updated = array();
576 + if ( isset( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] ) ) {
577 + $updated = $hook_extra['plugins'];
578 + } elseif ( isset( $hook_extra['plugin'] ) && is_string( $hook_extra['plugin'] ) ) {
579 + $updated = array( $hook_extra['plugin'] );
580 + }
581 +
582 + $ours = plugin_basename( XSPEED_FILE );
583 + if ( ! in_array( $ours, $updated, true ) ) {
584 + return;
585 + }
586 +
587 + Cache::restore_dropin_if_enabled();
588 + }
589 +
590 + /**
591 + * @param bool $network_wide Whether a network admin deactivated the
592 + * plugin for every site (WordPress passes this
593 + * to deactivation hooks).
594 + */
595 + public static function deactivate( $network_wide = false ) {
185 596 // Drop-in + WP_CACHE constant are NOT touched here. WordPress
186 597 // upgrades run as deactivate → wipe files → install → activate,
187 598 // so removing those artifacts on every deactivate would silently
188 599 // disable caching after each plugin update. uninstall.php
@@ -189,9 +600,16 @@
189 600 // handles full teardown when the user actually removes the
190 601 // plugin; auto_heal() restores state on the next admin_init if
191 602 // the drop-in or WP_CACHE went missing for any other reason.
192 603 Cache::purge_all();
193 - Minifier::purge_minified();
604 + // purge_all() is site-scoped and no longer touches min/, so clear it
605 + // here. On a network where only this site is deactivating, the other
606 + // sites still link those files: drop this site's manifests only.
607 + if ( is_multisite() && ! $network_wide ) {
608 + Minifier::purge_manifests( Asset_Manifest::blog_id() );
609 + } else {
610 + Minifier::purge_minified();
611 + }
194 612 Gzip::apply( false );
195 613
196 614 Module_Registry::deactivate_all();
197 615 }