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-resource-hints-processor.php +143 -25 1.3.6 → 1.3.7 View file →
@@ -148,26 +148,37 @@
148 148 return $out;
149 149 }
150 150
151 151 /**
152 - * Strip core `loading="lazy"` and add `fetchpriority="high"` +
153 - * `decoding="async"` on every <img> whose tag matches one of the given
154 - * exclusion substrings. Mirrors what Lazy_Loader does for an excluded image
155 - * inside the_content, but page-wide so heroes outside it are covered too.
156 - * (FBS-83553 H2)
152 + * Strip core `loading="lazy"` and add `decoding="async"` on every <img>
153 + * whose tag matches one of the given exclusion substrings. Mirrors what
154 + * Lazy_Loader does for an excluded image inside the_content, but page-wide
155 + * so heroes outside it are covered too. (FBS-83553 H2)
157 156 *
157 + * Only the first visible match outside <footer>/<nav>/<aside> gets
158 + * `fetchpriority="high"`, and only when no image in the page holds it yet.
159 + * Every match used to get it, so a pattern naming the header and footer
160 + * logo, or a hero in a closed accordion, put several images at High. (#558)
161 + *
158 162 * @param string $html Full page HTML.
159 163 * @param string[] $exclusions Substring patterns identifying above-the-fold heroes.
160 164 */
161 165 private static function promote_excluded_images( string $html, array $exclusions ): string {
162 - return (string) preg_replace_callback(
163 - '#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i',
164 - static function ( array $m ) use ( $exclusions ) {
165 - $tag = $m[0];
166 + $img_re = '#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i';
167 + $claimed = (bool) preg_match( '#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*?(?<![-\w])fetchpriority\s*=\s*["\']?high\b#i', $html );
168 + $skip = $claimed ? array() : array_merge( self::chrome_container_ranges( $html ), Lazy_Loader::hidden_ranges( $html ) );
169 +
170 + $result = preg_replace_callback(
171 + $img_re,
172 + static function ( array $m ) use ( $exclusions, &$claimed, $skip ) {
173 + [ $tag, $offset ] = $m[0];
166 174 foreach ( $exclusions as $needle ) {
167 175 if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
168 176 $tag = (string) preg_replace( '#\s*\bloading=(["\'])\s*lazy\s*\1#i', '', $tag );
169 - $tag = self::set_fetchpriority( $tag );
177 + if ( ! $claimed && ! self::offset_in_ranges( (int) $offset, $skip ) && ! Lazy_Loader::tag_is_hidden( $tag, 'img' ) ) {
178 + $tag = self::set_fetchpriority( $tag );
179 + $claimed = true;
180 + }
170 181 if ( ! preg_match( '#\bdecoding=#i', $tag ) ) {
171 182 $tag = (string) preg_replace( '#<img\b#i', '<img decoding="async"', $tag, 1 );
172 183 }
173 184 return $tag;
@@ -174,10 +185,15 @@
174 185 }
175 186 }
176 187 return $tag;
177 188 },
178 - $html
189 + $html,
190 + -1,
191 + $count,
192 + PREG_OFFSET_CAPTURE
179 193 );
194 +
195 + return is_string( $result ) ? $result : $html;
180 196 }
181 197
182 198 /**
183 199 * Build preconnect <link>s for detected font hosts + user hosts.
@@ -256,22 +272,21 @@
256 272 // one that matters, and the feature reported success either way. The
257 273 // marker list and size gate were heuristics layered on top of the
258 274 // wrong primitive rather than replacing it. (#96)
259 275 $candidates = array();
260 - $skip_ranges = self::chrome_container_ranges( $html );
276 + // Markup hidden on arrival (a closed <details>, `hidden`, inline
277 + // display:none) cannot paint as LCP either. Preloading the hero of a
278 + // closed accordion spent the page's one High fetch on it. (#558)
279 + $skip_ranges = array_merge( self::chrome_container_ranges( $html ), Lazy_Loader::hidden_ranges( $html ) );
280 + // <img> indexes that can't be the LCP however they are marked: chrome,
281 + // hidden, or a logo/icon. Only these give up a stray `high`. (#558)
282 + $ruled_out = array();
261 283 if ( preg_match_all( '#<img\b[^>]*>#i', $html, $matches, PREG_OFFSET_CAPTURE ) ) {
262 284 foreach ( $matches[0] as $index => $match ) {
263 285 [ $tag, $offset ] = $match;
264 286
265 287 // Skip anything the user excluded.
266 - $excluded = false;
267 - foreach ( $exclusions as $needle ) {
268 - if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
269 - $excluded = true;
270 - break;
271 - }
272 - }
273 - if ( $excluded ) {
288 + if ( self::matches_any( $tag, $exclusions ) ) {
274 289 continue;
275 290 }
276 291
277 292 // An image inside <footer>/<nav>/<aside> is site chrome by
@@ -278,9 +293,10 @@
278 293 // construction — a footer brand strip or FAQ illustration can
279 294 // never be the LCP element, whatever size it declares. On the
280 295 // FBS-84576 repro these decoys outranked the real hero three
281 296 // times on one layout.
282 - if ( self::offset_in_ranges( $offset, $skip_ranges ) ) {
297 + if ( self::offset_in_ranges( $offset, $skip_ranges ) || Lazy_Loader::tag_is_hidden( $tag, 'img' ) ) {
298 + $ruled_out[ $index ] = true;
283 299 continue;
284 300 }
285 301
286 302 // An image the theme explicitly lazy-loads is never the
@@ -302,8 +318,9 @@
302 318
303 319 // Chrome markers / explicit opt-out / obviously-tiny images
304 320 // never compete. (FBS-83553 H1 "logo before hero".)
305 321 if ( self::looks_too_small( $tag ) ) {
322 + $ruled_out[ $index ] = true;
306 323 continue;
307 324 }
308 325
309 326 $candidates[] = array(
@@ -312,8 +329,9 @@
312 329 'srcset' => $srcset,
313 330 'sizes' => $sizes,
314 331 'score' => self::weighted_score( self::lcp_score( $tag, $srcset ), $tag, $index ),
315 332 'order' => $index,
333 + 'offset' => $offset,
316 334 );
317 335 }
318 336 }
319 337
@@ -346,9 +364,29 @@
346 364 foreach ( self::style_block_candidates( $html, $exclusions, $skip_ranges ) as $sb ) {
347 365 $candidates[] = $sb;
348 366 }
349 367
368 + // A background video with no poster, ahead of every candidate, is the
369 + // hero. Nothing in its box is preloadable, and every image after it
370 + // sits lower on the page. Preloading the first of those spent the one
371 + // high-priority fetch on an image below the fold, ahead of the CSS.
372 + // Those images also give up a stray `high` from the lazy pass.
373 + $video_at = self::background_video_offset( $html, $skip_ranges );
374 + if ( null !== $video_at ) {
375 + foreach ( $candidates as $i => $c ) {
376 + if ( $c['offset'] > $video_at ) {
377 + if ( empty( $c['background'] ) ) {
378 + $ruled_out[ $c['order'] ] = true;
379 + }
380 + unset( $candidates[ $i ] );
381 + }
382 + }
383 + }
384 +
350 385 if ( empty( $candidates ) ) {
386 + if ( null !== $video_at ) {
387 + $html = self::demote_images( $html, $ruled_out );
388 + }
351 389 return array( $html, '' );
352 390 }
353 391
354 392 // Rank by score, biggest first. Document order breaks ties, so two
@@ -392,14 +430,26 @@
392 430 // Rewrite only the winning tags. Counting occurrences rather than
393 431 // matching on tag text, because the same markup can legitimately
394 432 // appear more than once on a page and only the ranked instance should
395 433 // be promoted.
396 - $seen = -1;
397 - $html = preg_replace_callback(
434 + //
435 + // When an <img> wins, it is the page's one High image, so an image
436 + // that can't be the LCP (a logo, an icon, a hidden panel, chrome)
437 + // gives up any `high` it carries. That is usually the lazy pass handing
438 + // its slot to the first image in the_content. An image that merely scored
439 + // lower, or that the user kept out of the pick, keeps its hint: the
440 + // ranking can be wrong, and core or the theme may have named the real
441 + // hero. (#558)
442 + $demote = ! empty( $chosen ) || null !== $video_at ? $ruled_out : array();
443 + $seen = -1;
444 + $html = preg_replace_callback(
398 445 '#<img\b[^>]*>#i',
399 - static function ( array $m ) use ( &$seen, $chosen ) {
446 + static function ( array $m ) use ( &$seen, $chosen, $demote ) {
400 447 ++$seen;
401 448 if ( ! isset( $chosen[ $seen ] ) ) {
449 + if ( isset( $demote[ $seen ] ) && 'high' === strtolower( self::attr( $m[0], 'fetchpriority' ) ) ) {
450 + return (string) preg_replace( '#\s*(?<![-\w])fetchpriority\s*=\s*(["\']?)high\1#i', '', $m[0], 1 );
451 + }
402 452 return $m[0];
403 453 }
404 454 // Add fetchpriority="high" AND remove any loading="lazy" the
405 455 // theme / WP core left on the LCP image. fetchpriority="high"
@@ -414,8 +464,56 @@
414 464 return array( (string) $html, $preload );
415 465 }
416 466
417 467 /**
468 + * Byte offset of the first visible background video with no poster, or
469 + * null when the page has none.
470 + *
471 + * @param string $html Full page HTML.
472 + * @param array<int,array{0:int,1:int}> $skip_ranges Chrome and hidden spans.
473 + */
474 + private static function background_video_offset( string $html, array $skip_ranges ): ?int {
475 + $body = stripos( $html, '<body' );
476 + if ( ! preg_match_all( '#<video\b[^>]*>#i', $html, $m, PREG_OFFSET_CAPTURE, false === $body ? 0 : $body ) ) {
477 + return null;
478 + }
479 + foreach ( $m[0] as [ $tag, $offset ] ) {
480 + if ( self::offset_in_ranges( $offset, $skip_ranges ) || Lazy_Loader::tag_is_hidden( $tag, 'video' ) ) {
481 + continue;
482 + }
483 + if ( Lazy_Loader::is_background_video_without_poster( $tag ) ) {
484 + return $offset;
485 + }
486 + }
487 + return null;
488 + }
489 +
490 + /**
491 + * Strip fetchpriority="high" from the <img> tags at the given indexes.
492 + *
493 + * @param string $html Page HTML.
494 + * @param array<int,bool> $indexes <img> indexes, in document order.
495 + */
496 + private static function demote_images( string $html, array $indexes ): string {
497 + if ( empty( $indexes ) ) {
498 + return $html;
499 + }
500 + $seen = -1;
501 + $out = preg_replace_callback(
502 + '#<img\b[^>]*>#i',
503 + static function ( array $m ) use ( &$seen, $indexes ) {
504 + ++$seen;
505 + if ( isset( $indexes[ $seen ] ) && 'high' === strtolower( self::attr( $m[0], 'fetchpriority' ) ) ) {
506 + return (string) preg_replace( '#\s*(?<![-\w])fetchpriority\s*=\s*(["\']?)high\1#i', '', $m[0], 1 );
507 + }
508 + return $m[0];
509 + },
510 + $html
511 + );
512 + return null === $out ? $html : $out;
513 + }
514 +
515 + /**
418 516 * Collect CSS `background-image` heroes as LCP candidates.
419 517 *
420 518 * Only INLINE `style` attributes are read. A background declared in an
421 519 * external stylesheet is invisible here by design: resolving it would mean
@@ -487,8 +585,9 @@
487 585 'score' => (float) $area,
488 586 // Offset so a background never ties ahead of an <img> that
489 587 // appeared earlier in the document; ties still break on order.
490 588 'order' => 100000 + $index,
589 + 'offset' => $offset,
491 590 'background' => true,
492 591 );
493 592 }
494 593
@@ -544,8 +643,9 @@
544 643 'srcset' => '',
545 644 'sizes' => '',
546 645 'score' => (float) $area,
547 646 'order' => 100000 + $index,
647 + 'offset' => $offset,
548 648 'background' => true,
549 649 );
550 650 }
551 651
@@ -648,8 +748,9 @@
648 748 // Offset past the inline-style backgrounds: a rule-matched
649 749 // background is one inference step less certain, so it must
650 750 // never tie ahead of one read straight off the element.
651 751 'order' => 200000 + $offset,
752 + 'offset' => $offset,
652 753 'background' => true,
653 754 );
654 755 }
655 756 }
@@ -926,12 +1027,29 @@
926 1027 }
927 1028 return $ranges;
928 1029 }
929 1030
1031 + /**
1032 + * Does a tag contain any of the given substring patterns?
1033 + *
1034 + * @param string[] $patterns
1035 + */
1036 + private static function matches_any( string $tag, array $patterns ): bool {
1037 + foreach ( $patterns as $needle ) {
1038 + if ( '' !== $needle && false !== stripos( $tag, $needle ) ) {
1039 + return true;
1040 + }
1041 + }
1042 + return false;
1043 + }
1044 +
930 1045 /** Does a byte offset fall inside any of the given [start, end] ranges? */
931 1046 private static function offset_in_ranges( int $offset, array $ranges ): bool {
932 1047 foreach ( $ranges as $range ) {
933 - if ( $offset > $range[0] && $offset < $range[1] ) {
1048 + // >= on the start: a closed <details> span from
1049 + // Lazy_Loader::hidden_ranges() opens right at the first image
1050 + // after its <summary>.
1051 + if ( $offset >= $range[0] && $offset < $range[1] ) {
934 1052 return true;
935 1053 }
936 1054 }
937 1055 return false;