| @@ -34,8 +34,38 @@ | ||
| 34 | 34 | */ |
| 35 | 35 | private static $image_counter = 0; |
| 36 | 36 | |
| 37 | 37 | /** |
| 38 | + * Whether an image on this page already holds fetchpriority="high". | |
| 39 | + * | |
| 40 | + * One per page. Every eager image used to get it, so with eager_first_n | |
| 41 | + * at 3 an accordion's hidden images fetched at High alongside the | |
| 42 | + * render-blocking CSS and the real LCP image. (#558) | |
| 43 | + * | |
| 44 | + * @var bool | |
| 45 | + */ | |
| 46 | + private static $priority_claimed = false; | |
| 47 | + | |
| 48 | + /** | |
| 49 | + * Byte spans of markup that is hidden on arrival in the chunk the image | |
| 50 | + * pass is walking, and whether the current tag sits in one. | |
| 51 | + * | |
| 52 | + * @var array<int, array{0:int, 1:int}> | |
| 53 | + */ | |
| 54 | + private static $hidden_ranges = array(); | |
| 55 | + | |
| 56 | + /** @var bool */ | |
| 57 | + private static $in_hidden = false; | |
| 58 | + | |
| 59 | + /** | |
| 60 | + * Whether a visible excluded image is still to come, so the eager budget | |
| 61 | + * must not take the high slot first. | |
| 62 | + * | |
| 63 | + * @var bool | |
| 64 | + */ | |
| 65 | + private static $priority_reserved = false; | |
| 66 | + | |
| 67 | + /** | |
| 38 | 68 | * Eager budget for inline-style backgrounds. Separate from images: a |
| 39 | 69 | * hero is either an <img> or a background, and the passes run one after |
| 40 | 70 | * the other, so one shared counter would hand the budget to whichever |
| 41 | 71 | * pass runs first rather than to what sits first on the page. |
| @@ -102,9 +132,9 @@ | ||
| 102 | 132 | return '#<' . $name . '\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i'; |
| 103 | 133 | }; |
| 104 | 134 | |
| 105 | 135 | if ( ! empty( $opts['lazy_images'] ) || ! empty( $opts['add_missing_dimensions'] ) ) { |
| 106 | - $work = self::apply_pass( $work, $tag_re( 'img' ), array( __CLASS__, 'rewrite_img' ) ); | |
| 136 | + $work = self::apply_img_pass( $work, $tag_re( 'img' ), $opts ); | |
| 107 | 137 | } |
| 108 | 138 | if ( ! empty( $opts['lazy_iframes'] ) ) { |
| 109 | 139 | $work = self::apply_pass( $work, $tag_re( 'iframe' ), array( __CLASS__, 'rewrite_iframe' ) ); |
| 110 | 140 | } |
| @@ -282,8 +312,149 @@ | ||
| 282 | 312 | return null; |
| 283 | 313 | } |
| 284 | 314 | |
| 285 | 315 | /** |
| 316 | + * The <img> pass, which also knows which tags sit in hidden markup. | |
| 317 | + * | |
| 318 | + * Hidden spans are only worked out while eager slots remain: past the | |
| 319 | + * budget every image is lazy anyway, and the scan costs a pass per | |
| 320 | + * hidden container. | |
| 321 | + * | |
| 322 | + * @param array<string,mixed> $opts Lazy settings. | |
| 323 | + */ | |
| 324 | + private static function apply_img_pass( string $html, string $pattern, array $opts ): string { | |
| 325 | + // Core picks one content image for fetchpriority="high" at priority | |
| 326 | + // 12, before this pass. That pick is the page's, so ours stands down. | |
| 327 | + if ( ! self::$priority_claimed && preg_match( '#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*?(?<![-\w])fetchpriority\s*=\s*["\']?high\b#i', $html ) ) { | |
| 328 | + self::$priority_claimed = true; | |
| 329 | + } | |
| 330 | + | |
| 331 | + $budget = max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) ); | |
| 332 | + self::$hidden_ranges = ( ! empty( $opts['lazy_images'] ) && ( self::$image_counter < $budget || ! empty( $opts['excluded_images'] ) ) ) | |
| 333 | + ? self::hidden_ranges( $html ) | |
| 334 | + : array(); | |
| 335 | + | |
| 336 | + // An image the user named in the exclusions is the hero they chose. | |
| 337 | + // When the chunk holds a visible one, the eager budget leaves the high | |
| 338 | + // slot to it rather than to an icon printed ahead of it. | |
| 339 | + if ( ! self::$priority_claimed && ! self::$priority_reserved && ! empty( $opts['lazy_images'] ) && ! empty( $opts['excluded_images'] ) | |
| 340 | + && preg_match_all( '#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i', $html, $imgs, PREG_OFFSET_CAPTURE ) ) { | |
| 341 | + foreach ( $imgs[0] as $img ) { | |
| 342 | + if ( ! self::is_excluded( (string) $img[0], $opts ) || self::tag_is_hidden( (string) $img[0], 'img' ) || self::offset_hidden( (int) $img[1] ) ) { | |
| 343 | + continue; | |
| 344 | + } | |
| 345 | + self::$priority_reserved = true; | |
| 346 | + break; | |
| 347 | + } | |
| 348 | + } | |
| 349 | + | |
| 350 | + if ( empty( self::$hidden_ranges ) ) { | |
| 351 | + return self::apply_pass( $html, $pattern, array( __CLASS__, 'rewrite_img' ) ); | |
| 352 | + } | |
| 353 | + | |
| 354 | + $ranges = self::$hidden_ranges; | |
| 355 | + $result = preg_replace_callback( | |
| 356 | + $pattern, | |
| 357 | + static function ( array $m ) use ( $ranges ): string { | |
| 358 | + $offset = (int) $m[0][1]; | |
| 359 | + self::$in_hidden = false; | |
| 360 | + foreach ( $ranges as $range ) { | |
| 361 | + if ( $offset >= $range[0] && $offset < $range[1] ) { | |
| 362 | + self::$in_hidden = true; | |
| 363 | + break; | |
| 364 | + } | |
| 365 | + } | |
| 366 | + $out = self::rewrite_img( array( (string) $m[0][0] ) ); | |
| 367 | + self::$in_hidden = false; | |
| 368 | + return $out; | |
| 369 | + }, | |
| 370 | + $html, | |
| 371 | + -1, | |
| 372 | + $count, | |
| 373 | + PREG_OFFSET_CAPTURE | |
| 374 | + ); | |
| 375 | + self::$hidden_ranges = array(); | |
| 376 | + | |
| 377 | + return is_string( $result ) ? $result : $html; | |
| 378 | + } | |
| 379 | + | |
| 380 | + /** Whether a byte offset in the chunk being walked sits in hidden markup. */ | |
| 381 | + private static function offset_hidden( int $offset ): bool { | |
| 382 | + foreach ( self::$hidden_ranges as $range ) { | |
| 383 | + if ( $offset >= $range[0] && $offset < $range[1] ) { | |
| 384 | + return true; | |
| 385 | + } | |
| 386 | + } | |
| 387 | + return false; | |
| 388 | + } | |
| 389 | + | |
| 390 | + /** | |
| 391 | + * Byte spans of elements the markup itself hides: a closed <details>, | |
| 392 | + * the `hidden` attribute, or an inline `display:none`. | |
| 393 | + * | |
| 394 | + * Only what the tag says. Panels a stylesheet or script hides (most | |
| 395 | + * accordion and tab blocks) look visible from here. | |
| 396 | + * | |
| 397 | + * Pure. Resource_Hints_Processor runs it over the whole page, so an | |
| 398 | + * image the lazy pass kept out of the high slot doesn't get it back | |
| 399 | + * from the page-wide pass. | |
| 400 | + * | |
| 401 | + * @return array<int, array{0:int, 1:int}> | |
| 402 | + */ | |
| 403 | + public static function hidden_ranges( string $html ): array { | |
| 404 | + if ( false === stripos( $html, '<details' ) && false === stripos( $html, 'hidden' ) && ! preg_match( '#display\s*:\s*none#i', $html ) ) { | |
| 405 | + return array(); | |
| 406 | + } | |
| 407 | + if ( ! preg_match_all( '#<([a-z][a-z0-9-]*)\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) { | |
| 408 | + return array(); | |
| 409 | + } | |
| 410 | + | |
| 411 | + $ranges = array(); | |
| 412 | + $until = -1; | |
| 413 | + foreach ( $m[0] as $i => $hit ) { | |
| 414 | + $start = (int) $hit[1]; | |
| 415 | + if ( $start < $until ) { | |
| 416 | + continue; // Inside a span already found. | |
| 417 | + } | |
| 418 | + $tag = (string) $hit[0]; | |
| 419 | + $name = strtolower( (string) $m[1][ $i ][0] ); | |
| 420 | + if ( 'img' === $name || ! self::tag_is_hidden( $tag, $name ) ) { | |
| 421 | + continue; | |
| 422 | + } | |
| 423 | + $end = self::same_tag_end( $html, $start, $name ); | |
| 424 | + if ( null === $end ) { | |
| 425 | + continue; | |
| 426 | + } | |
| 427 | + $until = $end; | |
| 428 | + // A closed <details> still shows its own <summary>, the child | |
| 429 | + // that opens it. A nested <details>' summary further in is not it. | |
| 430 | + if ( 'details' === $name ) { | |
| 431 | + $inner = $start + strlen( $tag ); | |
| 432 | + if ( preg_match( '#^\s*<summary\b.*?</summary\s*>#is', substr( $html, $inner, $end - $inner ), $sm ) ) { | |
| 433 | + $start = $inner + strlen( (string) $sm[0] ); | |
| 434 | + } | |
| 435 | + } | |
| 436 | + $ranges[] = array( $start, $end ); | |
| 437 | + } | |
| 438 | + return $ranges; | |
| 439 | + } | |
| 440 | + | |
| 441 | + /** Pure: whether one opening tag hides its contents. */ | |
| 442 | + public static function tag_is_hidden( string $tag, string $name ): bool { | |
| 443 | + // Attribute names only. Blanking the quoted values first keeps | |
| 444 | + // class="is-hidden" and aria-hidden="true" from reading as `hidden`. | |
| 445 | + $names = (string) preg_replace( '#=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)#', '', $tag ); | |
| 446 | + if ( 'details' === $name ) { | |
| 447 | + return ! preg_match( '#(?<![-\w])open(?![-\w])#i', $names ); | |
| 448 | + } | |
| 449 | + if ( preg_match( '#\shidden(?![-\w])#i', $names ) ) { | |
| 450 | + return true; | |
| 451 | + } | |
| 452 | + return preg_match( '#(?<![-\w])style\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', $tag, $style ) | |
| 453 | + && preg_match( '#(?<![-\w])display\s*:\s*none#i', $style[1] ); | |
| 454 | + } | |
| 455 | + | |
| 456 | + /** | |
| 286 | 457 | * Run one rewrite pass, keeping the input if PCRE bails. |
| 287 | 458 | * |
| 288 | 459 | * preg_replace_callback() returns null when it hits the backtrack or |
| 289 | 460 | * recursion limit — on a large page that would otherwise blank the |
| @@ -323,8 +494,10 @@ | ||
| 323 | 494 | || false !== stripos( $tag, 'data-no-lazy' ) |
| 324 | 495 | || self::has_high_fetchpriority( $tag ) |
| 325 | 496 | || self::is_excluded( $tag, $opts ); |
| 326 | 497 | |
| 498 | + $self_hidden = self::tag_is_hidden( $tag, 'img' ); | |
| 499 | + | |
| 327 | 500 | if ( $skip_lazy && ! empty( $opts['lazy_images'] ) ) { |
| 328 | 501 | // An EXCLUDED image is one the user marked as above-the-fold (a |
| 329 | 502 | // hero/logo) — the opposite of lazy. WordPress core adds |
| 330 | 503 | // `loading="lazy"` to images by default (since 5.5), so merely |
| @@ -331,24 +504,46 @@ | ||
| 331 | 504 | // *skipping* our lazy pass would leave core's lazy attribute on |
| 332 | 505 | // the LCP hero and tank LCP. Actively make it eager + |
| 333 | 506 | // high-priority so an excluded hero loads immediately. |
| 334 | 507 | $tag = self::set_attr( $tag, 'loading', 'eager' ); |
| 335 | - $tag = self::set_attr( $tag, 'fetchpriority', 'high', true ); | |
| 508 | + // Still one high image per page: every excluded logo and | |
| 509 | + // data-skip-lazy icon used to get it too. A data-skip-lazy icon | |
| 510 | + // printed ahead of a reserved excluded hero leaves the slot to | |
| 511 | + // the hero, as the eager budget does. (#558) | |
| 512 | + $may_take = ! self::$priority_reserved || self::is_excluded( $tag, $opts ); | |
| 513 | + if ( ! self::$priority_claimed && ! self::$in_hidden && ! $self_hidden && $may_take ) { | |
| 514 | + $tag = self::set_attr( $tag, 'fetchpriority', 'high', true ); | |
| 515 | + } | |
| 516 | + if ( self::has_high_fetchpriority( $tag ) ) { | |
| 517 | + self::$priority_claimed = true; | |
| 518 | + } | |
| 336 | 519 | $tag = self::set_attr( $tag, 'decoding', 'async', true ); |
| 520 | + } elseif ( ! empty( $opts['lazy_images'] ) && $self_hidden ) { | |
| 521 | + // A display:none image (a tracking pixel, most often) is not above | |
| 522 | + // the fold, so it must not take the hero's eager slot. Its loading | |
| 523 | + // is left alone: a lazy display:none image never loads, and the | |
| 524 | + // pixel would stop counting. (#558) | |
| 525 | + $tag = self::set_attr( $tag, 'decoding', 'async', true ); | |
| 337 | 526 | } elseif ( ! empty( $opts['lazy_images'] ) ) { |
| 338 | 527 | // Above-the-fold skip: first N images get loading="eager" |
| 339 | 528 | // instead of "lazy" so the LCP image isn't deferred. Only |
| 340 | - // non-excluded images consume the budget. | |
| 341 | - self::$image_counter++; | |
| 342 | - $is_above_fold = self::$image_counter <= max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) ); | |
| 343 | - $tag = self::set_attr( $tag, 'loading', $is_above_fold ? 'eager' : 'lazy' ); | |
| 344 | - $tag = self::set_attr( $tag, 'decoding', 'async', true ); | |
| 345 | - // The eager hero should also drop any core `loading="lazy"`; the | |
| 346 | - // set_attr above already overrode it. Give the first eager image | |
| 347 | - // high fetch priority so it wins the LCP race. | |
| 348 | - if ( $is_above_fold ) { | |
| 349 | - $tag = self::set_attr( $tag, 'fetchpriority', 'high', true ); | |
| 529 | + // non-excluded images consume the budget, and an image in | |
| 530 | + // markup that is hidden on arrival is not above the fold. (#558) | |
| 531 | + $is_above_fold = false; | |
| 532 | + if ( ! self::$in_hidden ) { | |
| 533 | + self::$image_counter++; | |
| 534 | + $is_above_fold = self::$image_counter <= max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) ); | |
| 350 | 535 | } |
| 536 | + $tag = self::set_attr( $tag, 'loading', $is_above_fold ? 'eager' : 'lazy' ); | |
| 537 | + $tag = self::set_attr( $tag, 'decoding', 'async', true ); | |
| 538 | + // Only the first eager image gets high priority. The rest of the | |
| 539 | + // eager budget skips lazy loading and no more: more than one | |
| 540 | + // High image competes with the render-blocking CSS, and the LCP | |
| 541 | + // is rarely the third image in the document. (#558) | |
| 542 | + if ( $is_above_fold && ! self::$priority_claimed && ! self::$priority_reserved ) { | |
| 543 | + $tag = self::set_attr( $tag, 'fetchpriority', 'high', true ); | |
| 544 | + self::$priority_claimed = true; | |
| 545 | + } | |
| 351 | 546 | } |
| 352 | 547 | |
| 353 | 548 | if ( ! empty( $opts['add_missing_dimensions'] ) ) { |
| 354 | 549 | $tag = self::ensure_dimensions( $tag ); |
| @@ -646,8 +841,29 @@ | ||
| 646 | 841 | if ( false !== stripos( $tag, 'data-xspeed-src' ) ) { |
| 647 | 842 | return $tag; |
| 648 | 843 | } |
| 649 | 844 | |
| 845 | + // A background video waits for the visitor's first interaction, not | |
| 846 | + // just the viewport. It is decoration, and on a hero it is in the | |
| 847 | + // viewport at once, so the viewport rule loaded it immediately and its | |
| 848 | + // first frame became the LCP. Held back, the hero text is the LCP and | |
| 849 | + // the video starts on the first scroll, tap, key or mouse move. | |
| 850 | + // Checked before autoplay is renamed below. | |
| 851 | + if ( self::is_background_video_without_poster( $tag ) ) { | |
| 852 | + /** | |
| 853 | + * Whether a background video waits for the first interaction. | |
| 854 | + * | |
| 855 | + * Return false to load it when it reaches the viewport instead, | |
| 856 | + * like any other autoplay video. | |
| 857 | + * | |
| 858 | + * @param bool $wait Whether the video waits. Default true. | |
| 859 | + * @param string $tag The <video> opening tag. | |
| 860 | + */ | |
| 861 | + if ( (bool) apply_filters( 'xspeed_lazy_background_video_waits_for_interaction', true, $tag ) ) { | |
| 862 | + $tag = self::set_attr( $tag, 'data-xspeed-wait', 'interaction' ); | |
| 863 | + } | |
| 864 | + } | |
| 865 | + | |
| 650 | 866 | $deferred = false; |
| 651 | 867 | |
| 652 | 868 | // The element's own src, when it has one. |
| 653 | 869 | if ( preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#i', $tag, $m ) && '' !== trim( $m[2] ) ) { |
| @@ -682,8 +898,37 @@ | ||
| 682 | 898 | return $tag; |
| 683 | 899 | } |
| 684 | 900 | |
| 685 | 901 | /** |
| 902 | + * Whether a <video> opening tag is a decorative background with no poster. | |
| 903 | + * | |
| 904 | + * Autoplay, muted, looping and without controls is how every builder | |
| 905 | + * marks a background video: nobody watches it, it sits behind the hero | |
| 906 | + * text. With no poster, nothing paints in its box until the first frame | |
| 907 | + * decodes, so on a hero the video's first frame becomes the page's LCP. | |
| 908 | + * On the measured site that was a 5 MB MP4 and a 3.5–3.9 s mobile LCP, | |
| 909 | + * against 2.3 s with the video out of the way. | |
| 910 | + * | |
| 911 | + * Reads autoplay in both spellings: the author's `autoplay`, and the | |
| 912 | + * `data-xspeed-autoplay` the lazy pass leaves when it defers the source. | |
| 913 | + * Resource Hints sees the tag after that pass has run. | |
| 914 | + * | |
| 915 | + * @param string $tag A <video> opening tag. | |
| 916 | + */ | |
| 917 | + public static function is_background_video_without_poster( string $tag ): bool { | |
| 918 | + $has = static function ( string $name ) use ( $tag ): bool { | |
| 919 | + return (bool) preg_match( '#\s' . $name . '(?=[\s/>=])#i', $tag ); | |
| 920 | + }; | |
| 921 | + if ( ! $has( 'autoplay' ) && ! $has( 'data-xspeed-autoplay' ) ) { | |
| 922 | + return false; | |
| 923 | + } | |
| 924 | + if ( ! $has( 'muted' ) || ! $has( 'loop' ) || $has( 'controls' ) ) { | |
| 925 | + return false; | |
| 926 | + } | |
| 927 | + return ! preg_match( '#\sposter\s*=\s*(?:["\']\s*)?[^"\'\s>]#i', $tag ); | |
| 928 | + } | |
| 929 | + | |
| 930 | + /** | |
| 686 | 931 | * Did this response defer at least one autoplay video? Gates the script |
| 687 | 932 | * so a page with no such video ships no extra bytes. |
| 688 | 933 | * |
| 689 | 934 | * @var bool |
| @@ -846,15 +1091,27 @@ | ||
| 846 | 1091 | v.setAttribute('preload','none'); |
| 847 | 1092 | if(v.load)v.load(); |
| 848 | 1093 | } |
| 849 | 1094 | } |
| 1095 | +// A background video (data-xspeed-wait) that reaches the viewport is parked | |
| 1096 | +// here until the visitor first scrolls, taps, types or moves the mouse, then | |
| 1097 | +// every parked one starts together. See defer_autoplay_source(). | |
| 1098 | +var I=false,P=[],E=['pointerdown','pointermove','touchstart','keydown','wheel','scroll']; | |
| 1099 | +function interacted(){ | |
| 1100 | +if(I)return;I=true; | |
| 1101 | +for(var i=0;i<E.length;i++)removeEventListener(E[i],interacted,true); | |
| 1102 | +for(var j=0;j<P.length;j++)go(P[j]); | |
| 1103 | +P=[]; | |
| 1104 | +} | |
| 1105 | +for(var k=0;k<E.length;k++)addEventListener(E[k],interacted,{capture:true,passive:true}); | |
| 1106 | +function reach(v){if(!I&&v.getAttribute('data-xspeed-wait'))P.push(v);else go(v);} | |
| 850 | 1107 | function scan(){ |
| 851 | 1108 | strip(); |
| 852 | 1109 | adopt(); |
| 853 | 1110 | var v=document.querySelectorAll(S); |
| 854 | -if(!('IntersectionObserver'in window)){for(var i=0;i<v.length;i++)go(v[i]);return;} | |
| 1111 | +if(!('IntersectionObserver'in window)){for(var i=0;i<v.length;i++)reach(v[i]);return;} | |
| 855 | 1112 | var o=new IntersectionObserver(function(es){ |
| 856 | -for(var i=0;i<es.length;i++){if(es[i].isIntersecting){go(es[i].target);o.unobserve(es[i].target);}} | |
| 1113 | +for(var i=0;i<es.length;i++){if(es[i].isIntersecting){reach(es[i].target);o.unobserve(es[i].target);}} | |
| 857 | 1114 | },{rootMargin:'200px'}); |
| 858 | 1115 | for(var j=0;j<v.length;j++)o.observe(v[j]); |
| 859 | 1116 | } |
| 860 | 1117 | if(document.readyState!=='loading')scan();else document.addEventListener('DOMContentLoaded',scan); |
| @@ -1622,8 +1879,12 @@ | ||
| 1622 | 1879 | */ |
| 1623 | 1880 | public static function reset_state(): void { |
| 1624 | 1881 | self::$opts = null; |
| 1625 | 1882 | self::$image_counter = 0; |
| 1883 | + self::$priority_claimed = false; | |
| 1884 | + self::$hidden_ranges = array(); | |
| 1885 | + self::$in_hidden = false; | |
| 1886 | + self::$priority_reserved = false; | |
| 1626 | 1887 | self::$background_counter = 0; |
| 1627 | 1888 | self::$src_dims_cache = null; |
| 1628 | 1889 | self::$facade_used = false; |
| 1629 | 1890 | self::$warming = false; |