PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.4
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / class-lazy-loader.php

class-lazy-loader.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.2.4, at includes/class-lazy-loader.php

1,191 lines 45.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Lazy_Loader — rewrites img / iframe / video tags in rendered HTML to
4 * add native `loading="lazy"` (or "eager" for above-the-fold) plus
5 * `decoding="async"` on images. Also auto-adds missing width/height
6 * attributes to prevent CLS.
7 *
8 * Why regex instead of DOMDocument:
9 * - DOMDocument forces a full HTML5 parse round trip per filter call;
10 * on a content-heavy post that's measurably slow. Regex over the
11 * specific tags is ~10× faster.
12 * - We don't need full DOM understanding — every rewrite is a tag-
13 * local attribute injection. Regex is sufficient + predictable.
14 * - Edge cases (img inside HTML comments, img in <script>) are rare
15 * in real post content; we leave those alone with a pre-pass that
16 * stubs out script / style / pre blocks before rewriting.
17 *
18 * @package XSpeed
19 */
20
21 declare(strict_types=1);
22
23 namespace XSpeed;
24
25 defined( 'ABSPATH' ) || exit;
26
27 final class Lazy_Loader {
28
29 /**
30 * In-process counter for above-the-fold skipping. Reset by
31 * process_html on every call so a fresh post starts at 0.
32 *
33 * @var int
34 */
35 private static $image_counter = 0;
36
37 /**
38 * Settings cache (one read per request).
39 *
40 * @var array|null
41 */
42 private static $opts = null;
43
44 /**
45 * Per-URL dimension cache (md5(src) => [w,h] | 0 for known-failure),
46 * hydrated from the `xspeed_img_dims` transient once per request.
47 *
48 * @var array<string,mixed>|null
49 */
50 private static $src_dims_cache = null;
51
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 /**
64 * Main entry point: take rendered HTML, return rewritten HTML.
65 * Pure function aside from the static counters.
66 */
67 public static function process_html( string $html ): string {
68 if ( '' === $html ) {
69 return $html;
70 }
71 $opts = self::opts();
72
73 // NOTE: the eager-load budget counter is NOT reset here. process_html
74 // runs once per filter pass — the_content, post_thumbnail_html, and
75 // once per get_avatar — so resetting per call let the featured image,
76 // the first content image, AND every comment avatar each claim an
77 // "eager" slot, defeating the budget. The counter is reset once per
78 // page render via reset_state() on template_redirect, so it now
79 // accumulates across all passes as intended. (FBS-82172 Bug 1)
80
81 // Stub out <script>, <style>, <noscript>, <pre>, <code> blocks
82 // so img tags embedded in them as text examples aren't
83 // rewritten. Restore after pass.
84 [ $work, $stubs ] = self::stub_safe_blocks( $html );
85
86 // Tag matcher that respects quoted attribute values, so a ">" inside
87 // an attribute (e.g. alt="a > b") doesn't end the match early and
88 // corrupt the tag. Matches: double-quoted runs, single-quoted runs,
89 // or any non-> char — repeated up to the real closing >.
90 // (FBS-82172 Bug 3)
91 $tag_re = static function ( string $name ): string {
92 return '#<' . $name . '\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i';
93 };
94
95 if ( ! empty( $opts['lazy_images'] ) || ! empty( $opts['add_missing_dimensions'] ) ) {
96 $work = self::apply_pass( $work, $tag_re( 'img' ), array( __CLASS__, 'rewrite_img' ) );
97 }
98 if ( ! empty( $opts['lazy_iframes'] ) ) {
99 $work = self::apply_pass( $work, $tag_re( 'iframe' ), array( __CLASS__, 'rewrite_iframe' ) );
100 }
101 // Facade runs AFTER the lazy pass, deliberately. The facade keeps the
102 // original tag inside <noscript> as the JS-less fallback, and that
103 // fallback should carry loading="lazy" too — running this first would
104 // produce an eager iframe for exactly the visitors least able to
105 // afford one.
106 //
107 // Unlike every other pass here, the facade REPLACES the element
108 // rather than injecting attributes into its opening tag — so it has
109 // to consume the whole element, `</iframe>` included. Matching the
110 // opening tag alone orphaned the closing tag outside the injected
111 // <noscript>, which broke nesting and swallowed sibling content in
112 // real browsers. The body is tempered (`(?!</?iframe\b)`) so an
113 // unclosed iframe can't make the match run on to a LATER embed's
114 // closing tag and eat everything in between; an iframe with no
115 // closing tag simply doesn't match and passes through untouched.
116 if ( ! empty( $opts['video_facade'] ) ) {
117 $work = self::apply_pass(
118 $work,
119 '#(<iframe\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>)((?:(?!</?iframe\b).)*)</iframe\s*>#is',
120 array( __CLASS__, 'rewrite_iframe_facade' )
121 );
122 }
123 if ( ! empty( $opts['lazy_videos'] ) ) {
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 );
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 }
143
144 return self::restore_safe_blocks( $work, $stubs );
145 }
146
147 /**
148 * Run one rewrite pass, keeping the input if PCRE bails.
149 *
150 * preg_replace_callback() returns null when it hits the backtrack or
151 * recursion limit — on a large page that would otherwise blank the
152 * whole document. Returning the untouched HTML costs the optimization
153 * for that request and nothing else.
154 *
155 * @param callable $callback Rewrite callback for one match.
156 */
157 private static function apply_pass( string $html, string $pattern, callable $callback ): string {
158 $result = preg_replace_callback( $pattern, $callback, $html );
159
160 return is_string( $result ) ? $result : $html;
161 }
162
163 private static function rewrite_img( array $m ): string {
164 $tag = $m[0];
165 $opts = self::opts();
166
167 // Explicit skip flag, or matches an exclusion pattern: opt OUT of
168 // LAZY-LOADING only. Dimension injection (CLS protection) still
169 // applies — excluding an above-the-fold hero/logo from lazy-load is
170 // exactly when you most want its width/height kept. Previously both
171 // of these returned early, silently stripping dimensions too.
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)
184 $skip_lazy = false !== stripos( $tag, 'data-skip-lazy' )
185 || false !== stripos( $tag, 'data-no-lazy' )
186 || self::has_high_fetchpriority( $tag )
187 || self::is_excluded( $tag, $opts );
188
189 if ( $skip_lazy && ! empty( $opts['lazy_images'] ) ) {
190 // An EXCLUDED image is one the user marked as above-the-fold (a
191 // hero/logo) — the opposite of lazy. WordPress core adds
192 // `loading="lazy"` to images by default (since 5.5), so merely
193 // *skipping* our lazy pass would leave core's lazy attribute on
194 // the LCP hero and tank LCP. Actively make it eager +
195 // high-priority so an excluded hero loads immediately.
196 $tag = self::set_attr( $tag, 'loading', 'eager' );
197 $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
198 $tag = self::set_attr( $tag, 'decoding', 'async', true );
199 } elseif ( ! empty( $opts['lazy_images'] ) ) {
200 // Above-the-fold skip: first N images get loading="eager"
201 // instead of "lazy" so the LCP image isn't deferred. Only
202 // non-excluded images consume the budget.
203 self::$image_counter++;
204 $is_above_fold = self::$image_counter <= max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) );
205 $tag = self::set_attr( $tag, 'loading', $is_above_fold ? 'eager' : 'lazy' );
206 $tag = self::set_attr( $tag, 'decoding', 'async', true );
207 // The eager hero should also drop any core `loading="lazy"`; the
208 // set_attr above already overrode it. Give the first eager image
209 // high fetch priority so it wins the LCP race.
210 if ( $is_above_fold ) {
211 $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
212 }
213 }
214
215 if ( ! empty( $opts['add_missing_dimensions'] ) ) {
216 $tag = self::ensure_dimensions( $tag );
217 }
218
219 return $tag;
220 }
221
222 private static function rewrite_iframe( array $m ): string {
223 $tag = $m[0];
224 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
225 return $tag;
226 }
227 if ( self::is_excluded( $tag, self::opts() ) ) {
228 return $tag;
229 }
230 return self::set_attr( $tag, 'loading', 'lazy' );
231 }
232
233 /**
234 * Swap a recognised video embed for a click-to-play facade.
235 *
236 * Passes the element through untouched unless it is a provider we can
237 * build a facade for — an unknown iframe (a map, a form, a dashboard)
238 * must never be replaced by a play button.
239 *
240 * $m[0] is the WHOLE element (`<iframe …>…</iframe>`); $m[1] is just
241 * the opening tag. Attributes are read from the opening tag, but what
242 * goes into the <noscript> fallback — and what is returned on every
243 * bail-out path — is the whole element, so the closing tag is never
244 * left stranded outside it.
245 */
246 private static function rewrite_iframe_facade( array $m ): string {
247 $element = $m[0];
248 $tag = $m[1];
249
250 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
251 return $element;
252 }
253 if ( self::is_excluded( $tag, self::opts() ) ) {
254 return $element;
255 }
256
257 if ( ! preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#i', $tag, $src_m ) ) {
258 return $element;
259 }
260 $src = $src_m[2];
261
262 $embed = Video_Facade::parse_embed( $src );
263 if ( null === $embed ) {
264 return $element;
265 }
266
267 $title = '';
268 if ( preg_match( '#\btitle\s*=\s*(["\'])(.*?)\1#i', $tag, $title_m ) ) {
269 $title = $title_m[2];
270 }
271
272 self::$facade_used = true;
273
274 return Video_Facade::render( $element, $embed, $src, $title );
275 }
276
277 /** @var bool True once a facade has been rendered on this page. */
278 private static $facade_used = false;
279
280 /**
281 * True when this render produced at least one facade — the module uses
282 * it to decide whether the click handler is worth printing at all.
283 */
284 public static function facade_used(): bool {
285 return self::$facade_used;
286 }
287
288 private static function rewrite_video( array $m ): string {
289 $tag = $m[0];
290 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
291 return $tag;
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 }
312 // HTML5 `<video>` doesn't support loading=lazy yet (Chromium
313 // won't add it before there's broad support). What we CAN do
314 // is set preload="none" so the browser doesn't pre-fetch the
315 // video bytes until play is requested — that's the actual win
316 // users want from "lazy-load videos".
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;
351 }
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 );
383 }
384
385 /**
386 * Add an attribute to an opening tag if it isn't already present.
387 * Pass $only_if_missing=false to override an existing value (e.g.
388 * flipping loading="lazy" → "eager" on the first image).
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
645 private static function set_attr( string $tag, string $name, string $value, bool $only_if_missing = false ): string {
646 $pattern = '#\b' . preg_quote( $name, '#' ) . '\s*=\s*(["\'][^"\']*["\']|\S+)#i';
647 if ( preg_match( $pattern, $tag ) ) {
648 if ( $only_if_missing ) {
649 return $tag;
650 }
651 return (string) preg_replace( $pattern, $name . '="' . $value . '"', $tag, 1 );
652 }
653 // Inject before the closing > (preserving self-closing `/>` if present).
654 if ( preg_match( '#(/?>)$#', $tag, $m ) ) {
655 $close = $m[1];
656 return substr( $tag, 0, -strlen( $close ) ) . ' ' . $name . '="' . $value . '"' . $close;
657 }
658 return $tag;
659 }
660
661 /**
662 * Attempt to fill in missing width / height from either an attached
663 * media library record (when class="wp-image-N") or from the local
664 * filesystem when src points at the uploads dir. Skip when we can't
665 * resolve cheaply — never block the request on a remote getimagesize.
666 */
667 private static function ensure_dimensions( string $tag ): string {
668 $has_w = (bool) preg_match( '#\bwidth\s*=#i', $tag );
669 $has_h = (bool) preg_match( '#\bheight\s*=#i', $tag );
670 if ( $has_w && $has_h ) {
671 return $tag;
672 }
673
674 // Try wp-image-<id> class first (cheapest path; one DB-cached
675 // get_post_meta call).
676 if ( preg_match( '#\bclass\s*=\s*["\']([^"\']*)["\']#i', $tag, $cm ) && preg_match( '#wp-image-(\d+)#i', $cm[1], $idm ) ) {
677 $dims = self::dimensions_for_attachment( (int) $idm[1] );
678 if ( $dims ) {
679 return self::apply_dimensions( $tag, $dims, $has_w, $has_h );
680 }
681 }
682
683 // No wp-image-N class — page-builder markup (Essential Blocks and
684 // friends) never emits it, which is why the setting silently failed
685 // on those images (issue #37). Resolve from the src instead, but only
686 // when the tag doesn't already tell us it renders at some other size:
687 // stamping the intrinsic file size onto a responsive or CSS-sized
688 // image would CREATE the layout shift this feature exists to remove.
689 if ( ! self::has_constrained_render( $tag ) && preg_match( '#\bsrc\s*=\s*["\']([^"\']+)["\']#i', $tag, $sm ) ) {
690 $dims = self::dimensions_for_src( $sm[1] );
691 if ( $dims ) {
692 return self::apply_dimensions( $tag, $dims, $has_w, $has_h );
693 }
694 }
695
696 // Couldn't resolve. Leave the tag alone — better no dimensions
697 // than wrong ones.
698 return $tag;
699 }
700
701 /**
702 * True when the tag already declares itself the LCP image via
703 * `fetchpriority="high"`.
704 *
705 * Only "high" counts. `fetchpriority="low"` and `="auto"` say the opposite
706 * (or nothing), and an image marked low-priority is a perfectly good
707 * lazy-load candidate. Pure — unit-tested.
708 */
709 public static function has_high_fetchpriority( string $tag ): bool {
710 return 1 === preg_match( '#\bfetchpriority\s*=\s*["\']?high\b#i', $tag );
711 }
712
713 /**
714 * True when the tag says it renders at a size other than the file's
715 * intrinsic one — a `srcset`/`sizes` pair (the browser picks a
716 * candidate) or an inline width/height style.
717 *
718 * Only guards the src-suffix fallback. The `wp-image-N` path stays
719 * unguarded: attachment metadata is authoritative, and WordPress'
720 * own `wp_filter_content_tags()` adds dimensions to responsive
721 * images the same way. Pure — unit-tested.
722 */
723 public static function has_constrained_render( string $tag ): bool {
724 if ( preg_match( '#\bsrcset\s*=#i', $tag ) || preg_match( '#\bsizes\s*=#i', $tag ) ) {
725 return true;
726 }
727 if ( preg_match( '#\bstyle\s*=\s*["\']([^"\']*)["\']#i', $tag, $m ) ) {
728 // width/height in the inline style wins over the attribute, so
729 // the file's intrinsic size would disagree with the layout.
730 return 1 === preg_match( '#(?:^|;)\s*(?:max-)?(?:width|height)\s*:#i', $m[1] );
731 }
732 return false;
733 }
734
735 /** @param int[] $dims [width, height]. */
736 private static function apply_dimensions( string $tag, array $dims, bool $has_w, bool $has_h ): string {
737 // One dimension already present: derive the other from the file's
738 // real aspect ratio rather than stamping its intrinsic size.
739 //
740 // A tag that says width="300" on a 1200x800 file renders 300x200. If
741 // we wrote height="800" the browser would reserve a box two and a
742 // half times too tall, then snap when the image painted — CREATING
743 // the shift this feature exists to remove. Scaling keeps the reserved
744 // box the shape the image will actually be.
745 if ( $has_w !== $has_h ) {
746 if ( $dims[0] <= 0 || $dims[1] <= 0 ) {
747 return $tag;
748 }
749 $from = $has_w ? 'width' : 'height';
750 $declared = self::attr_int( $tag, $from );
751 // A declared value we cannot read in pixels (`50%`, `auto`) means
752 // we do not know the rendered size, so there is no ratio to scale
753 // from. Stamping the intrinsic size here is exactly the bug this
754 // branch exists to avoid, so the tag is left alone.
755 if ( $declared <= 0 ) {
756 return $tag;
757 }
758 if ( $has_w ) {
759 $height = (int) round( $dims[1] * $declared / $dims[0] );
760 return $height > 0 ? self::set_attr( $tag, 'height', (string) $height ) : $tag;
761 }
762 $width = (int) round( $dims[0] * $declared / $dims[1] );
763 return $width > 0 ? self::set_attr( $tag, 'width', (string) $width ) : $tag;
764 }
765
766 // A header that reported 0 for either side is not a measurement. Half
767 // a dimension pair is worse than none: the browser reserves a box of
768 // the wrong shape and still shifts when the real image lands.
769 if ( $dims[0] <= 0 || $dims[1] <= 0 ) {
770 return $tag;
771 }
772
773 if ( ! $has_w ) {
774 $tag = self::set_attr( $tag, 'width', (string) $dims[0] );
775 }
776 if ( ! $has_h ) {
777 $tag = self::set_attr( $tag, 'height', (string) $dims[1] );
778 }
779 return $tag;
780 }
781
782 /**
783 * Read one numeric attribute off a tag.
784 *
785 * Returns 0 for anything that is not a plain number — `width="50%"` and
786 * `width="auto"` are CSS-ish values whose pixel size we do not know, and
787 * scaling from them would invent a box rather than reserve one.
788 *
789 * @param string $tag The tag.
790 * @param string $name Attribute name.
791 */
792 private static function attr_int( string $tag, string $name ): int {
793 // The value must be ENTIRELY digits. Matching a leading run would read
794 // `width="50%"` as 50 and scale from a percentage as though it were
795 // pixels — inventing a box rather than declining to guess.
796 if ( ! preg_match( '#\b' . preg_quote( $name, '#' ) . '\s*=\s*(?:"(\d+)"|\'(\d+)\'|(\d+)(?=[\s/>]))#i', $tag, $m ) ) {
797 return 0;
798 }
799 $value = '' !== ( $m[1] ?? '' ) ? $m[1] : ( '' !== ( $m[2] ?? '' ) ? $m[2] : ( $m[3] ?? '' ) );
800 return (int) $value;
801 }
802
803 /**
804 * WordPress names resized files `<name>-WxH.<ext>` — when the suffix is
805 * present it IS the rendered size, resolvable with zero I/O (works for
806 * CDN-hosted copies too). Pure — unit-tested.
807 *
808 * @return int[]|null [width, height] or null.
809 */
810 public static function parse_size_suffix( string $src ): ?array {
811 $path = (string) preg_replace( '/[?#].*$/', '', $src );
812 if ( preg_match( '#-(\d{1,4})x(\d{1,4})\.(?:jpe?g|png|gif|webp|avif)$#i', $path, $m ) ) {
813 $w = (int) $m[1];
814 $h = (int) $m[2];
815 if ( $w > 0 && $h > 0 ) {
816 return array( $w, $h );
817 }
818 }
819 return null;
820 }
821
822 /**
823 * Intrinsic size of an image hosted on another domain.
824 *
825 * An image the site does not host is still an image whose dimensions
826 * decide whether the page jumps while it loads. Refusing to look them up
827 * was leaving real layout shift unfixed on any site that embeds media from
828 * a CDN, a sister site, or a shared asset host — and telling the owner to
829 * go and edit their content, which is not a fix a caching plugin should be
830 * proud of.
831 *
832 * The reason for the old refusal was sound but too broad: a page render
833 * must never block on somebody else's server. So this fetches only the
834 * first few KB — enough for the header of every format WordPress
835 * supports — with a short timeout, and caches the answer (successes AND
836 * failures) so a URL is fetched once rather than once per pageview.
837 *
838 * By default it runs only when something has already warmed the cache
839 * off-request (the preloader, a cron pass, WP-CLI). A visitor's request
840 * therefore never waits on it. A site that would rather pay the cost
841 * inline can opt in:
842 *
843 * add_filter( 'xspeed_lazy_remote_dimensions_inline', '__return_true' );
844 *
845 * and one that wants nothing fetched from other hosts at all can opt out:
846 *
847 * add_filter( 'xspeed_lazy_remote_dimensions', '__return_false' );
848 *
849 * @param string $src Absolute URL on another host.
850 * @return int[]|null [width, height] or null when it cannot be resolved.
851 */
852 private static function remote_dimensions( string $src ): ?array {
853 /**
854 * Whether to resolve dimensions for images on other hosts at all.
855 *
856 * @param bool $enabled Default true.
857 * @param string $src The image URL.
858 */
859 if ( ! apply_filters( 'xspeed_lazy_remote_dimensions', true, $src ) ) {
860 return null;
861 }
862
863 if ( ! function_exists( 'wp_remote_get' ) ) {
864 return null;
865 }
866
867 // Only http(s). A data: or blob: src has no server to ask.
868 if ( ! preg_match( '#^https?://#i', $src ) ) {
869 return null;
870 }
871
872 /**
873 * Whether a front-end request may perform the fetch itself.
874 *
875 * Off by default: the whole point of the cache is that a visitor
876 * never waits on another host. Warm passes (cron, preloader, CLI)
877 * set this true for themselves.
878 *
879 * @param bool $inline Default false.
880 */
881 $inline = (bool) apply_filters( 'xspeed_lazy_remote_dimensions_inline', self::$warming );
882 if ( ! $inline ) {
883 return null;
884 }
885
886 // 32KB covers the header of JPEG, PNG, GIF, WebP and AVIF. Range is a
887 // request, not a guarantee — a server that ignores it sends the whole
888 // file, which the timeout still bounds.
889 $resp = wp_remote_get(
890 $src,
891 array(
892 'timeout' => 5,
893 'headers' => array( 'Range' => 'bytes=0-32767' ),
894 'user-agent' => 'xSpeed/dimension-probe',
895 )
896 );
897 if ( is_wp_error( $resp ) ) {
898 return null;
899 }
900 $code = (int) wp_remote_retrieve_response_code( $resp );
901 if ( 200 !== $code && 206 !== $code ) {
902 return null;
903 }
904
905 $body = (string) wp_remote_retrieve_body( $resp );
906 if ( '' === $body ) {
907 return null;
908 }
909
910 // getimagesizefromstring reads the header out of the bytes we already
911 // have — no second request, no temp file.
912 $size = @getimagesizefromstring( $body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a truncated or non-image body must degrade to null, not warn.
913 if ( is_array( $size ) && ! empty( $size[0] ) && ! empty( $size[1] ) ) {
914 return array( (int) $size[0], (int) $size[1] );
915 }
916 return null;
917 }
918
919 /**
920 * Resolve dimensions from an image URL, cheapest first:
921 * 1. `-WxH` filename suffix (no I/O).
922 * 2. Intrinsic size of the local file when src is under uploads
923 * (getimagesize on the header — no remote fetches, ever).
924 * 3. Attachment lookup by URL (uploads-hosted src only).
925 * Results — including failures — are cached per URL in a bounded
926 * transient so each image pays the lookup once, not per pageview.
927 *
928 * @return int[]|null [width, height] or null.
929 */
930 private static function dimensions_for_src( string $src ): ?array {
931 $suffix = self::parse_size_suffix( $src );
932 if ( $suffix ) {
933 return $suffix;
934 }
935
936 if ( ! function_exists( 'wp_get_upload_dir' ) || ! function_exists( 'get_transient' ) ) {
937 return null;
938 }
939 $uploads = wp_get_upload_dir();
940 $baseurl = isset( $uploads['baseurl'] ) ? (string) $uploads['baseurl'] : '';
941 $basedir = isset( $uploads['basedir'] ) ? (string) $uploads['basedir'] : '';
942
943 // The cache is consulted BEFORE the local/remote split, so a remote
944 // image pays its lookup once for the life of the transient rather
945 // than once per page render.
946 if ( null === self::$src_dims_cache ) {
947 $stored = get_transient( 'xspeed_img_dims' );
948 self::$src_dims_cache = is_array( $stored ) ? $stored : array();
949 }
950 $key = md5( $src );
951 if ( array_key_exists( $key, self::$src_dims_cache ) ) {
952 $hit = self::$src_dims_cache[ $key ];
953 if ( is_array( $hit ) ) {
954 return $hit;
955 }
956 // A cached FAILURE, not a cached answer. A front-end render
957 // honours it — that is the whole point, one failed lookup must
958 // not cost a request on every pageview. A warm pass does NOT:
959 // it was asked to resolve these, nothing is waiting on it, and
960 // the usual reason for a failure is a moment of bad luck rather
961 // than an image that can never be measured.
962 //
963 // Without this, one slow response poisoned a URL for the life of
964 // the transient. It happened on a real site: 15 images cached as
965 // failures, and every later warm returned "resolved: 0" while the
966 // page kept shifting.
967 if ( ! self::$warming || ! self::failure_is_retryable( $hit ) ) {
968 return null;
969 }
970 }
971
972 $is_local = '' !== $baseurl && '' !== $basedir && 0 === strpos( $src, $baseurl );
973
974 $dims = null;
975
976 if ( $is_local ) {
977 $relative = (string) preg_replace( '/[?#].*$/', '', substr( $src, strlen( $baseurl ) ) );
978 if ( false === strpos( $relative, '..' ) ) {
979 $file = $basedir . $relative;
980 if ( is_file( $file ) ) {
981 $size = @getimagesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-image/corrupt file must degrade to null, not warn.
982 if ( is_array( $size ) && ! empty( $size[0] ) && ! empty( $size[1] ) ) {
983 $dims = array( (int) $size[0], (int) $size[1] );
984 }
985 }
986 }
987
988 // File not on disk (offloaded originals) — one DB lookup by URL.
989 if ( null === $dims && function_exists( 'attachment_url_to_postid' ) ) {
990 $id = (int) attachment_url_to_postid( $src );
991 if ( $id > 0 ) {
992 $dims = self::dimensions_for_attachment( $id );
993 }
994 }
995 } else {
996 $dims = self::remote_dimensions( $src );
997 }
998
999 // Cache success AND failure (0), bounded so the blob can't grow
1000 // unbounded on media-heavy sites.
1001 if ( count( self::$src_dims_cache ) >= 500 ) {
1002 self::$src_dims_cache = array_slice( self::$src_dims_cache, 250, null, true );
1003 }
1004 // A resolved size is permanent — the file's intrinsic dimensions do
1005 // not change under the same URL. A failure is a snapshot of one
1006 // moment, so it is stored as a TIMESTAMP rather than a bare 0 and
1007 // stops counting after a while. Storing both the same way is what let
1008 // a transient blip look identical to "this can never be measured".
1009 self::$src_dims_cache[ $key ] = null === $dims ? time() : $dims;
1010 if ( function_exists( 'set_transient' ) ) {
1011 set_transient( 'xspeed_img_dims', self::$src_dims_cache, DAY_IN_SECONDS );
1012 }
1013 return $dims;
1014 }
1015
1016 /**
1017 * @return int[]|null [width, height] or null
1018 */
1019 private static function dimensions_for_attachment( int $attachment_id ): ?array {
1020 if ( ! function_exists( 'wp_get_attachment_metadata' ) ) {
1021 return null;
1022 }
1023 $meta = wp_get_attachment_metadata( $attachment_id );
1024 if ( ! is_array( $meta ) || empty( $meta['width'] ) || empty( $meta['height'] ) ) {
1025 return null;
1026 }
1027 return array( (int) $meta['width'], (int) $meta['height'] );
1028 }
1029
1030 private static function is_excluded( string $tag, array $opts ): bool {
1031 $excluded = $opts['excluded_images'] ?? array();
1032 if ( ! is_array( $excluded ) || empty( $excluded ) ) {
1033 return false;
1034 }
1035 foreach ( $excluded as $pattern ) {
1036 $pattern = (string) $pattern;
1037 if ( '' === $pattern ) {
1038 continue;
1039 }
1040 if ( false !== stripos( $tag, $pattern ) ) {
1041 return true;
1042 }
1043 }
1044 return false;
1045 }
1046
1047 /**
1048 * Replace <script>, <style>, <noscript>, <pre>, <code> blocks with
1049 * placeholder tokens before tag rewriting. Returns [stubbed_html,
1050 * stubs_map]. Restore via restore_safe_blocks().
1051 *
1052 * @return array{0: string, 1: array<string,string>}
1053 */
1054 private static function stub_safe_blocks( string $html ): array {
1055 $stubs = array();
1056 $re = '#<(script|style|noscript|pre|code)\b[^>]*>.*?</\1>#is';
1057 $out = preg_replace_callback(
1058 $re,
1059 static function ( $m ) use ( &$stubs ) {
1060 $key = '<!--XSPEED_LAZY_STUB_' . count( $stubs ) . '-->';
1061 $stubs[ $key ] = $m[0];
1062 return $key;
1063 },
1064 $html
1065 );
1066 return array( (string) $out, $stubs );
1067 }
1068
1069 private static function restore_safe_blocks( string $html, array $stubs ): string {
1070 if ( empty( $stubs ) ) {
1071 return $html;
1072 }
1073 return strtr( $html, $stubs );
1074 }
1075
1076 private static function opts(): array {
1077 if ( null === self::$opts ) {
1078 self::$opts = Settings_Manager::get( 'lazy' );
1079 }
1080 return self::$opts;
1081 }
1082
1083 /**
1084 * How long a failed lookup is trusted before a warm pass tries again.
1085 *
1086 * Long enough that a genuinely unmeasurable URL is not re-fetched on every
1087 * crawl, short enough that an outage does not cost a day of layout shift.
1088 */
1089 private const FAILURE_RETRY_AFTER = 900; // 15 minutes.
1090
1091 /**
1092 * Whether a stored failure is old enough to be worth retrying.
1093 *
1094 * Legacy entries were written as a bare `0` with no timestamp. Those are
1095 * always retryable: they predate this distinction, and one extra request
1096 * for each is a far better outcome than leaving a site permanently unable
1097 * to resolve images it could resolve today.
1098 *
1099 * @param mixed $entry Stored cache value.
1100 */
1101 private static function failure_is_retryable( $entry ): bool {
1102 if ( ! is_int( $entry ) || $entry <= 0 ) {
1103 return true; // legacy `0`, or nonsense — retry.
1104 }
1105 return ( time() - $entry ) >= self::FAILURE_RETRY_AFTER;
1106 }
1107
1108 /**
1109 * Whether this URL's dimensions are already known (or known-unresolvable).
1110 *
1111 * Lets a caller skip URLs that would cost nothing to look up, so a bounded
1112 * batch spends its budget on images it has not seen. Without this a capped
1113 * collector re-picks the same first N images every pass — they are always
1114 * in the same DOM order — and anything past the cap is never resolved at
1115 * all, however many times the crawl runs.
1116 *
1117 * Reads the cache only; never fetches.
1118 *
1119 * @param string $src Absolute image URL.
1120 */
1121 public static function dimensions_known( string $src ): bool {
1122 if ( ! function_exists( 'get_transient' ) ) {
1123 return false;
1124 }
1125 if ( null === self::$src_dims_cache ) {
1126 $stored = get_transient( 'xspeed_img_dims' );
1127 self::$src_dims_cache = is_array( $stored ) ? $stored : array();
1128 }
1129 $key = md5( $src );
1130 if ( ! array_key_exists( $key, self::$src_dims_cache ) ) {
1131 return false;
1132 }
1133 $hit = self::$src_dims_cache[ $key ];
1134 if ( is_array( $hit ) ) {
1135 return true;
1136 }
1137 // A failure that has aged out is NOT known — reporting it as known
1138 // would make the crawl skip the one URL that has become worth
1139 // retrying.
1140 return ! self::failure_is_retryable( $hit );
1141 }
1142
1143 /**
1144 * Resolve and cache dimensions for a batch of image URLs.
1145 *
1146 * Meant for anything running OFF a visitor's request — the preloader
1147 * crawling the sitemap, a cron pass, `wp xspeed lazy warm-dimensions`.
1148 * Once warmed, the front end serves the dimensions from cache, so the
1149 * layout shift is fixed without a single visitor waiting on another host.
1150 *
1151 * @param string[] $urls Absolute image URLs.
1152 * @return int How many were resolved.
1153 */
1154 public static function warm_dimensions( array $urls ): int {
1155 $resolved = 0;
1156 self::$warming = true;
1157 try {
1158 foreach ( array_unique( $urls ) as $url ) {
1159 if ( ! is_string( $url ) || '' === $url ) {
1160 continue;
1161 }
1162 if ( self::dimensions_for_src( $url ) ) {
1163 $resolved++;
1164 }
1165 }
1166 } finally {
1167 // In a finally so a throw mid-batch cannot leave the flag set and
1168 // silently turn every later front-end render into a fetcher.
1169 self::$warming = false;
1170 }
1171 return $resolved;
1172 }
1173
1174 /**
1175 * Test-only: clear cached opts + counter between assertions.
1176 */
1177 public static function reset_state(): void {
1178 self::$opts = null;
1179 self::$image_counter = 0;
1180 self::$src_dims_cache = null;
1181 self::$facade_used = false;
1182 self::$warming = false;
1183 // Both gate whether the autoplay restorer is printed. Left set, one
1184 // page carrying a video would make every later response in the same
1185 // process ship the script — and, worse for the preloader, a warmed
1186 // page could inherit a decision made for a different URL.
1187 self::$deferred_autoplay = false;
1188 self::$has_deferred_video_markup = false;
1189 }
1190 }
1191