PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 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 All 36 releases
← All changes | includes/class-scan.php +148 -6 1.2.4 → 1.4.2 View file →
@@ -102,8 +102,22 @@
102 102 return (bool) apply_filters( 'xspeed_scan_private_supported', true );
103 103 }
104 104
105 105 /**
106 + * Which Lighthouse runs a scan may spend. `both` is the engine's default:
107 + * the desktop run graded as the headline, the mobile run graded alongside
108 + * it. A single device costs half the engine's quota and becomes the
109 + * headline. Anything else is sent as `both`.
110 + */
111 + const STRATEGIES = array( 'both', 'desktop', 'mobile' );
112 +
113 + /** Coerce a caller's strategy to one the engine knows. */
114 + public static function strategy( string $value ): string {
115 + $value = strtolower( trim( $value ) );
116 + return in_array( $value, self::STRATEGIES, true ) ? $value : 'both';
117 + }
118 +
119 + /**
106 120 * Start a scan. Returns the scan id to poll, or a WP_Error.
107 121 *
108 122 * `$fresh` forces a new run; without it the engine may hand back a
109 123 * recent cached report for the same URL, which is what you want for a
@@ -108,11 +122,16 @@
108 122 * `$fresh` forces a new run; without it the engine may hand back a
109 123 * recent cached report for the same URL, which is what you want for a
110 124 * first look and not what you want after a change.
111 125 *
126 + * `$strategy` picks the Lighthouse runs (see STRATEGIES). The engine
127 + * grades the desktop run as the headline and the mobile run beside it,
128 + * so `both` is right for a report a person reads; a site that only
129 + * wants the run Google ranks on asks for `mobile` and spends half.
130 + *
112 131 * @return array{scan_id:string,report_url:string,cached:bool}|\WP_Error
113 132 */
114 - public static function start( string $url = '', bool $fresh = false ) {
133 + public static function start( string $url = '', bool $fresh = false, string $strategy = 'both' ) {
115 134 $url = '' !== $url ? $url : home_url( '/' );
116 135
117 136 // The engine probes this URL from the outside, so a host it cannot
118 137 // reach produces a confusing failure deep in the scan rather than
@@ -145,10 +164,11 @@
145 164 'timeout' => 20,
146 165 'headers' => array( 'Content-Type' => 'application/json' ),
147 166 'body' => wp_json_encode(
148 167 array(
149 - 'url' => $url,
150 - 'fresh' => $fresh,
168 + 'url' => $url,
169 + 'fresh' => $fresh,
170 + 'strategy' => self::strategy( $strategy ),
151 171 // Sent ahead of engine support: an unknown field is
152 172 // ignored today and becomes meaningful the moment
153 173 // the flag lands, with no plugin release needed.
154 174 'visibility' => 'private' === self::visibility() ? 'unlisted' : 'listed',
@@ -253,9 +273,9 @@
253 273 );
254 274 }
255 275
256 276 $result = self::normalise( $body, self::visibility() );
257 - update_option( self::RESULT_OPTION, $result, false );
277 + self::store( $result );
258 278 delete_option( self::PENDING_OPTION );
259 279
260 280 return $result;
261 281 }
@@ -310,14 +330,123 @@
310 330 }
311 331 return $p;
312 332 }
313 333
334 + /**
335 + * Save the last completed report, as JSON.
336 + *
337 + * A PHP-serialized array did not survive a `latin1` options table (#468).
338 + * MySQL turned a multibyte character from an engine check name (`≤`)
339 + * into `?`, the byte counts inside the serialized string no longer
340 + * matched, unserialize() failed, and the dashboard said "Never scanned"
341 + * after the next reload. wp_json_encode() escapes every non-ASCII
342 + * character as `\uXXXX`, so the stored bytes are plain ASCII and no
343 + * table charset can change them.
344 + *
345 + * The array is stored as before only if encoding fails, which would need
346 + * a report the JSON encoder cannot represent at all.
347 + *
348 + * @param array<string,mixed> $result A normalised report.
349 + */
350 + private static function store( array $result ): void {
351 + $json = wp_json_encode( $result );
352 + update_option( self::RESULT_OPTION, false === $json ? $result : $json, false );
353 + self::$stored_state = 'ok';
354 + }
355 +
356 + /**
357 + * What latest() found the last time it read the option: 'none' (never
358 + * scanned), 'ok', or 'unreadable' (a report was stored but could not be
359 + * read back, and was discarded).
360 + *
361 + * @var string
362 + */
363 + private static $stored_state = 'none';
364 +
314 365 /** The last completed report, or null. */
315 366 public static function latest(): ?array {
316 - $r = get_option( self::RESULT_OPTION );
317 - return is_array( $r ) && isset( $r['score'] ) ? $r : null;
367 + // A sentinel default, because get_option() returns false both for
368 + // a missing row and for a stored serialized value that no longer
369 + // unserializes. Only the second one is a lost report.
370 + $r = get_option( self::RESULT_OPTION, '__none__' );
371 +
372 + if ( '__none__' === $r ) {
373 + self::$stored_state = 'none';
374 + return null;
375 + }
376 +
377 + // A report stored before #468, as a serialized array.
378 + if ( is_array( $r ) ) {
379 + self::$stored_state = isset( $r['score'] ) ? 'ok' : 'none';
380 + return isset( $r['score'] ) ? self::with_devices( $r ) : null;
381 + }
382 +
383 + if ( is_string( $r ) ) {
384 + $decoded = json_decode( $r, true );
385 + if ( is_array( $decoded ) && isset( $decoded['score'] ) ) {
386 + self::$stored_state = 'ok';
387 + return self::with_devices( $decoded );
388 + }
389 + if ( is_array( $decoded ) && ! empty( $decoded['unreadable'] ) ) {
390 + self::$stored_state = 'unreadable';
391 + return null;
392 + }
393 + // Read back fine, but the scan graded nothing: treated like a
394 + // stored array without a score, not as a lost report.
395 + if ( is_array( $decoded ) ) {
396 + self::$stored_state = 'none';
397 + return null;
398 + }
399 + }
400 +
401 + // Stored, but not readable: a serialized array mangled by the table
402 + // charset (get_option() gives false), or a string that is not a
403 + // report. Replace it with a small marker, which also replaces the
404 + // mangled copy in a persistent object cache. The dashboard then says
405 + // the last report was lost, until the next scan overwrites it.
406 + update_option( self::RESULT_OPTION, (string) wp_json_encode( array( 'unreadable' => true ) ), false );
407 + self::$stored_state = 'unreadable';
408 + return null;
318 409 }
319 410
411 + /**
412 + * The state latest() found. Call latest() first.
413 + *
414 + * @return string 'none' | 'ok' | 'unreadable'
415 + */
416 + public static function stored_state(): string {
417 + return self::$stored_state;
418 + }
419 +
420 + /**
421 + * Give a stored record the per-device Lighthouse shape every reader now
422 + * expects, whenever it was written.
423 + *
424 + * A record written BEFORE the engine graded desktop as the headline has
425 + * no `device` and its `lighthouse` is the mobile run — that was the only
426 + * run PSI made by default. A record written after has `device` set and
427 + * carries both devices explicitly. So `device` is what decides how to
428 + * read `lighthouse`, and nothing else has to know the difference.
429 + *
430 + * Deliberately does NOT invent a missing device: a desktop-only scan
431 + * never measured mobile, and showing the desktop number under a mobile
432 + * label is exactly what this replaced.
433 + */
434 + private static function with_devices( array $r ): array {
435 + $m = isset( $r['measured'] ) && is_array( $r['measured'] ) ? $r['measured'] : array();
436 + if ( ! array_key_exists( 'lighthouse_mobile', $m ) ) {
437 + $m['lighthouse_mobile'] = '' === (string) ( $r['device'] ?? '' )
438 + ? ( $m['lighthouse'] ?? null ) // pre-change record: it was mobile
439 + : null; // post-change: mobile simply wasn't run
440 + }
441 + if ( ! array_key_exists( 'lighthouse_desktop', $m ) ) {
442 + $m['lighthouse_desktop'] = null;
443 + }
444 + $r['measured'] = $m;
445 + $r['device'] = (string) ( $r['device'] ?? '' );
446 + return $r;
447 + }
448 +
320 449 public static function report_url( string $scan_id ): string {
321 450 return self::base() . '/scan/r/' . $scan_id;
322 451 }
323 452
@@ -393,12 +522,25 @@
393 522 // A partial scan graded less than the full rubric; saying so is
394 523 // the difference between a low score and an incomplete one.
395 524 'partial' => ! empty( $body['partial'] ),
396 525 'dimensions' => $dims,
526 + // Which device the headline grade is for: `desktop` (the default
527 + // `both` run grades desktop as the headline), `mobile`, or ''
528 + // from an engine that predates the strategy option.
529 + 'device' => isset( $body['device'] ) ? (string) $body['device'] : '',
397 530 'measured' => array(
398 531 // Lighthouse is reported alongside, never AS, the score.
532 + //
533 + // `lighthouse` is the GRADED run's score, and which device
534 + // that is changed when the engine started grading desktop as
535 + // the headline. Read the two per-device fields instead; this
536 + // one stays only so a stored record keeps its shape, and
537 + // `latest()` back-fills it for records written before the
538 + // change. Reading it AS the mobile score is the bug this
539 + // comment exists to prevent (it printed desktop as mobile).
399 540 'lighthouse' => self::num( $measured['lighthouse'] ?? null ),
400 541 'lighthouse_desktop' => self::num( $measured['lighthouseDesktop'] ?? null ),
542 + 'lighthouse_mobile' => self::num( $measured['lighthouseMobile'] ?? null ),
401 543 'ttfb_ms' => self::num( $measured['ttfbMs'] ?? null ),
402 544 'lcp_ms' => self::num( $measured['lcpMs'] ?? null ),
403 545 'cls' => self::num( $measured['cls'] ?? null ),
404 546 'tbt_ms' => self::num( $measured['tbtMs'] ?? null ),