| @@ -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 ), |