PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
← All changes | includes/class-lazy-loader.php +911 -30 1.1.21.3.2 View file →
@@ -49,8 +49,19 @@
49 49 */
50 50 private static $src_dims_cache = null;
51 51
52 52 /**
53 + * True while a background pass is resolving dimensions.
54 + *
55 + * Front-end renders read the cache and never fetch; a warm pass is the
56 + * one thing allowed to pay the network cost, because no visitor is
57 + * waiting on it.
58 + *
59 + * @var bool
60 + */
61 + private static $warming = false;
62 +
63 + /**
53 64 * Main entry point: take rendered HTML, return rewritten HTML.
54 65 * Pure function aside from the static counters.
55 66 */
56 67 public static function process_html( string $html ): string {
@@ -110,9 +121,26 @@
110 121 );
111 122 }
112 123 if ( ! empty( $opts['lazy_videos'] ) ) {
113 124 $work = self::apply_pass( $work, $tag_re( 'video' ), array( __CLASS__, 'rewrite_video' ) );
125 + // A page builder's video block renders no <video> server-side, so
126 + // the pass above sees nothing to rewrite. Note that such markup is
127 + // here anyway, so the restorer ships and can defer the element the
128 + // block's own script creates. (See detect_attribute_video().)
129 + self::detect_attribute_video( $work );
114 130 }
131 + // Self-hosted <video> facade — after the lazy pass for the same
132 + // reason as the iframe facade above: the original element lands in
133 + // <noscript> as the JS-less fallback, and that copy should carry
134 + // preload="none" too. Same whole-element, tempered match so an
135 + // unclosed <video> passes through rather than eating siblings.
136 + if ( ! empty( $opts['video_facade'] ) ) {
137 + $work = self::apply_pass(
138 + $work,
139 + '#(<video\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>)((?:(?!</?video\b).)*)</video\s*>#is',
140 + array( __CLASS__, 'rewrite_video_facade' )
141 + );
142 + }
115 143
116 144 return self::restore_safe_blocks( $work, $stubs );
117 145 }
118 146
@@ -141,10 +169,22 @@
141 169 // applies — excluding an above-the-fold hero/logo from lazy-load is
142 170 // exactly when you most want its width/height kept. Previously both
143 171 // of these returned early, silently stripping dimensions too.
144 172 // (FBS-82172 Bug 2)
173 + //
174 + // `fetchpriority="high"` joins that set: an image carrying it has been
175 + // declared the LCP element by whoever rendered it — WordPress core, the
176 + // theme, a page builder, or our own Resource_Hints_Processor. Lazy-
177 + // loading it contradicts that declaration, because the tag would then
178 + // tell the browser to fetch at top priority AND that it may defer the
179 + // fetch indefinitely. Browsers resolve that in favour of the deferral,
180 + // so the hero arrives late and any layout sized from it (a Kadence hero
181 + // row, for example) reflows when it finally paints — the "sometimes
182 + // broken, sometimes fine" symptom, because it depends on paint timing.
183 + // Treat the hint as authoritative and keep the image eager. (#269)
145 184 $skip_lazy = false !== stripos( $tag, 'data-skip-lazy' )
146 185 || false !== stripos( $tag, 'data-no-lazy' )
186 + || self::has_high_fetchpriority( $tag )
147 187 || self::is_excluded( $tag, $opts );
148 188
149 189 if ( $skip_lazy && ! empty( $opts['lazy_images'] ) ) {
150 190 // An EXCLUDED image is one the user marked as above-the-fold (a
@@ -249,17 +289,98 @@
249 289 $tag = $m[0];
250 290 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
251 291 return $tag;
252 292 }
293 + /*
294 + * An autoplaying video is the one case preload="none" cannot help:
295 + * browsers fetch an autoplay source regardless of preload, because
296 + * the author asked for it to start on its own. Setting the attribute
297 + * would only make the markup lie about what happens.
298 + *
299 + * But "starts on its own" does not mean "must download before the
300 + * visitor has scrolled anywhere near it". A page of nine autoplay
301 + * demo clips pulled 44 MB on load and held the browser's loading
302 + * indicator open for 33 s, while none of them were on screen.
303 + *
304 + * So defer the SOURCE and restore it when the element reaches the
305 + * viewport, which is the first moment autoplay is meant to be
306 + * visible anyway. The author's choice is honoured — the video still
307 + * plays by itself — it simply costs nothing until it can be seen.
308 + */
309 + if ( preg_match( '#\sautoplay(?=[\s/>=])#i', $tag ) ) {
310 + return self::defer_autoplay_source( $tag );
311 + }
253 312 // HTML5 `<video>` doesn't support loading=lazy yet (Chromium
254 313 // won't add it before there's broad support). What we CAN do
255 314 // is set preload="none" so the browser doesn't pre-fetch the
256 315 // video bytes until play is requested — that's the actual win
257 316 // users want from "lazy-load videos".
258 - if ( false === stripos( $tag, 'preload=' ) ) {
259 - $tag = self::set_attr( $tag, 'preload', 'none' );
317 + //
318 + // OVERRIDE an existing value rather than bailing on it: players
319 + // that ship preload="auto" or "metadata" (Elementor's video
320 + // widget, most block themes) are exactly the case this setting
321 + // exists for, and skipping them made it a no-op right where it
322 + // mattered. (#309 — a 924KB MP4 transferred in full on every run
323 + // with this setting on.)
324 + return self::set_attr( $tag, 'preload', 'none' );
325 + }
326 +
327 + /**
328 + * Swap a self-hosted <video> for the click-to-play facade.
329 + *
330 + * The easy case of the facade, not the hard one: no third-party player
331 + * to defer, and the element usually already carries a real poster
332 + * frame. Bails out — element returned untouched — whenever the facade
333 + * would be worse than the video:
334 + *
335 + * - `autoplay` is a deliberate author choice (a hero background); a
336 + * play button in its place changes the page, not just its weight.
337 + * - no `poster` means the facade renders as a blank black box, which
338 + * is worse than the preload="none" the lazy pass already applied.
339 + * - no resolvable source means there is nothing to play on click.
340 + *
341 + * $m[0] is the whole element, $m[1] the opening tag — same contract as
342 + * rewrite_iframe_facade() above, and the same rule: every bail-out
343 + * path returns the WHOLE element so the closing tag is never stranded.
344 + */
345 + private static function rewrite_video_facade( array $m ): string {
346 + $element = $m[0];
347 + $tag = $m[1];
348 +
349 + if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
350 + return $element;
260 351 }
261 - return $tag;
352 + if ( preg_match( '#\sautoplay(?=[\s/>=])#i', $tag ) ) {
353 + return $element;
354 + }
355 + if ( self::is_excluded( $tag, self::opts() ) ) {
356 + return $element;
357 + }
358 +
359 + if ( ! preg_match( '#\bposter\s*=\s*(["\'])(.*?)\1#i', $tag, $poster_m ) || '' === trim( $poster_m[2] ) ) {
360 + return $element;
361 + }
362 + $poster = $poster_m[2];
363 +
364 + // Source: the src attribute, else the first <source src="…"> child.
365 + $src = '';
366 + if ( preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#i', $tag, $src_m ) ) {
367 + $src = $src_m[2];
368 + } elseif ( preg_match( '#<source\b[^>]*\bsrc\s*=\s*(["\'])(.*?)\1#i', $m[2], $src_m ) ) {
369 + $src = $src_m[2];
370 + }
371 + if ( '' === trim( $src ) ) {
372 + return $element;
373 + }
374 +
375 + $title = '';
376 + if ( preg_match( '#\btitle\s*=\s*(["\'])(.*?)\1#i', $tag, $title_m ) ) {
377 + $title = $title_m[2];
378 + }
379 +
380 + self::$facade_used = true;
381 +
382 + return Video_Facade::render_native( $element, $src, $poster, $title );
262 383 }
263 384
264 385 /**
265 386 * Add an attribute to an opening tag if it isn't already present.
@@ -265,10 +386,269 @@
265 386 * Add an attribute to an opening tag if it isn't already present.
266 387 * Pass $only_if_missing=false to override an existing value (e.g.
267 388 * flipping loading="lazy" → "eager" on the first image).
268 389 */
390 + /**
391 + * Hold an autoplay video's bytes until the element reaches the viewport.
392 + *
393 + * `preload="none"` is ignored for autoplay, so the only way to stop the
394 + * download is to take the source away and give it back later. We move
395 + * `src` to `data-xspeed-src` and drop `autoplay` — a `<video>` with no
396 + * resolvable source fetches nothing — then the script below restores
397 + * both when the element scrolls into view.
398 + *
399 + * Restoring `autoplay` rather than calling play() matters: play() from
400 + * a non-user gesture is refused unless the video is muted, and returns
401 + * a promise whose rejection most callers never handle. Setting the
402 + * attribute lets the browser apply its own autoplay policy exactly as
403 + * it would have on load.
404 + *
405 + * `<source>` children are handled too, since a video with multiple
406 + * formats carries no `src` of its own.
407 + *
408 + * Marked with a data attribute rather than a class so a theme's CSS
409 + * cannot accidentally select — or style away — the deferred state.
410 + */
411 + private static function defer_autoplay_source( string $tag ): string {
412 + // Already processed (a second pass, or another plugin got there).
413 + if ( false !== stripos( $tag, 'data-xspeed-src' ) ) {
414 + return $tag;
415 + }
416 +
417 + $deferred = false;
418 +
419 + // The element's own src, when it has one.
420 + if ( preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#i', $tag, $m ) && '' !== trim( $m[2] ) ) {
421 + $tag = (string) preg_replace(
422 + '#\bsrc\s*=\s*(["\'])(.*?)\1#i',
423 + 'data-xspeed-src="' . esc_attr( $m[2] ) . '"',
424 + $tag,
425 + 1
426 + );
427 + $deferred = true;
428 + }
429 +
430 + if ( ! $deferred ) {
431 + // No src of its own — the <source> children carry it, and those
432 + // are outside this opening tag. Mark the element so the script
433 + // knows to move them, and let it do the work in the DOM where
434 + // the children are actually reachable.
435 + $tag = self::set_attr( $tag, 'data-xspeed-defer-sources', '1' );
436 + }
437 +
438 + // Without this the browser starts fetching the moment a source is
439 + // restored, which is what we want — but it must not autoplay before
440 + // then, and it must not report itself as autoplaying meanwhile.
441 + $tag = (string) preg_replace( '#\sautoplay(?=[\s/>=])#i', ' data-xspeed-autoplay="1"', $tag, 1 );
442 +
443 + // preload="none" as well: belt and braces for the window between
444 + // parse and the observer attaching.
445 + $tag = self::set_attr( $tag, 'preload', 'none' );
446 +
447 + self::$deferred_autoplay = true;
448 +
449 + return $tag;
450 + }
451 +
452 + /**
453 + * Did this response defer at least one autoplay video? Gates the script
454 + * so a page with no such video ships no extra bytes.
455 + *
456 + * @var bool
457 + */
458 + private static $deferred_autoplay = false;
459 +
460 + /** Whether the viewport script needs to be injected into this response. */
461 + public static function needs_autoplay_script(): bool {
462 + return self::$deferred_autoplay || self::$has_deferred_video_markup;
463 + }
464 +
465 + /**
466 + * Page-builder video blocks that render NO <video> tag server-side.
467 + *
468 + * Essential Blocks' advanced-video, and widgets shaped like it, ship a
469 + * plain <div> carrying the file URL in an attribute and let their own JS
470 + * build the player after load. The PHP pass cannot rewrite what is not
471 + * there, so a page of nine such blocks was completely untouched — which
472 + * is exactly the 44 MB case this feature exists for.
473 + *
474 + * We deliberately do NOT rewrite those attributes. They belong to
475 + * another plugin, whose script reads them on init; renaming one is how
476 + * you get a player that silently never appears. Instead we note that
477 + * such markup is present so the restorer ships, and let its
478 + * MutationObserver catch the <video> the block creates — at which point
479 + * it is an ordinary element we can defer like any other.
480 + *
481 + * @var bool
482 + */
483 + private static $has_deferred_video_markup = false;
484 +
485 + /**
486 + * Does this HTML carry a video URL in an attribute rather than a tag?
487 + *
488 + * Matched on the URL, not on any one plugin's attribute name: `data-url`
489 + * is Essential Blocks, but `data-src`, `data-video-url` and others are
490 + * equally common, and a rule keyed to one vendor would miss the rest.
491 + */
492 + private static function detect_attribute_video( string $html ): void {
493 + if ( self::$has_deferred_video_markup ) {
494 + return;
495 + }
496 + if ( preg_match( '#\sdata-[\w-]+\s*=\s*(["\'])[^"\']*\.(?:mp4|webm|m4v|ogv|mov)(?:\?[^"\']*)?\1#i', $html ) ) {
497 + self::$has_deferred_video_markup = true;
498 + }
499 + }
500 +
501 + /**
502 + * Restore the source when the video reaches the viewport.
503 + *
504 + * Dependency-free and tiny, matching Video_Facade::facade_script(). The
505 + * rootMargin starts the fetch slightly before the element is visible so
506 + * playback begins without a visible stall.
507 + */
508 + public static function autoplay_script(): string {
509 + return <<<'JS'
510 +(function(){
511 +var S='video[data-xspeed-src],video[data-xspeed-defer-sources]';
512 +
513 +/*
514 + * Intercept the ASSIGNMENT, because observing the DOM is always too late.
515 + *
516 + * Measured on a live page: a builder's video player creates nine elements
517 + * and sets `src` BEFORE inserting them, so a MutationObserver watching for
518 + * insertions saw zero of them — and the browser had already begun fetching
519 + * by the time any observer could run. The order is: setAttribute('src'),
520 + * then setAttribute('preload','auto'), then insert. Only the first of those
521 + * matters, and it happens off-DOM.
522 + *
523 + * So wrap the two ways a source can be set on a media element and hold the
524 + * value instead of applying it. Nothing else can start a download: a
525 + * <video> with no resolvable source fetches nothing. The value is stored on
526 + * the element and handed back by go() when it reaches the viewport.
527 + *
528 + * Scoped to <video> only. <audio> is small and usually deliberate, and
529 + * touching it would change behaviour nobody complained about.
530 + */
531 +try{
532 +var VP=window.HTMLMediaElement&&HTMLMediaElement.prototype;
533 +var SD=VP&&Object.getOwnPropertyDescriptor(VP,'src');
534 +var hold=function(el,val){
535 +if(el.tagName!=='VIDEO')return false;
536 +if(el.getAttribute('data-xspeed-loaded'))return false; // released: let it through
537 +if(!val)return false;
538 +el.setAttribute('data-xspeed-src',String(val));
539 +el.setAttribute('data-xspeed-adopted','1');
540 +return true;
541 +};
542 +if(SD&&SD.set){
543 +Object.defineProperty(VP,'src',{configurable:true,enumerable:SD.enumerable,
544 +get:function(){return SD.get.call(this);},
545 +set:function(v){if(hold(this,v))return;return SD.set.call(this,v);}});
546 +}
547 +var SA=Element.prototype.setAttribute;
548 +Element.prototype.setAttribute=function(n,v){
549 +if(n==='src'&&hold(this,v))return;
550 +// An eager preload on a held video would re-arm the fetch the moment a
551 +// source comes back; keep it at none until we release it deliberately.
552 +if(n==='preload'&&this.tagName==='VIDEO'&&this.getAttribute('data-xspeed-src')&&v!=='none')
553 +return SA.call(this,'preload','none');
554 +return SA.call(this,n,v);
555 +};
556 +}catch(e){}
557 +function go(v){
558 +if(v.getAttribute('data-xspeed-loaded'))return;
559 +v.setAttribute('data-xspeed-loaded','1');
560 +var s=v.getAttribute('data-xspeed-src');
561 +if(s){v.setAttribute('src',s);v.removeAttribute('data-xspeed-src');}
562 +if(v.getAttribute('data-xspeed-defer-sources')){
563 +var c=v.querySelectorAll('source[data-xspeed-src]');
564 +for(var i=0;i<c.length;i++){c[i].setAttribute('src',c[i].getAttribute('data-xspeed-src'));c[i].removeAttribute('data-xspeed-src');}
565 +v.removeAttribute('data-xspeed-defer-sources');
566 +}
567 +
568 +if(v.getAttribute('data-xspeed-autoplay')){v.setAttribute('autoplay','');v.removeAttribute('data-xspeed-autoplay');}
569 +v.removeAttribute('preload');
570 +// load() picks up the sources we just restored; without it a <video>
571 +// that has already failed to resolve a source will not retry.
572 +if(v.load)v.load();
573 +}
574 +// A multi-format <video> carries no src of its own — the <source> children
575 +// do, and those sit outside the opening tag PHP rewrote. Strip them here,
576 +// as early as this script runs, then restore on intersect like the rest.
577 +function strip(){
578 +var d=document.querySelectorAll('video[data-xspeed-defer-sources]');
579 +for(var i=0;i<d.length;i++){
580 +if(d[i].getAttribute('data-xspeed-loaded'))continue;
581 +var c=d[i].querySelectorAll('source[src]');
582 +for(var j=0;j<c.length;j++){c[j].setAttribute('data-xspeed-src',c[j].getAttribute('src'));c[j].removeAttribute('src');}
583 +if(c.length&&d[i].load)d[i].load();
584 +}
585 +}
586 +// A page-builder block builds its <video> after load, so PHP never saw it
587 +// and it arrives with a live src and autoplay already set. Defer it here,
588 +// the same way the server would have, BEFORE the browser gets far into
589 +// fetching it. Only autoplay videos: anything else is already covered by
590 +// preload="none" and taking a source from a user-controlled player would
591 +// break its own play button.
592 +function adopt(){
593 +// Any JS-built <video> that would fetch on sight — NOT just autoplay.
594 +// Measured on a live page: a builder's video block creates nine elements
595 +// with autoplay=false and preload="auto", so an autoplay-only selector
596 +// skipped every one of them and 40 MB still downloaded. preload="auto" is
597 +// the same eager-fetch instruction by another name, and the server pass
598 +// would have rewritten it to "none" had the element existed in the HTML.
599 +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])');
600 +for(var i=0;i<a.length;i++){
601 +var v=a[i];
602 +v.setAttribute('data-xspeed-adopted','1');
603 +var auto=v.hasAttribute('autoplay');
604 +var s=v.getAttribute('src');
605 +if(s){v.setAttribute('data-xspeed-src',s);v.removeAttribute('src');}
606 +var c=v.querySelectorAll('source[src]');
607 +for(var j=0;j<c.length;j++){c[j].setAttribute('data-xspeed-src',c[j].getAttribute('src'));c[j].removeAttribute('src');}
608 +if(c.length)v.setAttribute('data-xspeed-defer-sources','1');
609 +// Only remember autoplay for the ones that actually had it — restoring it
610 +// on a video the author left click-to-play would start playback nobody
611 +// asked for.
612 +if(auto){v.removeAttribute('autoplay');v.setAttribute('data-xspeed-autoplay','1');}
613 +v.setAttribute('preload','none');
614 +if(v.load)v.load();
615 +}
616 +}
617 +function scan(){
618 +strip();
619 +adopt();
620 +var v=document.querySelectorAll(S);
621 +if(!('IntersectionObserver'in window)){for(var i=0;i<v.length;i++)go(v[i]);return;}
622 +var o=new IntersectionObserver(function(es){
623 +for(var i=0;i<es.length;i++){if(es[i].isIntersecting){go(es[i].target);o.unobserve(es[i].target);}}
624 +},{rootMargin:'200px'});
625 +for(var j=0;j<v.length;j++)o.observe(v[j]);
626 +}
627 +if(document.readyState!=='loading')scan();else document.addEventListener('DOMContentLoaded',scan);
628 +// Players that build their <video> after load (page-builder video blocks)
629 +// must be caught the INSTANT the element lands. A debounce loses the race:
630 +// the browser begins fetching as soon as a src is set, so by the time a
631 +// timer fires the bytes are already committed. adopt() is idempotent and
632 +// cheap (one guarded querySelectorAll), so run it synchronously on every
633 +// mutation and only debounce the fuller scan that attaches observers.
634 +if(window.MutationObserver){
635 +var t;
636 +new MutationObserver(function(){
637 +adopt();
638 +clearTimeout(t);t=setTimeout(scan,200);
639 +}).observe(document.documentElement,{childList:true,subtree:true});
640 +}
641 +})();
642 +JS;
643 + }
644 +
269 645 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';
646 + // Lookbehind, not `\b`: writing `width` onto a tag carrying
647 + // `data-width="800"` matched the DATA attribute and rewrote it to the
648 + // file's intrinsic size — corrupting a slider's own configuration and
649 + // leaving the tag with no real width at all. (#333 review round 3)
650 + $pattern = '#(?<![-\w])' . preg_quote( $name, '#' ) . '\s*=\s*(["\'][^"\']*["\']|\S+)#i';
271 651 if ( preg_match( $pattern, $tag ) ) {
272 652 if ( $only_if_missing ) {
273 653 return $tag;
274 654 }
@@ -288,10 +668,19 @@
288 668 * filesystem when src points at the uploads dir. Skip when we can't
289 669 * resolve cheaply — never block the request on a remote getimagesize.
290 670 */
291 671 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 );
672 + // `\b` sits between `-` and `w`, so a bare `\bwidth=` also matched
673 + // `data-width=` — a slider's own metadata, not a rendered dimension.
674 + // The tag then looked half-sized: apply_dimensions() derived the other
675 + // dimension from the ratio and wrote ONLY that, so a tag carrying
676 + // `data-width="800"` came out with `height="533"` and no width and
677 + // laid out at 41x30. Harmless while the URL never resolved; this
678 + // branch made it resolve, which is what exposed it. Half a pair is
679 + // worse than none, as the docblock below already says.
680 + // (#333 review round 3, issue 2)
681 + $has_w = (bool) preg_match( '#(?<![-\w])width\s*=#i', $tag );
682 + $has_h = (bool) preg_match( '#(?<![-\w])height\s*=#i', $tag );
294 683 if ( $has_w && $has_h ) {
295 684 return $tag;
296 685 }
297 686
@@ -309,12 +698,15 @@
309 698 // on those images (issue #37). Resolve from the src instead, but only
310 699 // when the tag doesn't already tell us it renders at some other size:
311 700 // stamping the intrinsic file size onto a responsive or CSS-sized
312 701 // 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 );
702 + if ( ! self::has_constrained_render( $tag ) ) {
703 + $url = self::resolvable_image_url( $tag );
704 + if ( '' !== $url ) {
705 + $dims = self::dimensions_for_src( $url );
706 + if ( $dims ) {
707 + return self::apply_dimensions( $tag, $dims, $has_w, $has_h );
708 + }
317 709 }
318 710 }
319 711
320 712 // Couldn't resolve. Leave the tag alone — better no dimensions
@@ -322,8 +714,201 @@
322 714 return $tag;
323 715 }
324 716
325 717 /**
718 + * The URL to measure an image by: its real `src`, or the lazy-loading
719 + * attribute holding the URL when `src` is absent or a placeholder.
720 + *
721 + * Page-builder sliders (Essential Blocks among them) ship the image with
722 + * NO `src` at all — the URL lives in `data-lazy`, and their own JS moves
723 + * it across at runtime. Resolving only from `src` left every one of those
724 + * images without dimensions (issue #328, the miss that #37 did not cover:
725 + * that one was about the missing `wp-image-N` class, this one is about the
726 + * URL not being in `src` in the first place).
727 + *
728 + * A placeholder `src` — a data: URI or the 1x1 spacer GIF these libraries
729 + * use — is treated as absent: measuring it would stamp the spacer's size
730 + * onto the tag and CREATE a layout shift.
731 + *
732 + * Note the explicit `(?<![-\w])src` boundary. `\bsrc=` also matches the
733 + * tail of `data-src=` and `data-lazy-src=` (a hyphen is a non-word
734 + * character, so `\b` sits between `-` and `s`), which is why those two
735 + * attributes happened to work before this method existed while `data-lazy`
736 + * and `data-original` did not. Relying on that accident meant the URL a
737 + * tag was measured by depended on how its attribute was spelled.
738 + *
739 + * Pure — unit-tested — EXCEPT when `$may_measure` is true and every
740 + * candidate was refused by name, which is the one branch that touches the
741 + * filesystem. Callers that are themselves arranging a measurement pass
742 + * false; see the note at that branch.
743 + *
744 + * @param string $tag The <img> tag.
745 + * @param bool $may_measure Whether a name-refused URL may be settled by
746 + * reading the file. False for the warm-up
747 + * collector, which would otherwise deadlock.
748 + */
749 + public static function resolvable_image_url( string $tag, bool $may_measure = true ): string {
750 + $src = '';
751 + $named_out = '';
752 + if ( preg_match( '#(?<![-\w])src\s*=\s*["\']([^"\']+)["\']#i', $tag, $m ) ) {
753 + $src = trim( $m[1] );
754 + if ( '' !== $src && ! self::is_placeholder_src( $src ) ) {
755 + return $src;
756 + }
757 + }
758 +
759 + foreach ( array( 'data-lazy', 'data-src', 'data-lazy-src', 'data-original' ) as $attr ) {
760 + // Anchor with a negative lookbehind, not `\b` and not
761 + // whitespace. `\b` sits between `-` and `d`, so a bare
762 + // `\bdata-src=` also matched the TAIL of `x-data-src=` and took
763 + // the wrong image's URL — worse than no size, because it reserves
764 + // a wrongly shaped box and CAUSES the shift.
765 + //
766 + // Requiring whitespace instead was my first fix and it was wrong:
767 + // attributes are not always separated by one (`alt="31"srcset=`
768 + // is valid), so that anchor silently stopped matching and handed
769 + // back a size for a tag the guard should have skipped. The
770 + // lookbehind rejects the same prefixed decoys without depending on
771 + // spacing, and is what `src` already uses two methods below.
772 + // (#333 review rounds 2 and 3, issue 1)
773 + if ( preg_match( '#(?<![-\w])' . preg_quote( $attr, '#' ) . '\s*=\s*["\']([^"\']+)["\']#i', $tag, $m ) ) {
774 + $url = trim( $m[1] );
775 + if ( '' === $url ) {
776 + continue;
777 + }
778 + if ( ! self::is_placeholder_src( $url ) ) {
779 + return $url;
780 + }
781 + // Refused on its NAME. Remember it — if nothing else in the
782 + // tag resolves, the file itself gets the final say below.
783 + if ( '' === $named_out ) {
784 + $named_out = $url;
785 + }
786 + }
787 + }
788 +
789 + // Every candidate was refused on its NAME alone. A name is a guess;
790 + // the file is the fact. Someone who uploads a photograph called
791 + // `placeholder.jpg` — an entirely ordinary thing to find in a media
792 + // library — got no dimensions at all, and neither did any of the
793 + // copies WordPress generates from it, so the layout shift this
794 + // feature removes came straight back for those images with nothing on
795 + // screen to explain why. (#333 review round 2, issue 1)
796 + //
797 + // Only reached when nothing else in the tag resolved, so the cost is a
798 + // lookup that was about to be skipped entirely, never an extra one.
799 + // A genuine stand-in fails this test on its own merits: a data: URI
800 + // never gets here, and a 1x1 spacer measures 1x1.
801 + //
802 + // The lazy attribute is preferred over `src`, matching the order
803 + // above: when a tag carries both, the lazy one names the real image
804 + // and `src` holds the stand-in.
805 + // The warm-up collector passes false here, and must. Deciding this by
806 + // MEASURING is circular for the caller whose whole job is to arrange
807 + // the measurement: remote lookups are gated off until `$warming` is
808 + // true, `$warming` only becomes true inside warm_dimensions(), and
809 + // warm_dimensions() is never reached because this returned ''. A
810 + // remote `placeholder.jpg` — a real photograph on a CDN — was warmable
811 + // before this branch and stopped being, with a failure cached against
812 + // it for good measure. The collector takes the URL the tag offers and
813 + // lets warm_dimensions() be the thing that decides.
814 + // (#333 review round 3, issue 3)
815 + if ( ! $may_measure ) {
816 + return '' !== $named_out ? $named_out : $src;
817 + }
818 +
819 + foreach ( array( $named_out, $src ) as $candidate ) {
820 + if ( '' !== $candidate && self::is_real_image( $candidate ) ) {
821 + return $candidate;
822 + }
823 + }
824 +
825 + return '';
826 + }
827 +
828 + /**
829 + * Does this URL resolve to something too big to be a lazy-load stand-in?
830 + *
831 + * The stand-ins this guards against are 1x1 spacers and inline data: URIs.
832 + * Anything with real extent is a real image, whatever it is called — which
833 + * is what lets a photograph named `placeholder.jpg` keep its dimensions
834 + * while `spacer.gif` still loses them.
835 + *
836 + * Deliberately conservative: an unresolvable URL returns false, so the
837 + * name-based verdict stands and the tag is left alone. Better no
838 + * dimensions than wrong ones. Uses the same resolver (and therefore the
839 + * same cache) as the normal path, so this costs no extra lookup.
840 + */
841 + private static function is_real_image( string $src ): bool {
842 + $dims = self::dimensions_for_src( $src );
843 + if ( ! is_array( $dims ) ) {
844 + return false;
845 + }
846 + // Indexed [ width, height ] — the shape apply_dimensions() consumes.
847 + $w = isset( $dims[0] ) ? (int) $dims[0] : 0;
848 + $h = isset( $dims[1] ) ? (int) $dims[1] : 0;
849 +
850 + // A few pixels either way is still a spacer — some libraries ship a
851 + // 2x2 or 4x4 rather than a true 1x1. Anything above that has extent a
852 + // stand-in does not.
853 + return $w > 4 && $h > 4;
854 + }
855 +
856 + /**
857 + * True for the stand-in a lazy-loader parks in `src` until its JS swaps
858 + * the real URL in: an inline data: URI, or a `spacer`/`blank`/`placeholder`
859 + * asset. Measuring one of these would stamp the spacer's dimensions onto
860 + * the tag. Pure — unit-tested.
861 + *
862 + * Matched on the WHOLE filename stem, not a word inside it. A word-boundary
863 + * search anywhere in the last segment caught every real image whose name
864 + * merely contains one of these ordinary words — `blank-space-cover.png`,
865 + * `placeholder-portrait.png`, `spacer-hero-banner.jpg` — and silently
866 + * stopped sizing them, which brings back the very layout shift this
867 + * feature exists to prevent, with nothing on screen to explain it
868 + * (#333 review, issue 1).
869 + *
870 + * A real stand-in is named for what it is and nothing else: `blank.gif`,
871 + * `spacer.png`, `lazy-loader.svg`, optionally with a dimension or version
872 + * suffix (`blank-1x1.gif`, `spacer@2x.png`). A descriptive tail is what
873 + * separates a photograph from a spacer, so the tail is what decides.
874 + */
875 + public static function is_placeholder_src( string $src ): bool {
876 + if ( 0 === stripos( $src, 'data:' ) ) {
877 + return true;
878 + }
879 +
880 + // Last path segment, without the query string or fragment —
881 + // `?v=placeholder` is a cache-buster on a real image, not a name.
882 + // Plain string work on purpose: this method is pure and unit-tested
883 + // with no WordPress loaded, so wp_parse_url() is not available.
884 + $path = strtok( $src, '?#' );
885 + if ( ! is_string( $path ) || '' === $path ) {
886 + $path = $src;
887 + }
888 + $name = strtolower( basename( $path ) );
889 +
890 + // Drop the extension, then any trailing dimension/DPR/version marker.
891 + $stem = preg_replace( '#\.[a-z0-9]+$#', '', $name );
892 + $stem = (string) preg_replace( '#[-_@]?(?:\d+x\d+|\d+x|x\d+|v\d+|\d+)$#', '', (string) $stem );
893 + $stem = trim( $stem, '-_.' );
894 +
895 + return 1 === preg_match( '#^(?:spacer|blank|placeholder|lazy-?loader|transparent|pixel|dummy)$#', $stem );
896 + }
897 +
898 + /**
899 + * True when the tag already declares itself the LCP image via
900 + * `fetchpriority="high"`.
901 + *
902 + * Only "high" counts. `fetchpriority="low"` and `="auto"` say the opposite
903 + * (or nothing), and an image marked low-priority is a perfectly good
904 + * lazy-load candidate. Pure — unit-tested.
905 + */
906 + public static function has_high_fetchpriority( string $tag ): bool {
907 + return 1 === preg_match( '#\bfetchpriority\s*=\s*["\']?high\b#i', $tag );
908 + }
909 +
910 + /**
326 911 * True when the tag says it renders at a size other than the file's
327 912 * intrinsic one — a `srcset`/`sizes` pair (the browser picks a
328 913 * candidate) or an inline width/height style.
329 914 *
@@ -332,9 +917,22 @@
332 917 * own `wp_filter_content_tags()` adds dimensions to responsive
333 918 * images the same way. Pure — unit-tested.
334 919 */
335 920 public static function has_constrained_render( string $tag ): bool {
336 - if ( preg_match( '#\bsrcset\s*=#i', $tag ) || preg_match( '#\bsizes\s*=#i', $tag ) ) {
921 + // `\b` sits between `-` and `s`, so a bare \bsrcset also matched
922 + // `data-srcset` — a lazy attribute the browser has NOT applied yet.
923 + // That made an unset attribute suppress dimensions on exactly the
924 + // slider images this feature exists to size, for the same
925 + // accidental-text-match reason the URL lookup moved away from
926 + // (#333 review, issue 3).
927 + //
928 + // The anchor is a negative lookbehind rather than "start or
929 + // whitespace": HTML does not require a space between attributes, so
930 + // `alt="31"srcset="..."` slipped past a whitespace anchor and this
931 + // guard stopped firing — the tag then got the file's intrinsic size
932 + // stamped on it while the browser rendered a differently-shaped
933 + // srcset candidate. (#333 review round 3, issue 1)
934 + if ( preg_match( '#(?<![-\w])(?:srcset|sizes)\s*=#i', $tag ) ) {
337 935 return true;
338 936 }
339 937 if ( preg_match( '#\bstyle\s*=\s*["\']([^"\']*)["\']#i', $tag, $m ) ) {
340 938 // width/height in the inline style wins over the attribute, so
@@ -345,8 +943,44 @@
345 943 }
346 944
347 945 /** @param int[] $dims [width, height]. */
348 946 private static function apply_dimensions( string $tag, array $dims, bool $has_w, bool $has_h ): string {
947 + // One dimension already present: derive the other from the file's
948 + // real aspect ratio rather than stamping its intrinsic size.
949 + //
950 + // A tag that says width="300" on a 1200x800 file renders 300x200. If
951 + // we wrote height="800" the browser would reserve a box two and a
952 + // half times too tall, then snap when the image painted — CREATING
953 + // the shift this feature exists to remove. Scaling keeps the reserved
954 + // box the shape the image will actually be.
955 + if ( $has_w !== $has_h ) {
956 + if ( $dims[0] <= 0 || $dims[1] <= 0 ) {
957 + return $tag;
958 + }
959 + $from = $has_w ? 'width' : 'height';
960 + $declared = self::attr_int( $tag, $from );
961 + // A declared value we cannot read in pixels (`50%`, `auto`) means
962 + // we do not know the rendered size, so there is no ratio to scale
963 + // from. Stamping the intrinsic size here is exactly the bug this
964 + // branch exists to avoid, so the tag is left alone.
965 + if ( $declared <= 0 ) {
966 + return $tag;
967 + }
968 + if ( $has_w ) {
969 + $height = (int) round( $dims[1] * $declared / $dims[0] );
970 + return $height > 0 ? self::set_attr( $tag, 'height', (string) $height ) : $tag;
971 + }
972 + $width = (int) round( $dims[0] * $declared / $dims[1] );
973 + return $width > 0 ? self::set_attr( $tag, 'width', (string) $width ) : $tag;
974 + }
975 +
976 + // A header that reported 0 for either side is not a measurement. Half
977 + // a dimension pair is worse than none: the browser reserves a box of
978 + // the wrong shape and still shifts when the real image lands.
979 + if ( $dims[0] <= 0 || $dims[1] <= 0 ) {
980 + return $tag;
981 + }
982 +
349 983 if ( ! $has_w ) {
350 984 $tag = self::set_attr( $tag, 'width', (string) $dims[0] );
351 985 }
352 986 if ( ! $has_h ) {
@@ -355,8 +989,32 @@
355 989 return $tag;
356 990 }
357 991
358 992 /**
993 + * Read one numeric attribute off a tag.
994 + *
995 + * Returns 0 for anything that is not a plain number — `width="50%"` and
996 + * `width="auto"` are CSS-ish values whose pixel size we do not know, and
997 + * scaling from them would invent a box rather than reserve one.
998 + *
999 + * @param string $tag The tag.
1000 + * @param string $name Attribute name.
1001 + */
1002 + private static function attr_int( string $tag, string $name ): int {
1003 + // The value must be ENTIRELY digits. Matching a leading run would read
1004 + // `width="50%"` as 50 and scale from a percentage as though it were
1005 + // pixels — inventing a box rather than declining to guess.
1006 + // Lookbehind for the same reason as set_attr(): `\bwidth=` also reads
1007 + // `data-width=`, so a slider's own metadata was scaled from as though
1008 + // it were a rendered dimension.
1009 + if ( ! preg_match( '#(?<![-\w])' . preg_quote( $name, '#' ) . '\s*=\s*(?:"(\d+)"|\'(\d+)\'|(\d+)(?=[\s/>]))#i', $tag, $m ) ) {
1010 + return 0;
1011 + }
1012 + $value = '' !== ( $m[1] ?? '' ) ? $m[1] : ( '' !== ( $m[2] ?? '' ) ? $m[2] : ( $m[3] ?? '' ) );
1013 + return (int) $value;
1014 + }
1015 +
1016 + /**
359 1017 * WordPress names resized files `<name>-WxH.<ext>` — when the suffix is
360 1018 * present it IS the rendered size, resolvable with zero I/O (works for
361 1019 * CDN-hosted copies too). Pure — unit-tested.
362 1020 *
@@ -374,8 +1032,105 @@
374 1032 return null;
375 1033 }
376 1034
377 1035 /**
1036 + * Intrinsic size of an image hosted on another domain.
1037 + *
1038 + * An image the site does not host is still an image whose dimensions
1039 + * decide whether the page jumps while it loads. Refusing to look them up
1040 + * was leaving real layout shift unfixed on any site that embeds media from
1041 + * a CDN, a sister site, or a shared asset host — and telling the owner to
1042 + * go and edit their content, which is not a fix a caching plugin should be
1043 + * proud of.
1044 + *
1045 + * The reason for the old refusal was sound but too broad: a page render
1046 + * must never block on somebody else's server. So this fetches only the
1047 + * first few KB — enough for the header of every format WordPress
1048 + * supports — with a short timeout, and caches the answer (successes AND
1049 + * failures) so a URL is fetched once rather than once per pageview.
1050 + *
1051 + * By default it runs only when something has already warmed the cache
1052 + * off-request (the preloader, a cron pass, WP-CLI). A visitor's request
1053 + * therefore never waits on it. A site that would rather pay the cost
1054 + * inline can opt in:
1055 + *
1056 + * add_filter( 'xspeed_lazy_remote_dimensions_inline', '__return_true' );
1057 + *
1058 + * and one that wants nothing fetched from other hosts at all can opt out:
1059 + *
1060 + * add_filter( 'xspeed_lazy_remote_dimensions', '__return_false' );
1061 + *
1062 + * @param string $src Absolute URL on another host.
1063 + * @return int[]|null [width, height] or null when it cannot be resolved.
1064 + */
1065 + private static function remote_dimensions( string $src ): ?array {
1066 + /**
1067 + * Whether to resolve dimensions for images on other hosts at all.
1068 + *
1069 + * @param bool $enabled Default true.
1070 + * @param string $src The image URL.
1071 + */
1072 + if ( ! apply_filters( 'xspeed_lazy_remote_dimensions', true, $src ) ) {
1073 + return null;
1074 + }
1075 +
1076 + if ( ! function_exists( 'wp_remote_get' ) ) {
1077 + return null;
1078 + }
1079 +
1080 + // Only http(s). A data: or blob: src has no server to ask.
1081 + if ( ! preg_match( '#^https?://#i', $src ) ) {
1082 + return null;
1083 + }
1084 +
1085 + /**
1086 + * Whether a front-end request may perform the fetch itself.
1087 + *
1088 + * Off by default: the whole point of the cache is that a visitor
1089 + * never waits on another host. Warm passes (cron, preloader, CLI)
1090 + * set this true for themselves.
1091 + *
1092 + * @param bool $inline Default false.
1093 + */
1094 + $inline = (bool) apply_filters( 'xspeed_lazy_remote_dimensions_inline', self::$warming );
1095 + if ( ! $inline ) {
1096 + return null;
1097 + }
1098 +
1099 + // 32KB covers the header of JPEG, PNG, GIF, WebP and AVIF. Range is a
1100 + // request, not a guarantee — a server that ignores it sends the whole
1101 + // file, which the timeout still bounds.
1102 + $resp = wp_remote_get(
1103 + $src,
1104 + array(
1105 + 'timeout' => 5,
1106 + 'headers' => array( 'Range' => 'bytes=0-32767' ),
1107 + 'user-agent' => 'xSpeed/dimension-probe',
1108 + )
1109 + );
1110 + if ( is_wp_error( $resp ) ) {
1111 + return null;
1112 + }
1113 + $code = (int) wp_remote_retrieve_response_code( $resp );
1114 + if ( 200 !== $code && 206 !== $code ) {
1115 + return null;
1116 + }
1117 +
1118 + $body = (string) wp_remote_retrieve_body( $resp );
1119 + if ( '' === $body ) {
1120 + return null;
1121 + }
1122 +
1123 + // getimagesizefromstring reads the header out of the bytes we already
1124 + // have — no second request, no temp file.
1125 + $size = @getimagesizefromstring( $body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a truncated or non-image body must degrade to null, not warn.
1126 + if ( is_array( $size ) && ! empty( $size[0] ) && ! empty( $size[1] ) ) {
1127 + return array( (int) $size[0], (int) $size[1] );
1128 + }
1129 + return null;
1130 + }
1131 +
1132 + /**
378 1133 * Resolve dimensions from an image URL, cheapest first:
379 1134 * 1. `-WxH` filename suffix (no I/O).
380 1135 * 2. Intrinsic size of the local file when src is under uploads
381 1136 * (getimagesize on the header — no remote fetches, ever).
@@ -396,12 +1151,12 @@
396 1151 }
397 1152 $uploads = wp_get_upload_dir();
398 1153 $baseurl = isset( $uploads['baseurl'] ) ? (string) $uploads['baseurl'] : '';
399 1154 $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 1155
1156 + // The cache is consulted BEFORE the local/remote split, so a remote
1157 + // image pays its lookup once for the life of the transient rather
1158 + // than once per page render.
404 1159 if ( null === self::$src_dims_cache ) {
405 1160 $stored = get_transient( 'xspeed_img_dims' );
406 1161 self::$src_dims_cache = is_array( $stored ) ? $stored : array();
407 1162 }
@@ -407,29 +1162,52 @@
407 1162 }
408 1163 $key = md5( $src );
409 1164 if ( array_key_exists( $key, self::$src_dims_cache ) ) {
410 1165 $hit = self::$src_dims_cache[ $key ];
411 - return is_array( $hit ) ? $hit : null; // 0 = cached failure.
1166 + if ( is_array( $hit ) ) {
1167 + return $hit;
1168 + }
1169 + // A cached FAILURE, not a cached answer. A front-end render
1170 + // honours it — that is the whole point, one failed lookup must
1171 + // not cost a request on every pageview. A warm pass does NOT:
1172 + // it was asked to resolve these, nothing is waiting on it, and
1173 + // the usual reason for a failure is a moment of bad luck rather
1174 + // than an image that can never be measured.
1175 + //
1176 + // Without this, one slow response poisoned a URL for the life of
1177 + // the transient. It happened on a real site: 15 images cached as
1178 + // failures, and every later warm returned "resolved: 0" while the
1179 + // page kept shifting.
1180 + if ( ! self::$warming || ! self::failure_is_retryable( $hit ) ) {
1181 + return null;
1182 + }
412 1183 }
413 1184
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] );
1185 + $is_local = '' !== $baseurl && '' !== $basedir && 0 === strpos( $src, $baseurl );
1186 +
1187 + $dims = null;
1188 +
1189 + if ( $is_local ) {
1190 + $relative = (string) preg_replace( '/[?#].*$/', '', substr( $src, strlen( $baseurl ) ) );
1191 + if ( false === strpos( $relative, '..' ) ) {
1192 + $file = $basedir . $relative;
1193 + if ( is_file( $file ) ) {
1194 + $size = @getimagesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-image/corrupt file must degrade to null, not warn.
1195 + if ( is_array( $size ) && ! empty( $size[0] ) && ! empty( $size[1] ) ) {
1196 + $dims = array( (int) $size[0], (int) $size[1] );
1197 + }
422 1198 }
423 1199 }
424 - }
425 1200
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 );
1201 + // File not on disk (offloaded originals) — one DB lookup by URL.
1202 + if ( null === $dims && function_exists( 'attachment_url_to_postid' ) ) {
1203 + $id = (int) attachment_url_to_postid( $src );
1204 + if ( $id > 0 ) {
1205 + $dims = self::dimensions_for_attachment( $id );
1206 + }
431 1207 }
1208 + } else {
1209 + $dims = self::remote_dimensions( $src );
432 1210 }
433 1211
434 1212 // Cache success AND failure (0), bounded so the blob can't grow
435 1213 // unbounded on media-heavy sites.
@@ -435,9 +1213,14 @@
435 1213 // unbounded on media-heavy sites.
436 1214 if ( count( self::$src_dims_cache ) >= 500 ) {
437 1215 self::$src_dims_cache = array_slice( self::$src_dims_cache, 250, null, true );
438 1216 }
439 - self::$src_dims_cache[ $key ] = null === $dims ? 0 : $dims;
1217 + // A resolved size is permanent — the file's intrinsic dimensions do
1218 + // not change under the same URL. A failure is a snapshot of one
1219 + // moment, so it is stored as a TIMESTAMP rather than a bare 0 and
1220 + // stops counting after a while. Storing both the same way is what let
1221 + // a transient blip look identical to "this can never be measured".
1222 + self::$src_dims_cache[ $key ] = null === $dims ? time() : $dims;
440 1223 if ( function_exists( 'set_transient' ) ) {
441 1224 set_transient( 'xspeed_img_dims', self::$src_dims_cache, DAY_IN_SECONDS );
442 1225 }
443 1226 return $dims;
@@ -510,8 +1293,99 @@
510 1293 return self::$opts;
511 1294 }
512 1295
513 1296 /**
1297 + * How long a failed lookup is trusted before a warm pass tries again.
1298 + *
1299 + * Long enough that a genuinely unmeasurable URL is not re-fetched on every
1300 + * crawl, short enough that an outage does not cost a day of layout shift.
1301 + */
1302 + private const FAILURE_RETRY_AFTER = 900; // 15 minutes.
1303 +
1304 + /**
1305 + * Whether a stored failure is old enough to be worth retrying.
1306 + *
1307 + * Legacy entries were written as a bare `0` with no timestamp. Those are
1308 + * always retryable: they predate this distinction, and one extra request
1309 + * for each is a far better outcome than leaving a site permanently unable
1310 + * to resolve images it could resolve today.
1311 + *
1312 + * @param mixed $entry Stored cache value.
1313 + */
1314 + private static function failure_is_retryable( $entry ): bool {
1315 + if ( ! is_int( $entry ) || $entry <= 0 ) {
1316 + return true; // legacy `0`, or nonsense — retry.
1317 + }
1318 + return ( time() - $entry ) >= self::FAILURE_RETRY_AFTER;
1319 + }
1320 +
1321 + /**
1322 + * Whether this URL's dimensions are already known (or known-unresolvable).
1323 + *
1324 + * Lets a caller skip URLs that would cost nothing to look up, so a bounded
1325 + * batch spends its budget on images it has not seen. Without this a capped
1326 + * collector re-picks the same first N images every pass — they are always
1327 + * in the same DOM order — and anything past the cap is never resolved at
1328 + * all, however many times the crawl runs.
1329 + *
1330 + * Reads the cache only; never fetches.
1331 + *
1332 + * @param string $src Absolute image URL.
1333 + */
1334 + public static function dimensions_known( string $src ): bool {
1335 + if ( ! function_exists( 'get_transient' ) ) {
1336 + return false;
1337 + }
1338 + if ( null === self::$src_dims_cache ) {
1339 + $stored = get_transient( 'xspeed_img_dims' );
1340 + self::$src_dims_cache = is_array( $stored ) ? $stored : array();
1341 + }
1342 + $key = md5( $src );
1343 + if ( ! array_key_exists( $key, self::$src_dims_cache ) ) {
1344 + return false;
1345 + }
1346 + $hit = self::$src_dims_cache[ $key ];
1347 + if ( is_array( $hit ) ) {
1348 + return true;
1349 + }
1350 + // A failure that has aged out is NOT known — reporting it as known
1351 + // would make the crawl skip the one URL that has become worth
1352 + // retrying.
1353 + return ! self::failure_is_retryable( $hit );
1354 + }
1355 +
1356 + /**
1357 + * Resolve and cache dimensions for a batch of image URLs.
1358 + *
1359 + * Meant for anything running OFF a visitor's request — the preloader
1360 + * crawling the sitemap, a cron pass, `wp xspeed lazy warm-dimensions`.
1361 + * Once warmed, the front end serves the dimensions from cache, so the
1362 + * layout shift is fixed without a single visitor waiting on another host.
1363 + *
1364 + * @param string[] $urls Absolute image URLs.
1365 + * @return int How many were resolved.
1366 + */
1367 + public static function warm_dimensions( array $urls ): int {
1368 + $resolved = 0;
1369 + self::$warming = true;
1370 + try {
1371 + foreach ( array_unique( $urls ) as $url ) {
1372 + if ( ! is_string( $url ) || '' === $url ) {
1373 + continue;
1374 + }
1375 + if ( self::dimensions_for_src( $url ) ) {
1376 + $resolved++;
1377 + }
1378 + }
1379 + } finally {
1380 + // In a finally so a throw mid-batch cannot leave the flag set and
1381 + // silently turn every later front-end render into a fetcher.
1382 + self::$warming = false;
1383 + }
1384 + return $resolved;
1385 + }
1386 +
1387 + /**
514 1388 * Test-only: clear cached opts + counter between assertions.
515 1389 */
516 1390 public static function reset_state(): void {
517 1391 self::$opts = null;
@@ -517,6 +1391,13 @@
517 1391 self::$opts = null;
518 1392 self::$image_counter = 0;
519 1393 self::$src_dims_cache = null;
520 1394 self::$facade_used = false;
1395 + self::$warming = false;
1396 + // Both gate whether the autoplay restorer is printed. Left set, one
1397 + // page carrying a video would make every later response in the same
1398 + // process ship the script — and, worse for the preloader, a warmed
1399 + // page could inherit a decision made for a different URL.
1400 + self::$deferred_autoplay = false;
1401 + self::$has_deferred_video_markup = false;
521 1402 }
522 1403 }