| @@ -34,8 +34,18 @@ | ||
| 34 | 34 | */ |
| 35 | 35 | private static $image_counter = 0; |
| 36 | 36 | |
| 37 | 37 | /** |
| 38 | + * Eager budget for inline-style backgrounds. Separate from images: a | |
| 39 | + * hero is either an <img> or a background, and the passes run one after | |
| 40 | + * the other, so one shared counter would hand the budget to whichever | |
| 41 | + * pass runs first rather than to what sits first on the page. | |
| 42 | + * | |
| 43 | + * @var int | |
| 44 | + */ | |
| 45 | + private static $background_counter = 0; | |
| 46 | + | |
| 47 | + /** | |
| 38 | 48 | * Settings cache (one read per request). |
| 39 | 49 | * |
| 40 | 50 | * @var array|null |
| 41 | 51 | */ |
| @@ -49,8 +59,19 @@ | ||
| 49 | 59 | */ |
| 50 | 60 | private static $src_dims_cache = null; |
| 51 | 61 | |
| 52 | 62 | /** |
| 63 | + * True while a background pass is resolving dimensions. | |
| 64 | + * | |
| 65 | + * Front-end renders read the cache and never fetch; a warm pass is the | |
| 66 | + * one thing allowed to pay the network cost, because no visitor is | |
| 67 | + * waiting on it. | |
| 68 | + * | |
| 69 | + * @var bool | |
| 70 | + */ | |
| 71 | + private static $warming = false; | |
| 72 | + | |
| 73 | + /** | |
| 53 | 74 | * Main entry point: take rendered HTML, return rewritten HTML. |
| 54 | 75 | * Pure function aside from the static counters. |
| 55 | 76 | */ |
| 56 | 77 | public static function process_html( string $html ): string { |
| @@ -86,8 +107,13 @@ | ||
| 86 | 107 | } |
| 87 | 108 | if ( ! empty( $opts['lazy_iframes'] ) ) { |
| 88 | 109 | $work = self::apply_pass( $work, $tag_re( 'iframe' ), array( __CLASS__, 'rewrite_iframe' ) ); |
| 89 | 110 | } |
| 111 | + // Every opening tag is a candidate, so skip the pass on content with | |
| 112 | + // no url( at all, which is most of it. | |
| 113 | + if ( ! empty( $opts['lazy_background_images'] ) && false !== stripos( $work, 'url(' ) ) { | |
| 114 | + $work = self::apply_pass( $work, '#<[a-z][a-z0-9-]*\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i', array( __CLASS__, 'rewrite_background' ) ); | |
| 115 | + } | |
| 90 | 116 | // Facade runs AFTER the lazy pass, deliberately. The facade keeps the |
| 91 | 117 | // original tag inside <noscript> as the JS-less fallback, and that |
| 92 | 118 | // fallback should carry loading="lazy" too — running this first would |
| 93 | 119 | // produce an eager iframe for exactly the visitors least able to |
| @@ -102,9 +128,9 @@ | ||
| 102 | 128 | // unclosed iframe can't make the match run on to a LATER embed's |
| 103 | 129 | // closing tag and eat everything in between; an iframe with no |
| 104 | 130 | // closing tag simply doesn't match and passes through untouched. |
| 105 | 131 | if ( ! empty( $opts['video_facade'] ) ) { |
| 106 | - $work = self::apply_pass( | |
| 132 | + $work = self::apply_facade_pass( | |
| 107 | 133 | $work, |
| 108 | 134 | '#(<iframe\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>)((?:(?!</?iframe\b).)*)</iframe\s*>#is', |
| 109 | 135 | array( __CLASS__, 'rewrite_iframe_facade' ) |
| 110 | 136 | ); |
| @@ -110,14 +136,154 @@ | ||
| 110 | 136 | ); |
| 111 | 137 | } |
| 112 | 138 | if ( ! empty( $opts['lazy_videos'] ) ) { |
| 113 | 139 | $work = self::apply_pass( $work, $tag_re( 'video' ), array( __CLASS__, 'rewrite_video' ) ); |
| 140 | + // A page builder's video block renders no <video> server-side, so | |
| 141 | + // the pass above sees nothing to rewrite. Note that such markup is | |
| 142 | + // here anyway, so the restorer ships and can defer the element the | |
| 143 | + // block's own script creates. (See detect_attribute_video().) | |
| 144 | + self::detect_attribute_video( $work ); | |
| 114 | 145 | } |
| 146 | + // Self-hosted <video> facade — after the lazy pass for the same | |
| 147 | + // reason as the iframe facade above: the original element lands in | |
| 148 | + // <noscript> as the JS-less fallback, and that copy should carry | |
| 149 | + // preload="none" too. Same whole-element, tempered match so an | |
| 150 | + // unclosed <video> passes through rather than eating siblings. | |
| 151 | + if ( ! empty( $opts['video_facade'] ) ) { | |
| 152 | + $work = self::apply_facade_pass( | |
| 153 | + $work, | |
| 154 | + '#(<video\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>)((?:(?!</?video\b).)*)</video\s*>#is', | |
| 155 | + array( __CLASS__, 'rewrite_video_facade' ) | |
| 156 | + ); | |
| 157 | + } | |
| 115 | 158 | |
| 116 | 159 | return self::restore_safe_blocks( $work, $stubs ); |
| 117 | 160 | } |
| 118 | 161 | |
| 119 | 162 | /** |
| 163 | + * Hidden popup/lightbox templates a facade must never replace into. | |
| 164 | + * | |
| 165 | + * A lightbox plugin ships its player iframe in a hidden template div | |
| 166 | + * and moves that markup into the popup when clicked. Facading the | |
| 167 | + * template swaps its iframe for the play button, and the popup then | |
| 168 | + * opens around a button its own CSS only sizes for an iframe — a blank | |
| 169 | + * modal (found live: EmbedPress's "See it in action", an Essential | |
| 170 | + * Addons lightbox whose Magnific popup opened empty). The template | |
| 171 | + * iframe already carries loading="lazy" from the pass above, and a | |
| 172 | + * hidden lazy iframe never loads until shown — so skipping the facade | |
| 173 | + * here costs nothing on page load. | |
| 174 | + */ | |
| 175 | + private const POPUP_TEMPLATE_CLASSES = array( | |
| 176 | + 'eael-lightbox-popup-window', // Essential Addons lightbox template. | |
| 177 | + 'mfp-hide', // Magnific Popup inline template. | |
| 178 | + 'lity-hide', // Lity inline template. | |
| 179 | + ); | |
| 180 | + | |
| 181 | + /** | |
| 182 | + * A facade pass that leaves popup-template containers alone. | |
| 183 | + * | |
| 184 | + * Same PCRE-bail contract as apply_pass(). Ranges are byte spans in | |
| 185 | + * $html; PREG_OFFSET_CAPTURE offsets refer to the original subject, so | |
| 186 | + * earlier replacements never shift the comparison. | |
| 187 | + * | |
| 188 | + * @param callable $callback Rewrite callback taking plain string matches. | |
| 189 | + */ | |
| 190 | + private static function apply_facade_pass( string $html, string $pattern, callable $callback ): string { | |
| 191 | + $ranges = self::popup_template_ranges( $html ); | |
| 192 | + if ( empty( $ranges ) ) { | |
| 193 | + return self::apply_pass( $html, $pattern, $callback ); | |
| 194 | + } | |
| 195 | + | |
| 196 | + $result = preg_replace_callback( | |
| 197 | + $pattern, | |
| 198 | + static function ( array $m ) use ( $callback, $ranges ): string { | |
| 199 | + $offset = (int) $m[0][1]; | |
| 200 | + foreach ( $ranges as $range ) { | |
| 201 | + if ( $offset >= $range[0] && $offset < $range[1] ) { | |
| 202 | + return (string) $m[0][0]; // Inside a template: untouched. | |
| 203 | + } | |
| 204 | + } | |
| 205 | + return (string) call_user_func( | |
| 206 | + $callback, | |
| 207 | + array_map( static fn( $group ): string => (string) $group[0], $m ) | |
| 208 | + ); | |
| 209 | + }, | |
| 210 | + $html, | |
| 211 | + -1, | |
| 212 | + $count, | |
| 213 | + PREG_OFFSET_CAPTURE | |
| 214 | + ); | |
| 215 | + | |
| 216 | + return is_string( $result ) ? $result : $html; | |
| 217 | + } | |
| 218 | + | |
| 219 | + /** | |
| 220 | + * Byte spans of every popup-template container in $html. | |
| 221 | + * | |
| 222 | + * The span is found by counting the container's own tag name to its | |
| 223 | + * balancing close — templates are plain nested divs, so a same-tag | |
| 224 | + * depth count is enough; a container whose close is never found is | |
| 225 | + * dropped rather than guessed at (its iframes stay facade-eligible, | |
| 226 | + * the pre-fix behavior). | |
| 227 | + * | |
| 228 | + * @return array<int, array{0:int, 1:int}> | |
| 229 | + */ | |
| 230 | + private static function popup_template_ranges( string $html ): array { | |
| 231 | + /** | |
| 232 | + * Filter the class names marking a hidden popup/lightbox template | |
| 233 | + * whose contents the video facade must leave alone. | |
| 234 | + * | |
| 235 | + * @param string[] $classes | |
| 236 | + */ | |
| 237 | + $classes = (array) apply_filters( 'xspeed_video_facade_popup_classes', self::POPUP_TEMPLATE_CLASSES ); | |
| 238 | + $classes = array_values( | |
| 239 | + array_filter( | |
| 240 | + array_map( 'strval', $classes ), | |
| 241 | + static fn( string $c ): bool => (bool) preg_match( '/^[A-Za-z0-9_-]+$/', $c ) | |
| 242 | + ) | |
| 243 | + ); | |
| 244 | + if ( empty( $classes ) ) { | |
| 245 | + return array(); | |
| 246 | + } | |
| 247 | + | |
| 248 | + $pattern = '#<(div|section|span|aside)\b[^>]*\bclass\s*=\s*(["\'])[^"\']*(?<![A-Za-z0-9_-])(?:' | |
| 249 | + . implode( '|', array_map( 'preg_quote', $classes ) ) | |
| 250 | + . ')(?![A-Za-z0-9_-])[^"\']*\2[^>]*>#i'; | |
| 251 | + if ( ! preg_match_all( $pattern, $html, $m, PREG_OFFSET_CAPTURE ) ) { | |
| 252 | + return array(); | |
| 253 | + } | |
| 254 | + | |
| 255 | + $ranges = array(); | |
| 256 | + foreach ( $m[0] as $i => $hit ) { | |
| 257 | + $start = (int) $hit[1]; | |
| 258 | + $end = self::same_tag_end( $html, $start, strtolower( (string) $m[1][ $i ][0] ) ); | |
| 259 | + if ( null !== $end ) { | |
| 260 | + $ranges[] = array( $start, $end ); | |
| 261 | + } | |
| 262 | + } | |
| 263 | + return $ranges; | |
| 264 | + } | |
| 265 | + | |
| 266 | + /** | |
| 267 | + * The offset just past the close tag balancing the open tag at $offset, | |
| 268 | + * counting only $tag's own opens and closes. Null when it never closes. | |
| 269 | + */ | |
| 270 | + private static function same_tag_end( string $html, int $offset, string $tag ): ?int { | |
| 271 | + $depth = 0; | |
| 272 | + $cursor = $offset; | |
| 273 | + $re = '#<(/?)' . preg_quote( $tag, '#' ) . '\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i'; | |
| 274 | + while ( preg_match( $re, $html, $t, PREG_OFFSET_CAPTURE, $cursor ) ) { | |
| 275 | + $cursor = (int) $t[0][1] + strlen( (string) $t[0][0] ); | |
| 276 | + if ( '' === $t[1][0] ) { | |
| 277 | + ++$depth; | |
| 278 | + } elseif ( --$depth <= 0 ) { | |
| 279 | + return $cursor; | |
| 280 | + } | |
| 281 | + } | |
| 282 | + return null; | |
| 283 | + } | |
| 284 | + | |
| 285 | + /** | |
| 120 | 286 | * Run one rewrite pass, keeping the input if PCRE bails. |
| 121 | 287 | * |
| 122 | 288 | * preg_replace_callback() returns null when it hits the backtrack or |
| 123 | 289 | * recursion limit — on a large page that would otherwise blank the |
| @@ -141,10 +307,22 @@ | ||
| 141 | 307 | // applies — excluding an above-the-fold hero/logo from lazy-load is |
| 142 | 308 | // exactly when you most want its width/height kept. Previously both |
| 143 | 309 | // of these returned early, silently stripping dimensions too. |
| 144 | 310 | // (FBS-82172 Bug 2) |
| 311 | + // | |
| 312 | + // `fetchpriority="high"` joins that set: an image carrying it has been | |
| 313 | + // declared the LCP element by whoever rendered it — WordPress core, the | |
| 314 | + // theme, a page builder, or our own Resource_Hints_Processor. Lazy- | |
| 315 | + // loading it contradicts that declaration, because the tag would then | |
| 316 | + // tell the browser to fetch at top priority AND that it may defer the | |
| 317 | + // fetch indefinitely. Browsers resolve that in favour of the deferral, | |
| 318 | + // so the hero arrives late and any layout sized from it (a Kadence hero | |
| 319 | + // row, for example) reflows when it finally paints — the "sometimes | |
| 320 | + // broken, sometimes fine" symptom, because it depends on paint timing. | |
| 321 | + // Treat the hint as authoritative and keep the image eager. (#269) | |
| 145 | 322 | $skip_lazy = false !== stripos( $tag, 'data-skip-lazy' ) |
| 146 | 323 | || false !== stripos( $tag, 'data-no-lazy' ) |
| 324 | + || self::has_high_fetchpriority( $tag ) | |
| 147 | 325 | || self::is_excluded( $tag, $opts ); |
| 148 | 326 | |
| 149 | 327 | if ( $skip_lazy && ! empty( $opts['lazy_images'] ) ) { |
| 150 | 328 | // An EXCLUDED image is one the user marked as above-the-fold (a |
| @@ -178,8 +356,103 @@ | ||
| 178 | 356 | |
| 179 | 357 | return $tag; |
| 180 | 358 | } |
| 181 | 359 | |
| 360 | + /** Class that holds an element's background back until it nears the viewport. */ | |
| 361 | + public const LAZY_BG_CLASS = 'xspeed-lazy-bg'; | |
| 362 | + | |
| 363 | + private static function rewrite_background( array $m ): string { | |
| 364 | + $tag = $m[0]; | |
| 365 | + if ( false === stripos( $tag, 'url(' ) ) { | |
| 366 | + return $tag; | |
| 367 | + } | |
| 368 | + if ( ! preg_match( '#\sstyle\s*=\s*(?:"([^"]*)"|\'([^\']*)\')#i', $tag, $sm ) ) { | |
| 369 | + return $tag; | |
| 370 | + } | |
| 371 | + $style = html_entity_decode( isset( $sm[2] ) && '' !== $sm[2] ? $sm[2] : $sm[1], ENT_QUOTES ); | |
| 372 | + if ( ! self::has_deferrable_background( $style ) ) { | |
| 373 | + return $tag; | |
| 374 | + } | |
| 375 | + $opts = self::opts(); | |
| 376 | + if ( false !== stripos( $tag, 'data-skip-lazy' ) | |
| 377 | + || false !== stripos( $tag, 'data-no-lazy' ) | |
| 378 | + || self::is_excluded( $tag, $opts ) ) { | |
| 379 | + return $tag; | |
| 380 | + } | |
| 381 | + self::$background_counter++; | |
| 382 | + if ( self::$background_counter <= max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) ) ) { | |
| 383 | + return $tag; | |
| 384 | + } | |
| 385 | + return self::add_class( $tag, self::LAZY_BG_CLASS ); | |
| 386 | + } | |
| 387 | + | |
| 388 | + /** | |
| 389 | + * Whether an inline style sets a background image we can hold back. | |
| 390 | + * | |
| 391 | + * The hold is a stylesheet rule with !important, which beats a normal | |
| 392 | + * inline declaration but loses to an inline !important one; those are | |
| 393 | + * left alone. data: URIs cost no request, so there is nothing to save. | |
| 394 | + */ | |
| 395 | + public static function has_deferrable_background( string $style ): bool { | |
| 396 | + $found = false; | |
| 397 | + foreach ( explode( ';', $style ) as $decl ) { | |
| 398 | + if ( ! preg_match( '#^\s*background(?:-image)?\s*:(.*)$#is', $decl, $dm ) ) { | |
| 399 | + continue; | |
| 400 | + } | |
| 401 | + $value = $dm[1]; | |
| 402 | + if ( false !== stripos( $value, '!important' ) ) { | |
| 403 | + return false; | |
| 404 | + } | |
| 405 | + if ( preg_match( '#url\(\s*[\'"]?(?!data:)[^)\s\'"]#i', $value ) ) { | |
| 406 | + $found = true; | |
| 407 | + } | |
| 408 | + } | |
| 409 | + return $found; | |
| 410 | + } | |
| 411 | + | |
| 412 | + private static function add_class( string $tag, string $class ): string { | |
| 413 | + if ( preg_match( '#\sclass\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'>=`]+))#i', $tag, $cm, PREG_OFFSET_CAPTURE ) ) { | |
| 414 | + // An unquoted value (class=hero) is rewritten as a quoted one. | |
| 415 | + // Adding a second class attribute would lose it: browsers keep | |
| 416 | + // the first of two duplicates. | |
| 417 | + if ( isset( $cm[3] ) && -1 !== $cm[3][1] ) { | |
| 418 | + $group = $cm[3]; | |
| 419 | + return substr( $tag, 0, $group[1] ) . '"' . $group[0] . ' ' . $class . '"' . substr( $tag, $group[1] + strlen( $group[0] ) ); | |
| 420 | + } | |
| 421 | + $group = isset( $cm[2] ) && -1 !== $cm[2][1] ? $cm[2] : $cm[1]; | |
| 422 | + $value = trim( $group[0] . ' ' . $class ); | |
| 423 | + return substr( $tag, 0, $group[1] ) . $value . substr( $tag, $group[1] + strlen( $group[0] ) ); | |
| 424 | + } | |
| 425 | + return (string) preg_replace( '#^<([a-z][a-z0-9-]*)#i', '<$1 class="' . $class . '"', $tag, 1 ); | |
| 426 | + } | |
| 427 | + | |
| 428 | + /** | |
| 429 | + * Scoped to a class the script puts on <html>, so without JavaScript the | |
| 430 | + * rule never matches and every background loads normally. | |
| 431 | + */ | |
| 432 | + public static function background_style(): string { | |
| 433 | + return '.xspeed-lazy-bg-js .' . self::LAZY_BG_CLASS . '{background-image:none!important}'; | |
| 434 | + } | |
| 435 | + | |
| 436 | + /** | |
| 437 | + * The MutationObserver is what keeps a background from staying blank: | |
| 438 | + * carousel clones, infinite scroll and builder re-renders insert | |
| 439 | + * elements carrying the class after DOMContentLoaded, and a one-time | |
| 440 | + * scan never saw them. | |
| 441 | + */ | |
| 442 | + public static function background_script(): string { | |
| 443 | + return <<<'JS' | |
| 444 | +(function(d,w){var h=d.documentElement,C='xspeed-lazy-bg';h.className+=' xspeed-lazy-bg-js'; | |
| 445 | +function show(el){el.classList.remove(C);} | |
| 446 | +var io='IntersectionObserver' in w?new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){show(e.target);io.unobserve(e.target);}});},{rootMargin:'300px 0px'}):null; | |
| 447 | +function watch(el){if(io)io.observe(el);else show(el);} | |
| 448 | +function scan(r){if(r.classList&&r.classList.contains(C))watch(r);if(r.querySelectorAll){var l=r.querySelectorAll('.'+C);for(var i=0;i<l.length;i++)watch(l[i]);}} | |
| 449 | +if('MutationObserver' in w)new MutationObserver(function(ms){for(var j=0;j<ms.length;j++){var a=ms[j].addedNodes;for(var i=0;i<a.length;i++)if(a[i].nodeType===1)scan(a[i]);}}).observe(h,{childList:true,subtree:true}); | |
| 450 | +function run(){scan(d);} | |
| 451 | +if(d.readyState==='loading')d.addEventListener('DOMContentLoaded',run);else run();})(document,window); | |
| 452 | +JS; | |
| 453 | + } | |
| 454 | + | |
| 182 | 455 | private static function rewrite_iframe( array $m ): string { |
| 183 | 456 | $tag = $m[0]; |
| 184 | 457 | if ( false !== stripos( $tag, 'data-skip-lazy' ) ) { |
| 185 | 458 | return $tag; |
| @@ -249,17 +522,98 @@ | ||
| 249 | 522 | $tag = $m[0]; |
| 250 | 523 | if ( false !== stripos( $tag, 'data-skip-lazy' ) ) { |
| 251 | 524 | return $tag; |
| 252 | 525 | } |
| 526 | + /* | |
| 527 | + * An autoplaying video is the one case preload="none" cannot help: | |
| 528 | + * browsers fetch an autoplay source regardless of preload, because | |
| 529 | + * the author asked for it to start on its own. Setting the attribute | |
| 530 | + * would only make the markup lie about what happens. | |
| 531 | + * | |
| 532 | + * But "starts on its own" does not mean "must download before the | |
| 533 | + * visitor has scrolled anywhere near it". A page of nine autoplay | |
| 534 | + * demo clips pulled 44 MB on load and held the browser's loading | |
| 535 | + * indicator open for 33 s, while none of them were on screen. | |
| 536 | + * | |
| 537 | + * So defer the SOURCE and restore it when the element reaches the | |
| 538 | + * viewport, which is the first moment autoplay is meant to be | |
| 539 | + * visible anyway. The author's choice is honoured — the video still | |
| 540 | + * plays by itself — it simply costs nothing until it can be seen. | |
| 541 | + */ | |
| 542 | + if ( preg_match( '#\sautoplay(?=[\s/>=])#i', $tag ) ) { | |
| 543 | + return self::defer_autoplay_source( $tag ); | |
| 544 | + } | |
| 253 | 545 | // HTML5 `<video>` doesn't support loading=lazy yet (Chromium |
| 254 | 546 | // won't add it before there's broad support). What we CAN do |
| 255 | 547 | // is set preload="none" so the browser doesn't pre-fetch the |
| 256 | 548 | // video bytes until play is requested — that's the actual win |
| 257 | 549 | // users want from "lazy-load videos". |
| 258 | - if ( false === stripos( $tag, 'preload=' ) ) { | |
| 259 | - $tag = self::set_attr( $tag, 'preload', 'none' ); | |
| 550 | + // | |
| 551 | + // OVERRIDE an existing value rather than bailing on it: players | |
| 552 | + // that ship preload="auto" or "metadata" (Elementor's video | |
| 553 | + // widget, most block themes) are exactly the case this setting | |
| 554 | + // exists for, and skipping them made it a no-op right where it | |
| 555 | + // mattered. (#309 — a 924KB MP4 transferred in full on every run | |
| 556 | + // with this setting on.) | |
| 557 | + return self::set_attr( $tag, 'preload', 'none' ); | |
| 558 | + } | |
| 559 | + | |
| 560 | + /** | |
| 561 | + * Swap a self-hosted <video> for the click-to-play facade. | |
| 562 | + * | |
| 563 | + * The easy case of the facade, not the hard one: no third-party player | |
| 564 | + * to defer, and the element usually already carries a real poster | |
| 565 | + * frame. Bails out — element returned untouched — whenever the facade | |
| 566 | + * would be worse than the video: | |
| 567 | + * | |
| 568 | + * - `autoplay` is a deliberate author choice (a hero background); a | |
| 569 | + * play button in its place changes the page, not just its weight. | |
| 570 | + * - no `poster` means the facade renders as a blank black box, which | |
| 571 | + * is worse than the preload="none" the lazy pass already applied. | |
| 572 | + * - no resolvable source means there is nothing to play on click. | |
| 573 | + * | |
| 574 | + * $m[0] is the whole element, $m[1] the opening tag — same contract as | |
| 575 | + * rewrite_iframe_facade() above, and the same rule: every bail-out | |
| 576 | + * path returns the WHOLE element so the closing tag is never stranded. | |
| 577 | + */ | |
| 578 | + private static function rewrite_video_facade( array $m ): string { | |
| 579 | + $element = $m[0]; | |
| 580 | + $tag = $m[1]; | |
| 581 | + | |
| 582 | + if ( false !== stripos( $tag, 'data-skip-lazy' ) ) { | |
| 583 | + return $element; | |
| 260 | 584 | } |
| 261 | - return $tag; | |
| 585 | + if ( preg_match( '#\sautoplay(?=[\s/>=])#i', $tag ) ) { | |
| 586 | + return $element; | |
| 587 | + } | |
| 588 | + if ( self::is_excluded( $tag, self::opts() ) ) { | |
| 589 | + return $element; | |
| 590 | + } | |
| 591 | + | |
| 592 | + if ( ! preg_match( '#\bposter\s*=\s*(["\'])(.*?)\1#i', $tag, $poster_m ) || '' === trim( $poster_m[2] ) ) { | |
| 593 | + return $element; | |
| 594 | + } | |
| 595 | + $poster = $poster_m[2]; | |
| 596 | + | |
| 597 | + // Source: the src attribute, else the first <source src="…"> child. | |
| 598 | + $src = ''; | |
| 599 | + if ( preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#i', $tag, $src_m ) ) { | |
| 600 | + $src = $src_m[2]; | |
| 601 | + } elseif ( preg_match( '#<source\b[^>]*\bsrc\s*=\s*(["\'])(.*?)\1#i', $m[2], $src_m ) ) { | |
| 602 | + $src = $src_m[2]; | |
| 603 | + } | |
| 604 | + if ( '' === trim( $src ) ) { | |
| 605 | + return $element; | |
| 606 | + } | |
| 607 | + | |
| 608 | + $title = ''; | |
| 609 | + if ( preg_match( '#\btitle\s*=\s*(["\'])(.*?)\1#i', $tag, $title_m ) ) { | |
| 610 | + $title = $title_m[2]; | |
| 611 | + } | |
| 612 | + | |
| 613 | + self::$facade_used = true; | |
| 614 | + | |
| 615 | + return Video_Facade::render_native( $element, $src, $poster, $title ); | |
| 262 | 616 | } |
| 263 | 617 | |
| 264 | 618 | /** |
| 265 | 619 | * Add an attribute to an opening tag if it isn't already present. |
| @@ -265,10 +619,269 @@ | ||
| 265 | 619 | * Add an attribute to an opening tag if it isn't already present. |
| 266 | 620 | * Pass $only_if_missing=false to override an existing value (e.g. |
| 267 | 621 | * flipping loading="lazy" → "eager" on the first image). |
| 268 | 622 | */ |
| 623 | + /** | |
| 624 | + * Hold an autoplay video's bytes until the element reaches the viewport. | |
| 625 | + * | |
| 626 | + * `preload="none"` is ignored for autoplay, so the only way to stop the | |
| 627 | + * download is to take the source away and give it back later. We move | |
| 628 | + * `src` to `data-xspeed-src` and drop `autoplay` — a `<video>` with no | |
| 629 | + * resolvable source fetches nothing — then the script below restores | |
| 630 | + * both when the element scrolls into view. | |
| 631 | + * | |
| 632 | + * Restoring `autoplay` rather than calling play() matters: play() from | |
| 633 | + * a non-user gesture is refused unless the video is muted, and returns | |
| 634 | + * a promise whose rejection most callers never handle. Setting the | |
| 635 | + * attribute lets the browser apply its own autoplay policy exactly as | |
| 636 | + * it would have on load. | |
| 637 | + * | |
| 638 | + * `<source>` children are handled too, since a video with multiple | |
| 639 | + * formats carries no `src` of its own. | |
| 640 | + * | |
| 641 | + * Marked with a data attribute rather than a class so a theme's CSS | |
| 642 | + * cannot accidentally select — or style away — the deferred state. | |
| 643 | + */ | |
| 644 | + private static function defer_autoplay_source( string $tag ): string { | |
| 645 | + // Already processed (a second pass, or another plugin got there). | |
| 646 | + if ( false !== stripos( $tag, 'data-xspeed-src' ) ) { | |
| 647 | + return $tag; | |
| 648 | + } | |
| 649 | + | |
| 650 | + $deferred = false; | |
| 651 | + | |
| 652 | + // The element's own src, when it has one. | |
| 653 | + if ( preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#i', $tag, $m ) && '' !== trim( $m[2] ) ) { | |
| 654 | + $tag = (string) preg_replace( | |
| 655 | + '#\bsrc\s*=\s*(["\'])(.*?)\1#i', | |
| 656 | + 'data-xspeed-src="' . esc_attr( $m[2] ) . '"', | |
| 657 | + $tag, | |
| 658 | + 1 | |
| 659 | + ); | |
| 660 | + $deferred = true; | |
| 661 | + } | |
| 662 | + | |
| 663 | + if ( ! $deferred ) { | |
| 664 | + // No src of its own — the <source> children carry it, and those | |
| 665 | + // are outside this opening tag. Mark the element so the script | |
| 666 | + // knows to move them, and let it do the work in the DOM where | |
| 667 | + // the children are actually reachable. | |
| 668 | + $tag = self::set_attr( $tag, 'data-xspeed-defer-sources', '1' ); | |
| 669 | + } | |
| 670 | + | |
| 671 | + // Without this the browser starts fetching the moment a source is | |
| 672 | + // restored, which is what we want — but it must not autoplay before | |
| 673 | + // then, and it must not report itself as autoplaying meanwhile. | |
| 674 | + $tag = (string) preg_replace( '#\sautoplay(?=[\s/>=])#i', ' data-xspeed-autoplay="1"', $tag, 1 ); | |
| 675 | + | |
| 676 | + // preload="none" as well: belt and braces for the window between | |
| 677 | + // parse and the observer attaching. | |
| 678 | + $tag = self::set_attr( $tag, 'preload', 'none' ); | |
| 679 | + | |
| 680 | + self::$deferred_autoplay = true; | |
| 681 | + | |
| 682 | + return $tag; | |
| 683 | + } | |
| 684 | + | |
| 685 | + /** | |
| 686 | + * Did this response defer at least one autoplay video? Gates the script | |
| 687 | + * so a page with no such video ships no extra bytes. | |
| 688 | + * | |
| 689 | + * @var bool | |
| 690 | + */ | |
| 691 | + private static $deferred_autoplay = false; | |
| 692 | + | |
| 693 | + /** Whether the viewport script needs to be injected into this response. */ | |
| 694 | + public static function needs_autoplay_script(): bool { | |
| 695 | + return self::$deferred_autoplay || self::$has_deferred_video_markup; | |
| 696 | + } | |
| 697 | + | |
| 698 | + /** | |
| 699 | + * Page-builder video blocks that render NO <video> tag server-side. | |
| 700 | + * | |
| 701 | + * Essential Blocks' advanced-video, and widgets shaped like it, ship a | |
| 702 | + * plain <div> carrying the file URL in an attribute and let their own JS | |
| 703 | + * build the player after load. The PHP pass cannot rewrite what is not | |
| 704 | + * there, so a page of nine such blocks was completely untouched — which | |
| 705 | + * is exactly the 44 MB case this feature exists for. | |
| 706 | + * | |
| 707 | + * We deliberately do NOT rewrite those attributes. They belong to | |
| 708 | + * another plugin, whose script reads them on init; renaming one is how | |
| 709 | + * you get a player that silently never appears. Instead we note that | |
| 710 | + * such markup is present so the restorer ships, and let its | |
| 711 | + * MutationObserver catch the <video> the block creates — at which point | |
| 712 | + * it is an ordinary element we can defer like any other. | |
| 713 | + * | |
| 714 | + * @var bool | |
| 715 | + */ | |
| 716 | + private static $has_deferred_video_markup = false; | |
| 717 | + | |
| 718 | + /** | |
| 719 | + * Does this HTML carry a video URL in an attribute rather than a tag? | |
| 720 | + * | |
| 721 | + * Matched on the URL, not on any one plugin's attribute name: `data-url` | |
| 722 | + * is Essential Blocks, but `data-src`, `data-video-url` and others are | |
| 723 | + * equally common, and a rule keyed to one vendor would miss the rest. | |
| 724 | + */ | |
| 725 | + private static function detect_attribute_video( string $html ): void { | |
| 726 | + if ( self::$has_deferred_video_markup ) { | |
| 727 | + return; | |
| 728 | + } | |
| 729 | + if ( preg_match( '#\sdata-[\w-]+\s*=\s*(["\'])[^"\']*\.(?:mp4|webm|m4v|ogv|mov)(?:\?[^"\']*)?\1#i', $html ) ) { | |
| 730 | + self::$has_deferred_video_markup = true; | |
| 731 | + } | |
| 732 | + } | |
| 733 | + | |
| 734 | + /** | |
| 735 | + * Restore the source when the video reaches the viewport. | |
| 736 | + * | |
| 737 | + * Dependency-free and tiny, matching Video_Facade::facade_script(). The | |
| 738 | + * rootMargin starts the fetch slightly before the element is visible so | |
| 739 | + * playback begins without a visible stall. | |
| 740 | + */ | |
| 741 | + public static function autoplay_script(): string { | |
| 742 | + return <<<'JS' | |
| 743 | +(function(){ | |
| 744 | +var S='video[data-xspeed-src],video[data-xspeed-defer-sources]'; | |
| 745 | + | |
| 746 | +/* | |
| 747 | + * Intercept the ASSIGNMENT, because observing the DOM is always too late. | |
| 748 | + * | |
| 749 | + * Measured on a live page: a builder's video player creates nine elements | |
| 750 | + * and sets `src` BEFORE inserting them, so a MutationObserver watching for | |
| 751 | + * insertions saw zero of them — and the browser had already begun fetching | |
| 752 | + * by the time any observer could run. The order is: setAttribute('src'), | |
| 753 | + * then setAttribute('preload','auto'), then insert. Only the first of those | |
| 754 | + * matters, and it happens off-DOM. | |
| 755 | + * | |
| 756 | + * So wrap the two ways a source can be set on a media element and hold the | |
| 757 | + * value instead of applying it. Nothing else can start a download: a | |
| 758 | + * <video> with no resolvable source fetches nothing. The value is stored on | |
| 759 | + * the element and handed back by go() when it reaches the viewport. | |
| 760 | + * | |
| 761 | + * Scoped to <video> only. <audio> is small and usually deliberate, and | |
| 762 | + * touching it would change behaviour nobody complained about. | |
| 763 | + */ | |
| 764 | +try{ | |
| 765 | +var VP=window.HTMLMediaElement&&HTMLMediaElement.prototype; | |
| 766 | +var SD=VP&&Object.getOwnPropertyDescriptor(VP,'src'); | |
| 767 | +var hold=function(el,val){ | |
| 768 | +if(el.tagName!=='VIDEO')return false; | |
| 769 | +if(el.getAttribute('data-xspeed-loaded'))return false; // released: let it through | |
| 770 | +if(!val)return false; | |
| 771 | +el.setAttribute('data-xspeed-src',String(val)); | |
| 772 | +el.setAttribute('data-xspeed-adopted','1'); | |
| 773 | +return true; | |
| 774 | +}; | |
| 775 | +if(SD&&SD.set){ | |
| 776 | +Object.defineProperty(VP,'src',{configurable:true,enumerable:SD.enumerable, | |
| 777 | +get:function(){return SD.get.call(this);}, | |
| 778 | +set:function(v){if(hold(this,v))return;return SD.set.call(this,v);}}); | |
| 779 | +} | |
| 780 | +var SA=Element.prototype.setAttribute; | |
| 781 | +Element.prototype.setAttribute=function(n,v){ | |
| 782 | +if(n==='src'&&hold(this,v))return; | |
| 783 | +// An eager preload on a held video would re-arm the fetch the moment a | |
| 784 | +// source comes back; keep it at none until we release it deliberately. | |
| 785 | +if(n==='preload'&&this.tagName==='VIDEO'&&this.getAttribute('data-xspeed-src')&&v!=='none') | |
| 786 | +return SA.call(this,'preload','none'); | |
| 787 | +return SA.call(this,n,v); | |
| 788 | +}; | |
| 789 | +}catch(e){} | |
| 790 | +function go(v){ | |
| 791 | +if(v.getAttribute('data-xspeed-loaded'))return; | |
| 792 | +v.setAttribute('data-xspeed-loaded','1'); | |
| 793 | +var s=v.getAttribute('data-xspeed-src'); | |
| 794 | +if(s){v.setAttribute('src',s);v.removeAttribute('data-xspeed-src');} | |
| 795 | +if(v.getAttribute('data-xspeed-defer-sources')){ | |
| 796 | +var c=v.querySelectorAll('source[data-xspeed-src]'); | |
| 797 | +for(var i=0;i<c.length;i++){c[i].setAttribute('src',c[i].getAttribute('data-xspeed-src'));c[i].removeAttribute('data-xspeed-src');} | |
| 798 | +v.removeAttribute('data-xspeed-defer-sources'); | |
| 799 | +} | |
| 800 | + | |
| 801 | +if(v.getAttribute('data-xspeed-autoplay')){v.setAttribute('autoplay','');v.removeAttribute('data-xspeed-autoplay');} | |
| 802 | +v.removeAttribute('preload'); | |
| 803 | +// load() picks up the sources we just restored; without it a <video> | |
| 804 | +// that has already failed to resolve a source will not retry. | |
| 805 | +if(v.load)v.load(); | |
| 806 | +} | |
| 807 | +// A multi-format <video> carries no src of its own — the <source> children | |
| 808 | +// do, and those sit outside the opening tag PHP rewrote. Strip them here, | |
| 809 | +// as early as this script runs, then restore on intersect like the rest. | |
| 810 | +function strip(){ | |
| 811 | +var d=document.querySelectorAll('video[data-xspeed-defer-sources]'); | |
| 812 | +for(var i=0;i<d.length;i++){ | |
| 813 | +if(d[i].getAttribute('data-xspeed-loaded'))continue; | |
| 814 | +var c=d[i].querySelectorAll('source[src]'); | |
| 815 | +for(var j=0;j<c.length;j++){c[j].setAttribute('data-xspeed-src',c[j].getAttribute('src'));c[j].removeAttribute('src');} | |
| 816 | +if(c.length&&d[i].load)d[i].load(); | |
| 817 | +} | |
| 818 | +} | |
| 819 | +// A page-builder block builds its <video> after load, so PHP never saw it | |
| 820 | +// and it arrives with a live src and autoplay already set. Defer it here, | |
| 821 | +// the same way the server would have, BEFORE the browser gets far into | |
| 822 | +// fetching it. Only autoplay videos: anything else is already covered by | |
| 823 | +// preload="none" and taking a source from a user-controlled player would | |
| 824 | +// break its own play button. | |
| 825 | +function adopt(){ | |
| 826 | +// Any JS-built <video> that would fetch on sight — NOT just autoplay. | |
| 827 | +// Measured on a live page: a builder's video block creates nine elements | |
| 828 | +// with autoplay=false and preload="auto", so an autoplay-only selector | |
| 829 | +// skipped every one of them and 40 MB still downloaded. preload="auto" is | |
| 830 | +// the same eager-fetch instruction by another name, and the server pass | |
| 831 | +// would have rewritten it to "none" had the element existed in the HTML. | |
| 832 | +var a=document.querySelectorAll('video[autoplay]:not([data-xspeed-loaded]):not([data-xspeed-adopted]),video[preload="auto"]:not([data-xspeed-loaded]):not([data-xspeed-adopted]),video[preload="metadata"]:not([data-xspeed-loaded]):not([data-xspeed-adopted])'); | |
| 833 | +for(var i=0;i<a.length;i++){ | |
| 834 | +var v=a[i]; | |
| 835 | +v.setAttribute('data-xspeed-adopted','1'); | |
| 836 | +var auto=v.hasAttribute('autoplay'); | |
| 837 | +var s=v.getAttribute('src'); | |
| 838 | +if(s){v.setAttribute('data-xspeed-src',s);v.removeAttribute('src');} | |
| 839 | +var c=v.querySelectorAll('source[src]'); | |
| 840 | +for(var j=0;j<c.length;j++){c[j].setAttribute('data-xspeed-src',c[j].getAttribute('src'));c[j].removeAttribute('src');} | |
| 841 | +if(c.length)v.setAttribute('data-xspeed-defer-sources','1'); | |
| 842 | +// Only remember autoplay for the ones that actually had it — restoring it | |
| 843 | +// on a video the author left click-to-play would start playback nobody | |
| 844 | +// asked for. | |
| 845 | +if(auto){v.removeAttribute('autoplay');v.setAttribute('data-xspeed-autoplay','1');} | |
| 846 | +v.setAttribute('preload','none'); | |
| 847 | +if(v.load)v.load(); | |
| 848 | +} | |
| 849 | +} | |
| 850 | +function scan(){ | |
| 851 | +strip(); | |
| 852 | +adopt(); | |
| 853 | +var v=document.querySelectorAll(S); | |
| 854 | +if(!('IntersectionObserver'in window)){for(var i=0;i<v.length;i++)go(v[i]);return;} | |
| 855 | +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);}} | |
| 857 | +},{rootMargin:'200px'}); | |
| 858 | +for(var j=0;j<v.length;j++)o.observe(v[j]); | |
| 859 | +} | |
| 860 | +if(document.readyState!=='loading')scan();else document.addEventListener('DOMContentLoaded',scan); | |
| 861 | +// Players that build their <video> after load (page-builder video blocks) | |
| 862 | +// must be caught the INSTANT the element lands. A debounce loses the race: | |
| 863 | +// the browser begins fetching as soon as a src is set, so by the time a | |
| 864 | +// timer fires the bytes are already committed. adopt() is idempotent and | |
| 865 | +// cheap (one guarded querySelectorAll), so run it synchronously on every | |
| 866 | +// mutation and only debounce the fuller scan that attaches observers. | |
| 867 | +if(window.MutationObserver){ | |
| 868 | +var t; | |
| 869 | +new MutationObserver(function(){ | |
| 870 | +adopt(); | |
| 871 | +clearTimeout(t);t=setTimeout(scan,200); | |
| 872 | +}).observe(document.documentElement,{childList:true,subtree:true}); | |
| 873 | +} | |
| 874 | +})(); | |
| 875 | +JS; | |
| 876 | + } | |
| 877 | + | |
| 269 | 878 | private static function set_attr( string $tag, string $name, string $value, bool $only_if_missing = false ): string { |
| 270 | - $pattern = '#\b' . preg_quote( $name, '#' ) . '\s*=\s*(["\'][^"\']*["\']|\S+)#i'; | |
| 879 | + // Lookbehind, not `\b`: writing `width` onto a tag carrying | |
| 880 | + // `data-width="800"` matched the DATA attribute and rewrote it to the | |
| 881 | + // file's intrinsic size — corrupting a slider's own configuration and | |
| 882 | + // leaving the tag with no real width at all. (#333 review round 3) | |
| 883 | + $pattern = '#(?<![-\w])' . preg_quote( $name, '#' ) . '\s*=\s*(["\'][^"\']*["\']|\S+)#i'; | |
| 271 | 884 | if ( preg_match( $pattern, $tag ) ) { |
| 272 | 885 | if ( $only_if_missing ) { |
| 273 | 886 | return $tag; |
| 274 | 887 | } |
| @@ -288,10 +901,19 @@ | ||
| 288 | 901 | * filesystem when src points at the uploads dir. Skip when we can't |
| 289 | 902 | * resolve cheaply — never block the request on a remote getimagesize. |
| 290 | 903 | */ |
| 291 | 904 | private static function ensure_dimensions( string $tag ): string { |
| 292 | - $has_w = (bool) preg_match( '#\bwidth\s*=#i', $tag ); | |
| 293 | - $has_h = (bool) preg_match( '#\bheight\s*=#i', $tag ); | |
| 905 | + // `\b` sits between `-` and `w`, so a bare `\bwidth=` also matched | |
| 906 | + // `data-width=` — a slider's own metadata, not a rendered dimension. | |
| 907 | + // The tag then looked half-sized: apply_dimensions() derived the other | |
| 908 | + // dimension from the ratio and wrote ONLY that, so a tag carrying | |
| 909 | + // `data-width="800"` came out with `height="533"` and no width and | |
| 910 | + // laid out at 41x30. Harmless while the URL never resolved; this | |
| 911 | + // branch made it resolve, which is what exposed it. Half a pair is | |
| 912 | + // worse than none, as the docblock below already says. | |
| 913 | + // (#333 review round 3, issue 2) | |
| 914 | + $has_w = (bool) preg_match( '#(?<![-\w])width\s*=#i', $tag ); | |
| 915 | + $has_h = (bool) preg_match( '#(?<![-\w])height\s*=#i', $tag ); | |
| 294 | 916 | if ( $has_w && $has_h ) { |
| 295 | 917 | return $tag; |
| 296 | 918 | } |
| 297 | 919 | |
| @@ -309,12 +931,15 @@ | ||
| 309 | 931 | // on those images (issue #37). Resolve from the src instead, but only |
| 310 | 932 | // when the tag doesn't already tell us it renders at some other size: |
| 311 | 933 | // stamping the intrinsic file size onto a responsive or CSS-sized |
| 312 | 934 | // image would CREATE the layout shift this feature exists to remove. |
| 313 | - if ( ! self::has_constrained_render( $tag ) && preg_match( '#\bsrc\s*=\s*["\']([^"\']+)["\']#i', $tag, $sm ) ) { | |
| 314 | - $dims = self::dimensions_for_src( $sm[1] ); | |
| 315 | - if ( $dims ) { | |
| 316 | - return self::apply_dimensions( $tag, $dims, $has_w, $has_h ); | |
| 935 | + if ( ! self::has_constrained_render( $tag ) ) { | |
| 936 | + $url = self::resolvable_image_url( $tag ); | |
| 937 | + if ( '' !== $url ) { | |
| 938 | + $dims = self::dimensions_for_src( $url ); | |
| 939 | + if ( $dims ) { | |
| 940 | + return self::apply_dimensions( $tag, $dims, $has_w, $has_h ); | |
| 941 | + } | |
| 317 | 942 | } |
| 318 | 943 | } |
| 319 | 944 | |
| 320 | 945 | // Couldn't resolve. Leave the tag alone — better no dimensions |
| @@ -322,8 +947,201 @@ | ||
| 322 | 947 | return $tag; |
| 323 | 948 | } |
| 324 | 949 | |
| 325 | 950 | /** |
| 951 | + * The URL to measure an image by: its real `src`, or the lazy-loading | |
| 952 | + * attribute holding the URL when `src` is absent or a placeholder. | |
| 953 | + * | |
| 954 | + * Page-builder sliders (Essential Blocks among them) ship the image with | |
| 955 | + * NO `src` at all — the URL lives in `data-lazy`, and their own JS moves | |
| 956 | + * it across at runtime. Resolving only from `src` left every one of those | |
| 957 | + * images without dimensions (issue #328, the miss that #37 did not cover: | |
| 958 | + * that one was about the missing `wp-image-N` class, this one is about the | |
| 959 | + * URL not being in `src` in the first place). | |
| 960 | + * | |
| 961 | + * A placeholder `src` — a data: URI or the 1x1 spacer GIF these libraries | |
| 962 | + * use — is treated as absent: measuring it would stamp the spacer's size | |
| 963 | + * onto the tag and CREATE a layout shift. | |
| 964 | + * | |
| 965 | + * Note the explicit `(?<![-\w])src` boundary. `\bsrc=` also matches the | |
| 966 | + * tail of `data-src=` and `data-lazy-src=` (a hyphen is a non-word | |
| 967 | + * character, so `\b` sits between `-` and `s`), which is why those two | |
| 968 | + * attributes happened to work before this method existed while `data-lazy` | |
| 969 | + * and `data-original` did not. Relying on that accident meant the URL a | |
| 970 | + * tag was measured by depended on how its attribute was spelled. | |
| 971 | + * | |
| 972 | + * Pure — unit-tested — EXCEPT when `$may_measure` is true and every | |
| 973 | + * candidate was refused by name, which is the one branch that touches the | |
| 974 | + * filesystem. Callers that are themselves arranging a measurement pass | |
| 975 | + * false; see the note at that branch. | |
| 976 | + * | |
| 977 | + * @param string $tag The <img> tag. | |
| 978 | + * @param bool $may_measure Whether a name-refused URL may be settled by | |
| 979 | + * reading the file. False for the warm-up | |
| 980 | + * collector, which would otherwise deadlock. | |
| 981 | + */ | |
| 982 | + public static function resolvable_image_url( string $tag, bool $may_measure = true ): string { | |
| 983 | + $src = ''; | |
| 984 | + $named_out = ''; | |
| 985 | + if ( preg_match( '#(?<![-\w])src\s*=\s*["\']([^"\']+)["\']#i', $tag, $m ) ) { | |
| 986 | + $src = trim( $m[1] ); | |
| 987 | + if ( '' !== $src && ! self::is_placeholder_src( $src ) ) { | |
| 988 | + return $src; | |
| 989 | + } | |
| 990 | + } | |
| 991 | + | |
| 992 | + foreach ( array( 'data-lazy', 'data-src', 'data-lazy-src', 'data-original' ) as $attr ) { | |
| 993 | + // Anchor with a negative lookbehind, not `\b` and not | |
| 994 | + // whitespace. `\b` sits between `-` and `d`, so a bare | |
| 995 | + // `\bdata-src=` also matched the TAIL of `x-data-src=` and took | |
| 996 | + // the wrong image's URL — worse than no size, because it reserves | |
| 997 | + // a wrongly shaped box and CAUSES the shift. | |
| 998 | + // | |
| 999 | + // Requiring whitespace instead was my first fix and it was wrong: | |
| 1000 | + // attributes are not always separated by one (`alt="31"srcset=` | |
| 1001 | + // is valid), so that anchor silently stopped matching and handed | |
| 1002 | + // back a size for a tag the guard should have skipped. The | |
| 1003 | + // lookbehind rejects the same prefixed decoys without depending on | |
| 1004 | + // spacing, and is what `src` already uses two methods below. | |
| 1005 | + // (#333 review rounds 2 and 3, issue 1) | |
| 1006 | + if ( preg_match( '#(?<![-\w])' . preg_quote( $attr, '#' ) . '\s*=\s*["\']([^"\']+)["\']#i', $tag, $m ) ) { | |
| 1007 | + $url = trim( $m[1] ); | |
| 1008 | + if ( '' === $url ) { | |
| 1009 | + continue; | |
| 1010 | + } | |
| 1011 | + if ( ! self::is_placeholder_src( $url ) ) { | |
| 1012 | + return $url; | |
| 1013 | + } | |
| 1014 | + // Refused on its NAME. Remember it — if nothing else in the | |
| 1015 | + // tag resolves, the file itself gets the final say below. | |
| 1016 | + if ( '' === $named_out ) { | |
| 1017 | + $named_out = $url; | |
| 1018 | + } | |
| 1019 | + } | |
| 1020 | + } | |
| 1021 | + | |
| 1022 | + // Every candidate was refused on its NAME alone. A name is a guess; | |
| 1023 | + // the file is the fact. Someone who uploads a photograph called | |
| 1024 | + // `placeholder.jpg` — an entirely ordinary thing to find in a media | |
| 1025 | + // library — got no dimensions at all, and neither did any of the | |
| 1026 | + // copies WordPress generates from it, so the layout shift this | |
| 1027 | + // feature removes came straight back for those images with nothing on | |
| 1028 | + // screen to explain why. (#333 review round 2, issue 1) | |
| 1029 | + // | |
| 1030 | + // Only reached when nothing else in the tag resolved, so the cost is a | |
| 1031 | + // lookup that was about to be skipped entirely, never an extra one. | |
| 1032 | + // A genuine stand-in fails this test on its own merits: a data: URI | |
| 1033 | + // never gets here, and a 1x1 spacer measures 1x1. | |
| 1034 | + // | |
| 1035 | + // The lazy attribute is preferred over `src`, matching the order | |
| 1036 | + // above: when a tag carries both, the lazy one names the real image | |
| 1037 | + // and `src` holds the stand-in. | |
| 1038 | + // The warm-up collector passes false here, and must. Deciding this by | |
| 1039 | + // MEASURING is circular for the caller whose whole job is to arrange | |
| 1040 | + // the measurement: remote lookups are gated off until `$warming` is | |
| 1041 | + // true, `$warming` only becomes true inside warm_dimensions(), and | |
| 1042 | + // warm_dimensions() is never reached because this returned ''. A | |
| 1043 | + // remote `placeholder.jpg` — a real photograph on a CDN — was warmable | |
| 1044 | + // before this branch and stopped being, with a failure cached against | |
| 1045 | + // it for good measure. The collector takes the URL the tag offers and | |
| 1046 | + // lets warm_dimensions() be the thing that decides. | |
| 1047 | + // (#333 review round 3, issue 3) | |
| 1048 | + if ( ! $may_measure ) { | |
| 1049 | + return '' !== $named_out ? $named_out : $src; | |
| 1050 | + } | |
| 1051 | + | |
| 1052 | + foreach ( array( $named_out, $src ) as $candidate ) { | |
| 1053 | + if ( '' !== $candidate && self::is_real_image( $candidate ) ) { | |
| 1054 | + return $candidate; | |
| 1055 | + } | |
| 1056 | + } | |
| 1057 | + | |
| 1058 | + return ''; | |
| 1059 | + } | |
| 1060 | + | |
| 1061 | + /** | |
| 1062 | + * Does this URL resolve to something too big to be a lazy-load stand-in? | |
| 1063 | + * | |
| 1064 | + * The stand-ins this guards against are 1x1 spacers and inline data: URIs. | |
| 1065 | + * Anything with real extent is a real image, whatever it is called — which | |
| 1066 | + * is what lets a photograph named `placeholder.jpg` keep its dimensions | |
| 1067 | + * while `spacer.gif` still loses them. | |
| 1068 | + * | |
| 1069 | + * Deliberately conservative: an unresolvable URL returns false, so the | |
| 1070 | + * name-based verdict stands and the tag is left alone. Better no | |
| 1071 | + * dimensions than wrong ones. Uses the same resolver (and therefore the | |
| 1072 | + * same cache) as the normal path, so this costs no extra lookup. | |
| 1073 | + */ | |
| 1074 | + private static function is_real_image( string $src ): bool { | |
| 1075 | + $dims = self::dimensions_for_src( $src ); | |
| 1076 | + if ( ! is_array( $dims ) ) { | |
| 1077 | + return false; | |
| 1078 | + } | |
| 1079 | + // Indexed [ width, height ] — the shape apply_dimensions() consumes. | |
| 1080 | + $w = isset( $dims[0] ) ? (int) $dims[0] : 0; | |
| 1081 | + $h = isset( $dims[1] ) ? (int) $dims[1] : 0; | |
| 1082 | + | |
| 1083 | + // A few pixels either way is still a spacer — some libraries ship a | |
| 1084 | + // 2x2 or 4x4 rather than a true 1x1. Anything above that has extent a | |
| 1085 | + // stand-in does not. | |
| 1086 | + return $w > 4 && $h > 4; | |
| 1087 | + } | |
| 1088 | + | |
| 1089 | + /** | |
| 1090 | + * True for the stand-in a lazy-loader parks in `src` until its JS swaps | |
| 1091 | + * the real URL in: an inline data: URI, or a `spacer`/`blank`/`placeholder` | |
| 1092 | + * asset. Measuring one of these would stamp the spacer's dimensions onto | |
| 1093 | + * the tag. Pure — unit-tested. | |
| 1094 | + * | |
| 1095 | + * Matched on the WHOLE filename stem, not a word inside it. A word-boundary | |
| 1096 | + * search anywhere in the last segment caught every real image whose name | |
| 1097 | + * merely contains one of these ordinary words — `blank-space-cover.png`, | |
| 1098 | + * `placeholder-portrait.png`, `spacer-hero-banner.jpg` — and silently | |
| 1099 | + * stopped sizing them, which brings back the very layout shift this | |
| 1100 | + * feature exists to prevent, with nothing on screen to explain it | |
| 1101 | + * (#333 review, issue 1). | |
| 1102 | + * | |
| 1103 | + * A real stand-in is named for what it is and nothing else: `blank.gif`, | |
| 1104 | + * `spacer.png`, `lazy-loader.svg`, optionally with a dimension or version | |
| 1105 | + * suffix (`blank-1x1.gif`, `[email protected]`). A descriptive tail is what | |
| 1106 | + * separates a photograph from a spacer, so the tail is what decides. | |
| 1107 | + */ | |
| 1108 | + public static function is_placeholder_src( string $src ): bool { | |
| 1109 | + if ( 0 === stripos( $src, 'data:' ) ) { | |
| 1110 | + return true; | |
| 1111 | + } | |
| 1112 | + | |
| 1113 | + // Last path segment, without the query string or fragment — | |
| 1114 | + // `?v=placeholder` is a cache-buster on a real image, not a name. | |
| 1115 | + // Plain string work on purpose: this method is pure and unit-tested | |
| 1116 | + // with no WordPress loaded, so wp_parse_url() is not available. | |
| 1117 | + $path = strtok( $src, '?#' ); | |
| 1118 | + if ( ! is_string( $path ) || '' === $path ) { | |
| 1119 | + $path = $src; | |
| 1120 | + } | |
| 1121 | + $name = strtolower( basename( $path ) ); | |
| 1122 | + | |
| 1123 | + // Drop the extension, then any trailing dimension/DPR/version marker. | |
| 1124 | + $stem = preg_replace( '#\.[a-z0-9]+$#', '', $name ); | |
| 1125 | + $stem = (string) preg_replace( '#[-_@]?(?:\d+x\d+|\d+x|x\d+|v\d+|\d+)$#', '', (string) $stem ); | |
| 1126 | + $stem = trim( $stem, '-_.' ); | |
| 1127 | + | |
| 1128 | + return 1 === preg_match( '#^(?:spacer|blank|placeholder|lazy-?loader|transparent|pixel|dummy)$#', $stem ); | |
| 1129 | + } | |
| 1130 | + | |
| 1131 | + /** | |
| 1132 | + * True when the tag already declares itself the LCP image via | |
| 1133 | + * `fetchpriority="high"`. | |
| 1134 | + * | |
| 1135 | + * Only "high" counts. `fetchpriority="low"` and `="auto"` say the opposite | |
| 1136 | + * (or nothing), and an image marked low-priority is a perfectly good | |
| 1137 | + * lazy-load candidate. Pure — unit-tested. | |
| 1138 | + */ | |
| 1139 | + public static function has_high_fetchpriority( string $tag ): bool { | |
| 1140 | + return 1 === preg_match( '#\bfetchpriority\s*=\s*["\']?high\b#i', $tag ); | |
| 1141 | + } | |
| 1142 | + | |
| 1143 | + /** | |
| 326 | 1144 | * True when the tag says it renders at a size other than the file's |
| 327 | 1145 | * intrinsic one — a `srcset`/`sizes` pair (the browser picks a |
| 328 | 1146 | * candidate) or an inline width/height style. |
| 329 | 1147 | * |
| @@ -332,9 +1150,22 @@ | ||
| 332 | 1150 | * own `wp_filter_content_tags()` adds dimensions to responsive |
| 333 | 1151 | * images the same way. Pure — unit-tested. |
| 334 | 1152 | */ |
| 335 | 1153 | public static function has_constrained_render( string $tag ): bool { |
| 336 | - if ( preg_match( '#\bsrcset\s*=#i', $tag ) || preg_match( '#\bsizes\s*=#i', $tag ) ) { | |
| 1154 | + // `\b` sits between `-` and `s`, so a bare \bsrcset also matched | |
| 1155 | + // `data-srcset` — a lazy attribute the browser has NOT applied yet. | |
| 1156 | + // That made an unset attribute suppress dimensions on exactly the | |
| 1157 | + // slider images this feature exists to size, for the same | |
| 1158 | + // accidental-text-match reason the URL lookup moved away from | |
| 1159 | + // (#333 review, issue 3). | |
| 1160 | + // | |
| 1161 | + // The anchor is a negative lookbehind rather than "start or | |
| 1162 | + // whitespace": HTML does not require a space between attributes, so | |
| 1163 | + // `alt="31"srcset="..."` slipped past a whitespace anchor and this | |
| 1164 | + // guard stopped firing — the tag then got the file's intrinsic size | |
| 1165 | + // stamped on it while the browser rendered a differently-shaped | |
| 1166 | + // srcset candidate. (#333 review round 3, issue 1) | |
| 1167 | + if ( preg_match( '#(?<![-\w])(?:srcset|sizes)\s*=#i', $tag ) ) { | |
| 337 | 1168 | return true; |
| 338 | 1169 | } |
| 339 | 1170 | if ( preg_match( '#\bstyle\s*=\s*["\']([^"\']*)["\']#i', $tag, $m ) ) { |
| 340 | 1171 | // width/height in the inline style wins over the attribute, so |
| @@ -345,8 +1176,44 @@ | ||
| 345 | 1176 | } |
| 346 | 1177 | |
| 347 | 1178 | /** @param int[] $dims [width, height]. */ |
| 348 | 1179 | private static function apply_dimensions( string $tag, array $dims, bool $has_w, bool $has_h ): string { |
| 1180 | + // One dimension already present: derive the other from the file's | |
| 1181 | + // real aspect ratio rather than stamping its intrinsic size. | |
| 1182 | + // | |
| 1183 | + // A tag that says width="300" on a 1200x800 file renders 300x200. If | |
| 1184 | + // we wrote height="800" the browser would reserve a box two and a | |
| 1185 | + // half times too tall, then snap when the image painted — CREATING | |
| 1186 | + // the shift this feature exists to remove. Scaling keeps the reserved | |
| 1187 | + // box the shape the image will actually be. | |
| 1188 | + if ( $has_w !== $has_h ) { | |
| 1189 | + if ( $dims[0] <= 0 || $dims[1] <= 0 ) { | |
| 1190 | + return $tag; | |
| 1191 | + } | |
| 1192 | + $from = $has_w ? 'width' : 'height'; | |
| 1193 | + $declared = self::attr_int( $tag, $from ); | |
| 1194 | + // A declared value we cannot read in pixels (`50%`, `auto`) means | |
| 1195 | + // we do not know the rendered size, so there is no ratio to scale | |
| 1196 | + // from. Stamping the intrinsic size here is exactly the bug this | |
| 1197 | + // branch exists to avoid, so the tag is left alone. | |
| 1198 | + if ( $declared <= 0 ) { | |
| 1199 | + return $tag; | |
| 1200 | + } | |
| 1201 | + if ( $has_w ) { | |
| 1202 | + $height = (int) round( $dims[1] * $declared / $dims[0] ); | |
| 1203 | + return $height > 0 ? self::set_attr( $tag, 'height', (string) $height ) : $tag; | |
| 1204 | + } | |
| 1205 | + $width = (int) round( $dims[0] * $declared / $dims[1] ); | |
| 1206 | + return $width > 0 ? self::set_attr( $tag, 'width', (string) $width ) : $tag; | |
| 1207 | + } | |
| 1208 | + | |
| 1209 | + // A header that reported 0 for either side is not a measurement. Half | |
| 1210 | + // a dimension pair is worse than none: the browser reserves a box of | |
| 1211 | + // the wrong shape and still shifts when the real image lands. | |
| 1212 | + if ( $dims[0] <= 0 || $dims[1] <= 0 ) { | |
| 1213 | + return $tag; | |
| 1214 | + } | |
| 1215 | + | |
| 349 | 1216 | if ( ! $has_w ) { |
| 350 | 1217 | $tag = self::set_attr( $tag, 'width', (string) $dims[0] ); |
| 351 | 1218 | } |
| 352 | 1219 | if ( ! $has_h ) { |
| @@ -355,8 +1222,32 @@ | ||
| 355 | 1222 | return $tag; |
| 356 | 1223 | } |
| 357 | 1224 | |
| 358 | 1225 | /** |
| 1226 | + * Read one numeric attribute off a tag. | |
| 1227 | + * | |
| 1228 | + * Returns 0 for anything that is not a plain number — `width="50%"` and | |
| 1229 | + * `width="auto"` are CSS-ish values whose pixel size we do not know, and | |
| 1230 | + * scaling from them would invent a box rather than reserve one. | |
| 1231 | + * | |
| 1232 | + * @param string $tag The tag. | |
| 1233 | + * @param string $name Attribute name. | |
| 1234 | + */ | |
| 1235 | + private static function attr_int( string $tag, string $name ): int { | |
| 1236 | + // The value must be ENTIRELY digits. Matching a leading run would read | |
| 1237 | + // `width="50%"` as 50 and scale from a percentage as though it were | |
| 1238 | + // pixels — inventing a box rather than declining to guess. | |
| 1239 | + // Lookbehind for the same reason as set_attr(): `\bwidth=` also reads | |
| 1240 | + // `data-width=`, so a slider's own metadata was scaled from as though | |
| 1241 | + // it were a rendered dimension. | |
| 1242 | + if ( ! preg_match( '#(?<![-\w])' . preg_quote( $name, '#' ) . '\s*=\s*(?:"(\d+)"|\'(\d+)\'|(\d+)(?=[\s/>]))#i', $tag, $m ) ) { | |
| 1243 | + return 0; | |
| 1244 | + } | |
| 1245 | + $value = '' !== ( $m[1] ?? '' ) ? $m[1] : ( '' !== ( $m[2] ?? '' ) ? $m[2] : ( $m[3] ?? '' ) ); | |
| 1246 | + return (int) $value; | |
| 1247 | + } | |
| 1248 | + | |
| 1249 | + /** | |
| 359 | 1250 | * WordPress names resized files `<name>-WxH.<ext>` — when the suffix is |
| 360 | 1251 | * present it IS the rendered size, resolvable with zero I/O (works for |
| 361 | 1252 | * CDN-hosted copies too). Pure — unit-tested. |
| 362 | 1253 | * |
| @@ -374,8 +1265,105 @@ | ||
| 374 | 1265 | return null; |
| 375 | 1266 | } |
| 376 | 1267 | |
| 377 | 1268 | /** |
| 1269 | + * Intrinsic size of an image hosted on another domain. | |
| 1270 | + * | |
| 1271 | + * An image the site does not host is still an image whose dimensions | |
| 1272 | + * decide whether the page jumps while it loads. Refusing to look them up | |
| 1273 | + * was leaving real layout shift unfixed on any site that embeds media from | |
| 1274 | + * a CDN, a sister site, or a shared asset host — and telling the owner to | |
| 1275 | + * go and edit their content, which is not a fix a caching plugin should be | |
| 1276 | + * proud of. | |
| 1277 | + * | |
| 1278 | + * The reason for the old refusal was sound but too broad: a page render | |
| 1279 | + * must never block on somebody else's server. So this fetches only the | |
| 1280 | + * first few KB — enough for the header of every format WordPress | |
| 1281 | + * supports — with a short timeout, and caches the answer (successes AND | |
| 1282 | + * failures) so a URL is fetched once rather than once per pageview. | |
| 1283 | + * | |
| 1284 | + * By default it runs only when something has already warmed the cache | |
| 1285 | + * off-request (the preloader, a cron pass, WP-CLI). A visitor's request | |
| 1286 | + * therefore never waits on it. A site that would rather pay the cost | |
| 1287 | + * inline can opt in: | |
| 1288 | + * | |
| 1289 | + * add_filter( 'xspeed_lazy_remote_dimensions_inline', '__return_true' ); | |
| 1290 | + * | |
| 1291 | + * and one that wants nothing fetched from other hosts at all can opt out: | |
| 1292 | + * | |
| 1293 | + * add_filter( 'xspeed_lazy_remote_dimensions', '__return_false' ); | |
| 1294 | + * | |
| 1295 | + * @param string $src Absolute URL on another host. | |
| 1296 | + * @return int[]|null [width, height] or null when it cannot be resolved. | |
| 1297 | + */ | |
| 1298 | + private static function remote_dimensions( string $src ): ?array { | |
| 1299 | + /** | |
| 1300 | + * Whether to resolve dimensions for images on other hosts at all. | |
| 1301 | + * | |
| 1302 | + * @param bool $enabled Default true. | |
| 1303 | + * @param string $src The image URL. | |
| 1304 | + */ | |
| 1305 | + if ( ! apply_filters( 'xspeed_lazy_remote_dimensions', true, $src ) ) { | |
| 1306 | + return null; | |
| 1307 | + } | |
| 1308 | + | |
| 1309 | + if ( ! function_exists( 'wp_remote_get' ) ) { | |
| 1310 | + return null; | |
| 1311 | + } | |
| 1312 | + | |
| 1313 | + // Only http(s). A data: or blob: src has no server to ask. | |
| 1314 | + if ( ! preg_match( '#^https?://#i', $src ) ) { | |
| 1315 | + return null; | |
| 1316 | + } | |
| 1317 | + | |
| 1318 | + /** | |
| 1319 | + * Whether a front-end request may perform the fetch itself. | |
| 1320 | + * | |
| 1321 | + * Off by default: the whole point of the cache is that a visitor | |
| 1322 | + * never waits on another host. Warm passes (cron, preloader, CLI) | |
| 1323 | + * set this true for themselves. | |
| 1324 | + * | |
| 1325 | + * @param bool $inline Default false. | |
| 1326 | + */ | |
| 1327 | + $inline = (bool) apply_filters( 'xspeed_lazy_remote_dimensions_inline', self::$warming ); | |
| 1328 | + if ( ! $inline ) { | |
| 1329 | + return null; | |
| 1330 | + } | |
| 1331 | + | |
| 1332 | + // 32KB covers the header of JPEG, PNG, GIF, WebP and AVIF. Range is a | |
| 1333 | + // request, not a guarantee — a server that ignores it sends the whole | |
| 1334 | + // file, which the timeout still bounds. | |
| 1335 | + $resp = wp_remote_get( | |
| 1336 | + $src, | |
| 1337 | + array( | |
| 1338 | + 'timeout' => 5, | |
| 1339 | + 'headers' => array( 'Range' => 'bytes=0-32767' ), | |
| 1340 | + 'user-agent' => 'xSpeed/dimension-probe', | |
| 1341 | + ) | |
| 1342 | + ); | |
| 1343 | + if ( is_wp_error( $resp ) ) { | |
| 1344 | + return null; | |
| 1345 | + } | |
| 1346 | + $code = (int) wp_remote_retrieve_response_code( $resp ); | |
| 1347 | + if ( 200 !== $code && 206 !== $code ) { | |
| 1348 | + return null; | |
| 1349 | + } | |
| 1350 | + | |
| 1351 | + $body = (string) wp_remote_retrieve_body( $resp ); | |
| 1352 | + if ( '' === $body ) { | |
| 1353 | + return null; | |
| 1354 | + } | |
| 1355 | + | |
| 1356 | + // getimagesizefromstring reads the header out of the bytes we already | |
| 1357 | + // have — no second request, no temp file. | |
| 1358 | + $size = @getimagesizefromstring( $body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a truncated or non-image body must degrade to null, not warn. | |
| 1359 | + if ( is_array( $size ) && ! empty( $size[0] ) && ! empty( $size[1] ) ) { | |
| 1360 | + return array( (int) $size[0], (int) $size[1] ); | |
| 1361 | + } | |
| 1362 | + return null; | |
| 1363 | + } | |
| 1364 | + | |
| 1365 | + /** | |
| 378 | 1366 | * Resolve dimensions from an image URL, cheapest first: |
| 379 | 1367 | * 1. `-WxH` filename suffix (no I/O). |
| 380 | 1368 | * 2. Intrinsic size of the local file when src is under uploads |
| 381 | 1369 | * (getimagesize on the header — no remote fetches, ever). |
| @@ -396,12 +1384,12 @@ | ||
| 396 | 1384 | } |
| 397 | 1385 | $uploads = wp_get_upload_dir(); |
| 398 | 1386 | $baseurl = isset( $uploads['baseurl'] ) ? (string) $uploads['baseurl'] : ''; |
| 399 | 1387 | $basedir = isset( $uploads['basedir'] ) ? (string) $uploads['basedir'] : ''; |
| 400 | - if ( '' === $baseurl || '' === $basedir || 0 !== strpos( $src, $baseurl ) ) { | |
| 401 | - return null; // External image — never fetch remotely for a size. | |
| 402 | - } | |
| 403 | 1388 | |
| 1389 | + // The cache is consulted BEFORE the local/remote split, so a remote | |
| 1390 | + // image pays its lookup once for the life of the transient rather | |
| 1391 | + // than once per page render. | |
| 404 | 1392 | if ( null === self::$src_dims_cache ) { |
| 405 | 1393 | $stored = get_transient( 'xspeed_img_dims' ); |
| 406 | 1394 | self::$src_dims_cache = is_array( $stored ) ? $stored : array(); |
| 407 | 1395 | } |
| @@ -407,29 +1395,52 @@ | ||
| 407 | 1395 | } |
| 408 | 1396 | $key = md5( $src ); |
| 409 | 1397 | if ( array_key_exists( $key, self::$src_dims_cache ) ) { |
| 410 | 1398 | $hit = self::$src_dims_cache[ $key ]; |
| 411 | - return is_array( $hit ) ? $hit : null; // 0 = cached failure. | |
| 1399 | + if ( is_array( $hit ) ) { | |
| 1400 | + return $hit; | |
| 1401 | + } | |
| 1402 | + // A cached FAILURE, not a cached answer. A front-end render | |
| 1403 | + // honours it — that is the whole point, one failed lookup must | |
| 1404 | + // not cost a request on every pageview. A warm pass does NOT: | |
| 1405 | + // it was asked to resolve these, nothing is waiting on it, and | |
| 1406 | + // the usual reason for a failure is a moment of bad luck rather | |
| 1407 | + // than an image that can never be measured. | |
| 1408 | + // | |
| 1409 | + // Without this, one slow response poisoned a URL for the life of | |
| 1410 | + // the transient. It happened on a real site: 15 images cached as | |
| 1411 | + // failures, and every later warm returned "resolved: 0" while the | |
| 1412 | + // page kept shifting. | |
| 1413 | + if ( ! self::$warming || ! self::failure_is_retryable( $hit ) ) { | |
| 1414 | + return null; | |
| 1415 | + } | |
| 412 | 1416 | } |
| 413 | 1417 | |
| 414 | - $dims = null; | |
| 415 | - $relative = (string) preg_replace( '/[?#].*$/', '', substr( $src, strlen( $baseurl ) ) ); | |
| 416 | - if ( false === strpos( $relative, '..' ) ) { | |
| 417 | - $file = $basedir . $relative; | |
| 418 | - if ( is_file( $file ) ) { | |
| 419 | - $size = @getimagesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-image/corrupt file must degrade to null, not warn. | |
| 420 | - if ( is_array( $size ) && ! empty( $size[0] ) && ! empty( $size[1] ) ) { | |
| 421 | - $dims = array( (int) $size[0], (int) $size[1] ); | |
| 1418 | + $is_local = '' !== $baseurl && '' !== $basedir && 0 === strpos( $src, $baseurl ); | |
| 1419 | + | |
| 1420 | + $dims = null; | |
| 1421 | + | |
| 1422 | + if ( $is_local ) { | |
| 1423 | + $relative = (string) preg_replace( '/[?#].*$/', '', substr( $src, strlen( $baseurl ) ) ); | |
| 1424 | + if ( false === strpos( $relative, '..' ) ) { | |
| 1425 | + $file = $basedir . $relative; | |
| 1426 | + if ( is_file( $file ) ) { | |
| 1427 | + $size = @getimagesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-image/corrupt file must degrade to null, not warn. | |
| 1428 | + if ( is_array( $size ) && ! empty( $size[0] ) && ! empty( $size[1] ) ) { | |
| 1429 | + $dims = array( (int) $size[0], (int) $size[1] ); | |
| 1430 | + } | |
| 422 | 1431 | } |
| 423 | 1432 | } |
| 424 | - } | |
| 425 | 1433 | |
| 426 | - // File not on disk (offloaded originals) — one DB lookup by URL. | |
| 427 | - if ( null === $dims && function_exists( 'attachment_url_to_postid' ) ) { | |
| 428 | - $id = (int) attachment_url_to_postid( $src ); | |
| 429 | - if ( $id > 0 ) { | |
| 430 | - $dims = self::dimensions_for_attachment( $id ); | |
| 1434 | + // File not on disk (offloaded originals) — one DB lookup by URL. | |
| 1435 | + if ( null === $dims && function_exists( 'attachment_url_to_postid' ) ) { | |
| 1436 | + $id = (int) attachment_url_to_postid( $src ); | |
| 1437 | + if ( $id > 0 ) { | |
| 1438 | + $dims = self::dimensions_for_attachment( $id ); | |
| 1439 | + } | |
| 431 | 1440 | } |
| 1441 | + } else { | |
| 1442 | + $dims = self::remote_dimensions( $src ); | |
| 432 | 1443 | } |
| 433 | 1444 | |
| 434 | 1445 | // Cache success AND failure (0), bounded so the blob can't grow |
| 435 | 1446 | // unbounded on media-heavy sites. |
| @@ -435,9 +1446,14 @@ | ||
| 435 | 1446 | // unbounded on media-heavy sites. |
| 436 | 1447 | if ( count( self::$src_dims_cache ) >= 500 ) { |
| 437 | 1448 | self::$src_dims_cache = array_slice( self::$src_dims_cache, 250, null, true ); |
| 438 | 1449 | } |
| 439 | - self::$src_dims_cache[ $key ] = null === $dims ? 0 : $dims; | |
| 1450 | + // A resolved size is permanent — the file's intrinsic dimensions do | |
| 1451 | + // not change under the same URL. A failure is a snapshot of one | |
| 1452 | + // moment, so it is stored as a TIMESTAMP rather than a bare 0 and | |
| 1453 | + // stops counting after a while. Storing both the same way is what let | |
| 1454 | + // a transient blip look identical to "this can never be measured". | |
| 1455 | + self::$src_dims_cache[ $key ] = null === $dims ? time() : $dims; | |
| 440 | 1456 | if ( function_exists( 'set_transient' ) ) { |
| 441 | 1457 | set_transient( 'xspeed_img_dims', self::$src_dims_cache, DAY_IN_SECONDS ); |
| 442 | 1458 | } |
| 443 | 1459 | return $dims; |
| @@ -510,13 +1526,112 @@ | ||
| 510 | 1526 | return self::$opts; |
| 511 | 1527 | } |
| 512 | 1528 | |
| 513 | 1529 | /** |
| 1530 | + * How long a failed lookup is trusted before a warm pass tries again. | |
| 1531 | + * | |
| 1532 | + * Long enough that a genuinely unmeasurable URL is not re-fetched on every | |
| 1533 | + * crawl, short enough that an outage does not cost a day of layout shift. | |
| 1534 | + */ | |
| 1535 | + private const FAILURE_RETRY_AFTER = 900; // 15 minutes. | |
| 1536 | + | |
| 1537 | + /** | |
| 1538 | + * Whether a stored failure is old enough to be worth retrying. | |
| 1539 | + * | |
| 1540 | + * Legacy entries were written as a bare `0` with no timestamp. Those are | |
| 1541 | + * always retryable: they predate this distinction, and one extra request | |
| 1542 | + * for each is a far better outcome than leaving a site permanently unable | |
| 1543 | + * to resolve images it could resolve today. | |
| 1544 | + * | |
| 1545 | + * @param mixed $entry Stored cache value. | |
| 1546 | + */ | |
| 1547 | + private static function failure_is_retryable( $entry ): bool { | |
| 1548 | + if ( ! is_int( $entry ) || $entry <= 0 ) { | |
| 1549 | + return true; // legacy `0`, or nonsense — retry. | |
| 1550 | + } | |
| 1551 | + return ( time() - $entry ) >= self::FAILURE_RETRY_AFTER; | |
| 1552 | + } | |
| 1553 | + | |
| 1554 | + /** | |
| 1555 | + * Whether this URL's dimensions are already known (or known-unresolvable). | |
| 1556 | + * | |
| 1557 | + * Lets a caller skip URLs that would cost nothing to look up, so a bounded | |
| 1558 | + * batch spends its budget on images it has not seen. Without this a capped | |
| 1559 | + * collector re-picks the same first N images every pass — they are always | |
| 1560 | + * in the same DOM order — and anything past the cap is never resolved at | |
| 1561 | + * all, however many times the crawl runs. | |
| 1562 | + * | |
| 1563 | + * Reads the cache only; never fetches. | |
| 1564 | + * | |
| 1565 | + * @param string $src Absolute image URL. | |
| 1566 | + */ | |
| 1567 | + public static function dimensions_known( string $src ): bool { | |
| 1568 | + if ( ! function_exists( 'get_transient' ) ) { | |
| 1569 | + return false; | |
| 1570 | + } | |
| 1571 | + if ( null === self::$src_dims_cache ) { | |
| 1572 | + $stored = get_transient( 'xspeed_img_dims' ); | |
| 1573 | + self::$src_dims_cache = is_array( $stored ) ? $stored : array(); | |
| 1574 | + } | |
| 1575 | + $key = md5( $src ); | |
| 1576 | + if ( ! array_key_exists( $key, self::$src_dims_cache ) ) { | |
| 1577 | + return false; | |
| 1578 | + } | |
| 1579 | + $hit = self::$src_dims_cache[ $key ]; | |
| 1580 | + if ( is_array( $hit ) ) { | |
| 1581 | + return true; | |
| 1582 | + } | |
| 1583 | + // A failure that has aged out is NOT known — reporting it as known | |
| 1584 | + // would make the crawl skip the one URL that has become worth | |
| 1585 | + // retrying. | |
| 1586 | + return ! self::failure_is_retryable( $hit ); | |
| 1587 | + } | |
| 1588 | + | |
| 1589 | + /** | |
| 1590 | + * Resolve and cache dimensions for a batch of image URLs. | |
| 1591 | + * | |
| 1592 | + * Meant for anything running OFF a visitor's request — the preloader | |
| 1593 | + * crawling the sitemap, a cron pass, `wp xspeed lazy warm-dimensions`. | |
| 1594 | + * Once warmed, the front end serves the dimensions from cache, so the | |
| 1595 | + * layout shift is fixed without a single visitor waiting on another host. | |
| 1596 | + * | |
| 1597 | + * @param string[] $urls Absolute image URLs. | |
| 1598 | + * @return int How many were resolved. | |
| 1599 | + */ | |
| 1600 | + public static function warm_dimensions( array $urls ): int { | |
| 1601 | + $resolved = 0; | |
| 1602 | + self::$warming = true; | |
| 1603 | + try { | |
| 1604 | + foreach ( array_unique( $urls ) as $url ) { | |
| 1605 | + if ( ! is_string( $url ) || '' === $url ) { | |
| 1606 | + continue; | |
| 1607 | + } | |
| 1608 | + if ( self::dimensions_for_src( $url ) ) { | |
| 1609 | + $resolved++; | |
| 1610 | + } | |
| 1611 | + } | |
| 1612 | + } finally { | |
| 1613 | + // In a finally so a throw mid-batch cannot leave the flag set and | |
| 1614 | + // silently turn every later front-end render into a fetcher. | |
| 1615 | + self::$warming = false; | |
| 1616 | + } | |
| 1617 | + return $resolved; | |
| 1618 | + } | |
| 1619 | + | |
| 1620 | + /** | |
| 514 | 1621 | * Test-only: clear cached opts + counter between assertions. |
| 515 | 1622 | */ |
| 516 | 1623 | public static function reset_state(): void { |
| 517 | 1624 | self::$opts = null; |
| 518 | 1625 | self::$image_counter = 0; |
| 1626 | + self::$background_counter = 0; | |
| 519 | 1627 | self::$src_dims_cache = null; |
| 520 | 1628 | self::$facade_used = false; |
| 1629 | + self::$warming = false; | |
| 1630 | + // Both gate whether the autoplay restorer is printed. Left set, one | |
| 1631 | + // page carrying a video would make every later response in the same | |
| 1632 | + // process ship the script — and, worse for the preloader, a warmed | |
| 1633 | + // page could inherit a decision made for a different URL. | |
| 1634 | + self::$deferred_autoplay = false; | |
| 1635 | + self::$has_deferred_video_markup = false; | |
| 521 | 1636 | } |
| 522 | 1637 | } |