PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 All 35 releases
← All changes | includes/class-lazy-loader.php +1721 -39 1.0.9 → 1.4.1 View file →
@@ -34,8 +34,48 @@
34 34 */
35 35 private static $image_counter = 0;
36 36
37 37 /**
38 + * Whether an image on this page already holds fetchpriority="high".
39 + *
40 + * One per page. Every eager image used to get it, so with eager_first_n
41 + * at 3 an accordion's hidden images fetched at High alongside the
42 + * render-blocking CSS and the real LCP image. (#558)
43 + *
44 + * @var bool
45 + */
46 + private static $priority_claimed = false;
47 +
48 + /**
49 + * Byte spans of markup that is hidden on arrival in the chunk the image
50 + * pass is walking, and whether the current tag sits in one.
51 + *
52 + * @var array<int, array{0:int, 1:int}>
53 + */
54 + private static $hidden_ranges = array();
55 +
56 + /** @var bool */
57 + private static $in_hidden = false;
58 +
59 + /**
60 + * Whether a visible excluded image is still to come, so the eager budget
61 + * must not take the high slot first.
62 + *
63 + * @var bool
64 + */
65 + private static $priority_reserved = false;
66 +
67 + /**
68 + * Eager budget for inline-style backgrounds. Separate from images: a
69 + * hero is either an <img> or a background, and the passes run one after
70 + * the other, so one shared counter would hand the budget to whichever
71 + * pass runs first rather than to what sits first on the page.
72 + *
73 + * @var int
74 + */
75 + private static $background_counter = 0;
76 +
77 + /**
38 78 * Settings cache (one read per request).
39 79 *
40 80 * @var array|null
41 81 */
@@ -41,8 +81,27 @@
41 81 */
42 82 private static $opts = null;
43 83
44 84 /**
85 + * Per-URL dimension cache (md5(src) => [w,h] | 0 for known-failure),
86 + * hydrated from the `xspeed_img_dims` transient once per request.
87 + *
88 + * @var array<string,mixed>|null
89 + */
90 + private static $src_dims_cache = null;
91 +
92 + /**
93 + * True while a background pass is resolving dimensions.
94 + *
95 + * Front-end renders read the cache and never fetch; a warm pass is the
96 + * one thing allowed to pay the network cost, because no visitor is
97 + * waiting on it.
98 + *
99 + * @var bool
100 + */
101 + private static $warming = false;
102 +
103 + /**
45 104 * Main entry point: take rendered HTML, return rewritten HTML.
46 105 * Pure function aside from the static counters.
47 106 */
48 107 public static function process_html( string $html ): string {
@@ -73,26 +132,58 @@
73 132 return '#<' . $name . '\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i';
74 133 };
75 134
76 135 if ( ! empty( $opts['lazy_images'] ) || ! empty( $opts['add_missing_dimensions'] ) ) {
77 - $work = preg_replace_callback(
78 - $tag_re( 'img' ),
79 - array( __CLASS__, 'rewrite_img' ),
80 - $work
81 - );
136 + $work = self::apply_img_pass( $work, $tag_re( 'img' ), $opts );
82 137 }
83 138 if ( ! empty( $opts['lazy_iframes'] ) ) {
84 - $work = preg_replace_callback(
85 - $tag_re( 'iframe' ),
86 - array( __CLASS__, 'rewrite_iframe' ),
87 - $work
139 + $work = self::apply_pass( $work, $tag_re( 'iframe' ), array( __CLASS__, 'rewrite_iframe' ) );
140 + }
141 + // Every opening tag is a candidate, so skip the pass on content with
142 + // no url( at all, which is most of it.
143 + if ( ! empty( $opts['lazy_background_images'] ) && false !== stripos( $work, 'url(' ) ) {
144 + $work = self::apply_pass( $work, '#<[a-z][a-z0-9-]*\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i', array( __CLASS__, 'rewrite_background' ) );
145 + }
146 + // Facade runs AFTER the lazy pass, deliberately. The facade keeps the
147 + // original tag inside <noscript> as the JS-less fallback, and that
148 + // fallback should carry loading="lazy" too — running this first would
149 + // produce an eager iframe for exactly the visitors least able to
150 + // afford one.
151 + //
152 + // Unlike every other pass here, the facade REPLACES the element
153 + // rather than injecting attributes into its opening tag — so it has
154 + // to consume the whole element, `</iframe>` included. Matching the
155 + // opening tag alone orphaned the closing tag outside the injected
156 + // <noscript>, which broke nesting and swallowed sibling content in
157 + // real browsers. The body is tempered (`(?!</?iframe\b)`) so an
158 + // unclosed iframe can't make the match run on to a LATER embed's
159 + // closing tag and eat everything in between; an iframe with no
160 + // closing tag simply doesn't match and passes through untouched.
161 + if ( ! empty( $opts['video_facade'] ) ) {
162 + $work = self::apply_facade_pass(
163 + $work,
164 + '#(<iframe\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>)((?:(?!</?iframe\b).)*)</iframe\s*>#is',
165 + array( __CLASS__, 'rewrite_iframe_facade' )
88 166 );
89 167 }
90 168 if ( ! empty( $opts['lazy_videos'] ) ) {
91 - $work = preg_replace_callback(
92 - $tag_re( 'video' ),
93 - array( __CLASS__, 'rewrite_video' ),
94 - $work
169 + $work = self::apply_pass( $work, $tag_re( 'video' ), array( __CLASS__, 'rewrite_video' ) );
170 + // A page builder's video block renders no <video> server-side, so
171 + // the pass above sees nothing to rewrite. Note that such markup is
172 + // here anyway, so the restorer ships and can defer the element the
173 + // block's own script creates. (See detect_attribute_video().)
174 + self::detect_attribute_video( $work );
175 + }
176 + // Self-hosted <video> facade — after the lazy pass for the same
177 + // reason as the iframe facade above: the original element lands in
178 + // <noscript> as the JS-less fallback, and that copy should carry
179 + // preload="none" too. Same whole-element, tempered match so an
180 + // unclosed <video> passes through rather than eating siblings.
181 + if ( ! empty( $opts['video_facade'] ) ) {
182 + $work = self::apply_facade_pass(
183 + $work,
184 + '#(<video\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>)((?:(?!</?video\b).)*)</video\s*>#is',
185 + array( __CLASS__, 'rewrite_video_facade' )
95 186 );
96 187 }
97 188
98 189 return self::restore_safe_blocks( $work, $stubs );
@@ -97,8 +188,288 @@
97 188
98 189 return self::restore_safe_blocks( $work, $stubs );
99 190 }
100 191
192 + /**
193 + * Hidden popup/lightbox templates a facade must never replace into.
194 + *
195 + * A lightbox plugin ships its player iframe in a hidden template div
196 + * and moves that markup into the popup when clicked. Facading the
197 + * template swaps its iframe for the play button, and the popup then
198 + * opens around a button its own CSS only sizes for an iframe — a blank
199 + * modal (found live: EmbedPress's "See it in action", an Essential
200 + * Addons lightbox whose Magnific popup opened empty). The template
201 + * iframe already carries loading="lazy" from the pass above, and a
202 + * hidden lazy iframe never loads until shown — so skipping the facade
203 + * here costs nothing on page load.
204 + */
205 + private const POPUP_TEMPLATE_CLASSES = array(
206 + 'eael-lightbox-popup-window', // Essential Addons lightbox template.
207 + 'mfp-hide', // Magnific Popup inline template.
208 + 'lity-hide', // Lity inline template.
209 + );
210 +
211 + /**
212 + * A facade pass that leaves popup-template containers alone.
213 + *
214 + * Same PCRE-bail contract as apply_pass(). Ranges are byte spans in
215 + * $html; PREG_OFFSET_CAPTURE offsets refer to the original subject, so
216 + * earlier replacements never shift the comparison.
217 + *
218 + * @param callable $callback Rewrite callback taking plain string matches.
219 + */
220 + private static function apply_facade_pass( string $html, string $pattern, callable $callback ): string {
221 + $ranges = self::popup_template_ranges( $html );
222 + if ( empty( $ranges ) ) {
223 + return self::apply_pass( $html, $pattern, $callback );
224 + }
225 +
226 + $result = preg_replace_callback(
227 + $pattern,
228 + static function ( array $m ) use ( $callback, $ranges ): string {
229 + $offset = (int) $m[0][1];
230 + foreach ( $ranges as $range ) {
231 + if ( $offset >= $range[0] && $offset < $range[1] ) {
232 + return (string) $m[0][0]; // Inside a template: untouched.
233 + }
234 + }
235 + return (string) call_user_func(
236 + $callback,
237 + array_map( static fn( $group ): string => (string) $group[0], $m )
238 + );
239 + },
240 + $html,
241 + -1,
242 + $count,
243 + PREG_OFFSET_CAPTURE
244 + );
245 +
246 + return is_string( $result ) ? $result : $html;
247 + }
248 +
249 + /**
250 + * Byte spans of every popup-template container in $html.
251 + *
252 + * The span is found by counting the container's own tag name to its
253 + * balancing close — templates are plain nested divs, so a same-tag
254 + * depth count is enough; a container whose close is never found is
255 + * dropped rather than guessed at (its iframes stay facade-eligible,
256 + * the pre-fix behavior).
257 + *
258 + * @return array<int, array{0:int, 1:int}>
259 + */
260 + private static function popup_template_ranges( string $html ): array {
261 + /**
262 + * Filter the class names marking a hidden popup/lightbox template
263 + * whose contents the video facade must leave alone.
264 + *
265 + * @param string[] $classes
266 + */
267 + $classes = (array) apply_filters( 'xspeed_video_facade_popup_classes', self::POPUP_TEMPLATE_CLASSES );
268 + $classes = array_values(
269 + array_filter(
270 + array_map( 'strval', $classes ),
271 + static fn( string $c ): bool => (bool) preg_match( '/^[A-Za-z0-9_-]+$/', $c )
272 + )
273 + );
274 + if ( empty( $classes ) ) {
275 + return array();
276 + }
277 +
278 + $pattern = '#<(div|section|span|aside)\b[^>]*\bclass\s*=\s*(["\'])[^"\']*(?<![A-Za-z0-9_-])(?:'
279 + . implode( '|', array_map( 'preg_quote', $classes ) )
280 + . ')(?![A-Za-z0-9_-])[^"\']*\2[^>]*>#i';
281 + if ( ! preg_match_all( $pattern, $html, $m, PREG_OFFSET_CAPTURE ) ) {
282 + return array();
283 + }
284 +
285 + $ranges = array();
286 + foreach ( $m[0] as $i => $hit ) {
287 + $start = (int) $hit[1];
288 + $end = self::same_tag_end( $html, $start, strtolower( (string) $m[1][ $i ][0] ) );
289 + if ( null !== $end ) {
290 + $ranges[] = array( $start, $end );
291 + }
292 + }
293 + return $ranges;
294 + }
295 +
296 + /**
297 + * The offset just past the close tag balancing the open tag at $offset,
298 + * counting only $tag's own opens and closes. Null when it never closes.
299 + */
300 + private static function same_tag_end( string $html, int $offset, string $tag ): ?int {
301 + $depth = 0;
302 + $cursor = $offset;
303 + $re = '#<(/?)' . preg_quote( $tag, '#' ) . '\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i';
304 + while ( preg_match( $re, $html, $t, PREG_OFFSET_CAPTURE, $cursor ) ) {
305 + $cursor = (int) $t[0][1] + strlen( (string) $t[0][0] );
306 + if ( '' === $t[1][0] ) {
307 + ++$depth;
308 + } elseif ( --$depth <= 0 ) {
309 + return $cursor;
310 + }
311 + }
312 + return null;
313 + }
314 +
315 + /**
316 + * The <img> pass, which also knows which tags sit in hidden markup.
317 + *
318 + * Hidden spans are only worked out while eager slots remain: past the
319 + * budget every image is lazy anyway, and the scan costs a pass per
320 + * hidden container.
321 + *
322 + * @param array<string,mixed> $opts Lazy settings.
323 + */
324 + private static function apply_img_pass( string $html, string $pattern, array $opts ): string {
325 + // Core picks one content image for fetchpriority="high" at priority
326 + // 12, before this pass. That pick is the page's, so ours stands down.
327 + if ( ! self::$priority_claimed && preg_match( '#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*?(?<![-\w])fetchpriority\s*=\s*["\']?high\b#i', $html ) ) {
328 + self::$priority_claimed = true;
329 + }
330 +
331 + $budget = max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) );
332 + self::$hidden_ranges = ( ! empty( $opts['lazy_images'] ) && ( self::$image_counter < $budget || ! empty( $opts['excluded_images'] ) ) )
333 + ? self::hidden_ranges( $html )
334 + : array();
335 +
336 + // An image the user named in the exclusions is the hero they chose.
337 + // When the chunk holds a visible one, the eager budget leaves the high
338 + // slot to it rather than to an icon printed ahead of it.
339 + if ( ! self::$priority_claimed && ! self::$priority_reserved && ! empty( $opts['lazy_images'] ) && ! empty( $opts['excluded_images'] )
340 + && preg_match_all( '#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i', $html, $imgs, PREG_OFFSET_CAPTURE ) ) {
341 + foreach ( $imgs[0] as $img ) {
342 + if ( ! self::is_excluded( (string) $img[0], $opts ) || self::tag_is_hidden( (string) $img[0], 'img' ) || self::offset_hidden( (int) $img[1] ) ) {
343 + continue;
344 + }
345 + self::$priority_reserved = true;
346 + break;
347 + }
348 + }
349 +
350 + if ( empty( self::$hidden_ranges ) ) {
351 + return self::apply_pass( $html, $pattern, array( __CLASS__, 'rewrite_img' ) );
352 + }
353 +
354 + $ranges = self::$hidden_ranges;
355 + $result = preg_replace_callback(
356 + $pattern,
357 + static function ( array $m ) use ( $ranges ): string {
358 + $offset = (int) $m[0][1];
359 + self::$in_hidden = false;
360 + foreach ( $ranges as $range ) {
361 + if ( $offset >= $range[0] && $offset < $range[1] ) {
362 + self::$in_hidden = true;
363 + break;
364 + }
365 + }
366 + $out = self::rewrite_img( array( (string) $m[0][0] ) );
367 + self::$in_hidden = false;
368 + return $out;
369 + },
370 + $html,
371 + -1,
372 + $count,
373 + PREG_OFFSET_CAPTURE
374 + );
375 + self::$hidden_ranges = array();
376 +
377 + return is_string( $result ) ? $result : $html;
378 + }
379 +
380 + /** Whether a byte offset in the chunk being walked sits in hidden markup. */
381 + private static function offset_hidden( int $offset ): bool {
382 + foreach ( self::$hidden_ranges as $range ) {
383 + if ( $offset >= $range[0] && $offset < $range[1] ) {
384 + return true;
385 + }
386 + }
387 + return false;
388 + }
389 +
390 + /**
391 + * Byte spans of elements the markup itself hides: a closed <details>,
392 + * the `hidden` attribute, or an inline `display:none`.
393 + *
394 + * Only what the tag says. Panels a stylesheet or script hides (most
395 + * accordion and tab blocks) look visible from here.
396 + *
397 + * Pure. Resource_Hints_Processor runs it over the whole page, so an
398 + * image the lazy pass kept out of the high slot doesn't get it back
399 + * from the page-wide pass.
400 + *
401 + * @return array<int, array{0:int, 1:int}>
402 + */
403 + public static function hidden_ranges( string $html ): array {
404 + if ( false === stripos( $html, '<details' ) && false === stripos( $html, 'hidden' ) && ! preg_match( '#display\s*:\s*none#i', $html ) ) {
405 + return array();
406 + }
407 + if ( ! preg_match_all( '#<([a-z][a-z0-9-]*)\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i', $html, $m, PREG_OFFSET_CAPTURE ) ) {
408 + return array();
409 + }
410 +
411 + $ranges = array();
412 + $until = -1;
413 + foreach ( $m[0] as $i => $hit ) {
414 + $start = (int) $hit[1];
415 + if ( $start < $until ) {
416 + continue; // Inside a span already found.
417 + }
418 + $tag = (string) $hit[0];
419 + $name = strtolower( (string) $m[1][ $i ][0] );
420 + if ( 'img' === $name || ! self::tag_is_hidden( $tag, $name ) ) {
421 + continue;
422 + }
423 + $end = self::same_tag_end( $html, $start, $name );
424 + if ( null === $end ) {
425 + continue;
426 + }
427 + $until = $end;
428 + // A closed <details> still shows its own <summary>, the child
429 + // that opens it. A nested <details>' summary further in is not it.
430 + if ( 'details' === $name ) {
431 + $inner = $start + strlen( $tag );
432 + if ( preg_match( '#^\s*<summary\b.*?</summary\s*>#is', substr( $html, $inner, $end - $inner ), $sm ) ) {
433 + $start = $inner + strlen( (string) $sm[0] );
434 + }
435 + }
436 + $ranges[] = array( $start, $end );
437 + }
438 + return $ranges;
439 + }
440 +
441 + /** Pure: whether one opening tag hides its contents. */
442 + public static function tag_is_hidden( string $tag, string $name ): bool {
443 + // Attribute names only. Blanking the quoted values first keeps
444 + // class="is-hidden" and aria-hidden="true" from reading as `hidden`.
445 + $names = (string) preg_replace( '#=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)#', '', $tag );
446 + if ( 'details' === $name ) {
447 + return ! preg_match( '#(?<![-\w])open(?![-\w])#i', $names );
448 + }
449 + if ( preg_match( '#\shidden(?![-\w])#i', $names ) ) {
450 + return true;
451 + }
452 + return preg_match( '#(?<![-\w])style\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', $tag, $style )
453 + && preg_match( '#(?<![-\w])display\s*:\s*none#i', $style[1] );
454 + }
455 +
456 + /**
457 + * Run one rewrite pass, keeping the input if PCRE bails.
458 + *
459 + * preg_replace_callback() returns null when it hits the backtrack or
460 + * recursion limit — on a large page that would otherwise blank the
461 + * whole document. Returning the untouched HTML costs the optimization
462 + * for that request and nothing else.
463 + *
464 + * @param callable $callback Rewrite callback for one match.
465 + */
466 + private static function apply_pass( string $html, string $pattern, callable $callback ): string {
467 + $result = preg_replace_callback( $pattern, $callback, $html );
468 +
469 + return is_string( $result ) ? $result : $html;
470 + }
471 +
101 472 private static function rewrite_img( array $m ): string {
102 473 $tag = $m[0];
103 474 $opts = self::opts();
104 475
@@ -107,12 +478,26 @@
107 478 // applies — excluding an above-the-fold hero/logo from lazy-load is
108 479 // exactly when you most want its width/height kept. Previously both
109 480 // of these returned early, silently stripping dimensions too.
110 481 // (FBS-82172 Bug 2)
482 + //
483 + // `fetchpriority="high"` joins that set: an image carrying it has been
484 + // declared the LCP element by whoever rendered it — WordPress core, the
485 + // theme, a page builder, or our own Resource_Hints_Processor. Lazy-
486 + // loading it contradicts that declaration, because the tag would then
487 + // tell the browser to fetch at top priority AND that it may defer the
488 + // fetch indefinitely. Browsers resolve that in favour of the deferral,
489 + // so the hero arrives late and any layout sized from it (a Kadence hero
490 + // row, for example) reflows when it finally paints — the "sometimes
491 + // broken, sometimes fine" symptom, because it depends on paint timing.
492 + // Treat the hint as authoritative and keep the image eager. (#269)
111 493 $skip_lazy = false !== stripos( $tag, 'data-skip-lazy' )
112 494 || false !== stripos( $tag, 'data-no-lazy' )
495 + || self::has_high_fetchpriority( $tag )
113 496 || self::is_excluded( $tag, $opts );
114 497
498 + $self_hidden = self::tag_is_hidden( $tag, 'img' );
499 +
115 500 if ( $skip_lazy && ! empty( $opts['lazy_images'] ) ) {
116 501 // An EXCLUDED image is one the user marked as above-the-fold (a
117 502 // hero/logo) — the opposite of lazy. WordPress core adds
118 503 // `loading="lazy"` to images by default (since 5.5), so merely
@@ -119,24 +504,46 @@
119 504 // *skipping* our lazy pass would leave core's lazy attribute on
120 505 // the LCP hero and tank LCP. Actively make it eager +
121 506 // high-priority so an excluded hero loads immediately.
122 507 $tag = self::set_attr( $tag, 'loading', 'eager' );
123 - $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
508 + // Still one high image per page: every excluded logo and
509 + // data-skip-lazy icon used to get it too. A data-skip-lazy icon
510 + // printed ahead of a reserved excluded hero leaves the slot to
511 + // the hero, as the eager budget does. (#558)
512 + $may_take = ! self::$priority_reserved || self::is_excluded( $tag, $opts );
513 + if ( ! self::$priority_claimed && ! self::$in_hidden && ! $self_hidden && $may_take ) {
514 + $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
515 + }
516 + if ( self::has_high_fetchpriority( $tag ) ) {
517 + self::$priority_claimed = true;
518 + }
124 519 $tag = self::set_attr( $tag, 'decoding', 'async', true );
520 + } elseif ( ! empty( $opts['lazy_images'] ) && $self_hidden ) {
521 + // A display:none image (a tracking pixel, most often) is not above
522 + // the fold, so it must not take the hero's eager slot. Its loading
523 + // is left alone: a lazy display:none image never loads, and the
524 + // pixel would stop counting. (#558)
525 + $tag = self::set_attr( $tag, 'decoding', 'async', true );
125 526 } elseif ( ! empty( $opts['lazy_images'] ) ) {
126 527 // Above-the-fold skip: first N images get loading="eager"
127 528 // instead of "lazy" so the LCP image isn't deferred. Only
128 - // non-excluded images consume the budget.
129 - self::$image_counter++;
130 - $is_above_fold = self::$image_counter <= max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) );
131 - $tag = self::set_attr( $tag, 'loading', $is_above_fold ? 'eager' : 'lazy' );
132 - $tag = self::set_attr( $tag, 'decoding', 'async', true );
133 - // The eager hero should also drop any core `loading="lazy"`; the
134 - // set_attr above already overrode it. Give the first eager image
135 - // high fetch priority so it wins the LCP race.
136 - if ( $is_above_fold ) {
137 - $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
529 + // non-excluded images consume the budget, and an image in
530 + // markup that is hidden on arrival is not above the fold. (#558)
531 + $is_above_fold = false;
532 + if ( ! self::$in_hidden ) {
533 + self::$image_counter++;
534 + $is_above_fold = self::$image_counter <= max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) );
138 535 }
536 + $tag = self::set_attr( $tag, 'loading', $is_above_fold ? 'eager' : 'lazy' );
537 + $tag = self::set_attr( $tag, 'decoding', 'async', true );
538 + // Only the first eager image gets high priority. The rest of the
539 + // eager budget skips lazy loading and no more: more than one
540 + // High image competes with the render-blocking CSS, and the LCP
541 + // is rarely the third image in the document. (#558)
542 + if ( $is_above_fold && ! self::$priority_claimed && ! self::$priority_reserved ) {
543 + $tag = self::set_attr( $tag, 'fetchpriority', 'high', true );
544 + self::$priority_claimed = true;
545 + }
139 546 }
140 547
141 548 if ( ! empty( $opts['add_missing_dimensions'] ) ) {
142 549 $tag = self::ensure_dimensions( $tag );
@@ -144,8 +551,103 @@
144 551
145 552 return $tag;
146 553 }
147 554
555 + /** Class that holds an element's background back until it nears the viewport. */
556 + public const LAZY_BG_CLASS = 'xspeed-lazy-bg';
557 +
558 + private static function rewrite_background( array $m ): string {
559 + $tag = $m[0];
560 + if ( false === stripos( $tag, 'url(' ) ) {
561 + return $tag;
562 + }
563 + if ( ! preg_match( '#\sstyle\s*=\s*(?:"([^"]*)"|\'([^\']*)\')#i', $tag, $sm ) ) {
564 + return $tag;
565 + }
566 + $style = html_entity_decode( isset( $sm[2] ) && '' !== $sm[2] ? $sm[2] : $sm[1], ENT_QUOTES );
567 + if ( ! self::has_deferrable_background( $style ) ) {
568 + return $tag;
569 + }
570 + $opts = self::opts();
571 + if ( false !== stripos( $tag, 'data-skip-lazy' )
572 + || false !== stripos( $tag, 'data-no-lazy' )
573 + || self::is_excluded( $tag, $opts ) ) {
574 + return $tag;
575 + }
576 + self::$background_counter++;
577 + if ( self::$background_counter <= max( 0, (int) ( $opts['eager_first_n'] ?? 1 ) ) ) {
578 + return $tag;
579 + }
580 + return self::add_class( $tag, self::LAZY_BG_CLASS );
581 + }
582 +
583 + /**
584 + * Whether an inline style sets a background image we can hold back.
585 + *
586 + * The hold is a stylesheet rule with !important, which beats a normal
587 + * inline declaration but loses to an inline !important one; those are
588 + * left alone. data: URIs cost no request, so there is nothing to save.
589 + */
590 + public static function has_deferrable_background( string $style ): bool {
591 + $found = false;
592 + foreach ( explode( ';', $style ) as $decl ) {
593 + if ( ! preg_match( '#^\s*background(?:-image)?\s*:(.*)$#is', $decl, $dm ) ) {
594 + continue;
595 + }
596 + $value = $dm[1];
597 + if ( false !== stripos( $value, '!important' ) ) {
598 + return false;
599 + }
600 + if ( preg_match( '#url\(\s*[\'"]?(?!data:)[^)\s\'"]#i', $value ) ) {
601 + $found = true;
602 + }
603 + }
604 + return $found;
605 + }
606 +
607 + private static function add_class( string $tag, string $class ): string {
608 + if ( preg_match( '#\sclass\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'>=`]+))#i', $tag, $cm, PREG_OFFSET_CAPTURE ) ) {
609 + // An unquoted value (class=hero) is rewritten as a quoted one.
610 + // Adding a second class attribute would lose it: browsers keep
611 + // the first of two duplicates.
612 + if ( isset( $cm[3] ) && -1 !== $cm[3][1] ) {
613 + $group = $cm[3];
614 + return substr( $tag, 0, $group[1] ) . '"' . $group[0] . ' ' . $class . '"' . substr( $tag, $group[1] + strlen( $group[0] ) );
615 + }
616 + $group = isset( $cm[2] ) && -1 !== $cm[2][1] ? $cm[2] : $cm[1];
617 + $value = trim( $group[0] . ' ' . $class );
618 + return substr( $tag, 0, $group[1] ) . $value . substr( $tag, $group[1] + strlen( $group[0] ) );
619 + }
620 + return (string) preg_replace( '#^<([a-z][a-z0-9-]*)#i', '<$1 class="' . $class . '"', $tag, 1 );
621 + }
622 +
623 + /**
624 + * Scoped to a class the script puts on <html>, so without JavaScript the
625 + * rule never matches and every background loads normally.
626 + */
627 + public static function background_style(): string {
628 + return '.xspeed-lazy-bg-js .' . self::LAZY_BG_CLASS . '{background-image:none!important}';
629 + }
630 +
631 + /**
632 + * The MutationObserver is what keeps a background from staying blank:
633 + * carousel clones, infinite scroll and builder re-renders insert
634 + * elements carrying the class after DOMContentLoaded, and a one-time
635 + * scan never saw them.
636 + */
637 + public static function background_script(): string {
638 + return <<<'JS'
639 +(function(d,w){var h=d.documentElement,C='xspeed-lazy-bg';h.className+=' xspeed-lazy-bg-js';
640 +function show(el){el.classList.remove(C);}
641 +var io='IntersectionObserver' in w?new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){show(e.target);io.unobserve(e.target);}});},{rootMargin:'300px 0px'}):null;
642 +function watch(el){if(io)io.observe(el);else show(el);}
643 +function scan(r){if(r.classList&&r.classList.contains(C))watch(r);if(r.querySelectorAll){var l=r.querySelectorAll('.'+C);for(var i=0;i<l.length;i++)watch(l[i]);}}
644 +if('MutationObserver' in w)new MutationObserver(function(ms){for(var j=0;j<ms.length;j++){var a=ms[j].addedNodes;for(var i=0;i<a.length;i++)if(a[i].nodeType===1)scan(a[i]);}}).observe(h,{childList:true,subtree:true});
645 +function run(){scan(d);}
646 +if(d.readyState==='loading')d.addEventListener('DOMContentLoaded',run);else run();})(document,window);
647 +JS;
648 + }
649 +
148 650 private static function rewrite_iframe( array $m ): string {
149 651 $tag = $m[0];
150 652 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
151 653 return $tag;
@@ -155,22 +657,168 @@
155 657 }
156 658 return self::set_attr( $tag, 'loading', 'lazy' );
157 659 }
158 660
661 + /**
662 + * Swap a recognised video embed for a click-to-play facade.
663 + *
664 + * Passes the element through untouched unless it is a provider we can
665 + * build a facade for — an unknown iframe (a map, a form, a dashboard)
666 + * must never be replaced by a play button.
667 + *
668 + * $m[0] is the WHOLE element (`<iframe …>…</iframe>`); $m[1] is just
669 + * the opening tag. Attributes are read from the opening tag, but what
670 + * goes into the <noscript> fallback — and what is returned on every
671 + * bail-out path — is the whole element, so the closing tag is never
672 + * left stranded outside it.
673 + */
674 + private static function rewrite_iframe_facade( array $m ): string {
675 + $element = $m[0];
676 + $tag = $m[1];
677 +
678 + if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
679 + return $element;
680 + }
681 + if ( self::is_excluded( $tag, self::opts() ) ) {
682 + return $element;
683 + }
684 +
685 + if ( ! preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#i', $tag, $src_m ) ) {
686 + return $element;
687 + }
688 + $src = $src_m[2];
689 +
690 + $embed = Video_Facade::parse_embed( $src );
691 + if ( null === $embed ) {
692 + return $element;
693 + }
694 +
695 + $title = '';
696 + if ( preg_match( '#\btitle\s*=\s*(["\'])(.*?)\1#i', $tag, $title_m ) ) {
697 + $title = $title_m[2];
698 + }
699 +
700 + self::$facade_used = true;
701 +
702 + return Video_Facade::render( $element, $embed, $src, $title );
703 + }
704 +
705 + /** @var bool True once a facade has been rendered on this page. */
706 + private static $facade_used = false;
707 +
708 + /**
709 + * True when this render produced at least one facade — the module uses
710 + * it to decide whether the click handler is worth printing at all.
711 + */
712 + public static function facade_used(): bool {
713 + return self::$facade_used;
714 + }
715 +
159 716 private static function rewrite_video( array $m ): string {
160 717 $tag = $m[0];
161 - if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
718 + // data-no-lazy is the other common opt-out spelling; the restorer
719 + // honours both for players that build their <video> from script.
720 + if ( false !== stripos( $tag, 'data-skip-lazy' ) || false !== stripos( $tag, 'data-no-lazy' ) ) {
162 721 return $tag;
163 722 }
723 + if ( self::is_excluded( $tag, self::opts() ) ) {
724 + return $tag;
725 + }
726 + /*
727 + * An autoplaying video is the one case preload="none" cannot help:
728 + * browsers fetch an autoplay source regardless of preload, because
729 + * the author asked for it to start on its own. Setting the attribute
730 + * would only make the markup lie about what happens.
731 + *
732 + * But "starts on its own" does not mean "must download before the
733 + * visitor has scrolled anywhere near it". A page of nine autoplay
734 + * demo clips pulled 44 MB on load and held the browser's loading
735 + * indicator open for 33 s, while none of them were on screen.
736 + *
737 + * So defer the SOURCE and restore it when the element reaches the
738 + * viewport, which is the first moment autoplay is meant to be
739 + * visible anyway. The author's choice is honoured — the video still
740 + * plays by itself — it simply costs nothing until it can be seen.
741 + */
742 + if ( preg_match( '#\sautoplay(?=[\s/>=])#i', $tag ) ) {
743 + return self::defer_autoplay_source( $tag );
744 + }
164 745 // HTML5 `<video>` doesn't support loading=lazy yet (Chromium
165 746 // won't add it before there's broad support). What we CAN do
166 747 // is set preload="none" so the browser doesn't pre-fetch the
167 748 // video bytes until play is requested — that's the actual win
168 749 // users want from "lazy-load videos".
169 - if ( false === stripos( $tag, 'preload=' ) ) {
170 - $tag = self::set_attr( $tag, 'preload', 'none' );
750 + //
751 + // OVERRIDE an existing value rather than bailing on it: players
752 + // that ship preload="auto" or "metadata" (Elementor's video
753 + // widget, most block themes) are exactly the case this setting
754 + // exists for, and skipping them made it a no-op right where it
755 + // mattered. (#309 — a 924KB MP4 transferred in full on every run
756 + // with this setting on.)
757 + return self::set_attr( $tag, 'preload', 'none' );
758 + }
759 +
760 + /**
761 + * Swap a self-hosted <video> for the click-to-play facade.
762 + *
763 + * The easy case of the facade, not the hard one: no third-party player
764 + * to defer, and the element usually already carries a real poster
765 + * frame. Bails out — element returned untouched — whenever the facade
766 + * would be worse than the video:
767 + *
768 + * - `autoplay` is a deliberate author choice (a hero background); a
769 + * play button in its place changes the page, not just its weight.
770 + * - no `poster` means the facade renders as a blank black box, which
771 + * is worse than the preload="none" the lazy pass already applied.
772 + * - no resolvable source means there is nothing to play on click.
773 + *
774 + * $m[0] is the whole element, $m[1] the opening tag — same contract as
775 + * rewrite_iframe_facade() above, and the same rule: every bail-out
776 + * path returns the WHOLE element so the closing tag is never stranded.
777 + */
778 + private static function rewrite_video_facade( array $m ): string {
779 + $element = $m[0];
780 + $tag = $m[1];
781 +
782 + if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
783 + return $element;
171 784 }
172 - return $tag;
785 + // The lazy pass runs first and renames `autoplay` to
786 + // data-xspeed-autoplay when it defers the source, so check both.
787 + // Checking only the attribute turned every deferred autoplay video
788 + // with a poster (a Kadence row background, a hero) into a play
789 + // button.
790 + if ( preg_match( '#\s(?:data-xspeed-)?autoplay(?=[\s/>=])#i', $tag ) ) {
791 + return $element;
792 + }
793 + if ( self::is_excluded( $tag, self::opts() ) ) {
794 + return $element;
795 + }
796 +
797 + if ( ! preg_match( '#\bposter\s*=\s*(["\'])(.*?)\1#i', $tag, $poster_m ) || '' === trim( $poster_m[2] ) ) {
798 + return $element;
799 + }
800 + $poster = $poster_m[2];
801 +
802 + // Source: the src attribute, else the first <source src="…"> child.
803 + $src = '';
804 + if ( preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#i', $tag, $src_m ) ) {
805 + $src = $src_m[2];
806 + } elseif ( preg_match( '#<source\b[^>]*\bsrc\s*=\s*(["\'])(.*?)\1#i', $m[2], $src_m ) ) {
807 + $src = $src_m[2];
808 + }
809 + if ( '' === trim( $src ) ) {
810 + return $element;
811 + }
812 +
813 + $title = '';
814 + if ( preg_match( '#\btitle\s*=\s*(["\'])(.*?)\1#i', $tag, $title_m ) ) {
815 + $title = $title_m[2];
816 + }
817 +
818 + self::$facade_used = true;
819 +
820 + return Video_Facade::render_native( $element, $src, $poster, $title );
173 821 }
174 822
175 823 /**
176 824 * Add an attribute to an opening tag if it isn't already present.
@@ -176,10 +824,408 @@
176 824 * Add an attribute to an opening tag if it isn't already present.
177 825 * Pass $only_if_missing=false to override an existing value (e.g.
178 826 * flipping loading="lazy" → "eager" on the first image).
179 827 */
828 + /**
829 + * Hold an autoplay video's bytes until the element reaches the viewport.
830 + *
831 + * `preload="none"` is ignored for autoplay, so the only way to stop the
832 + * download is to take the source away and give it back later. We move
833 + * `src` to `data-xspeed-src` and drop `autoplay` — a `<video>` with no
834 + * resolvable source fetches nothing — then the script below restores
835 + * both when the element scrolls into view.
836 + *
837 + * Restoring `autoplay` rather than calling play() matters: play() from
838 + * a non-user gesture is refused unless the video is muted, and returns
839 + * a promise whose rejection most callers never handle. Setting the
840 + * attribute lets the browser apply its own autoplay policy exactly as
841 + * it would have on load.
842 + *
843 + * `<source>` children are handled too, since a video with multiple
844 + * formats carries no `src` of its own.
845 + *
846 + * Marked with a data attribute rather than a class so a theme's CSS
847 + * cannot accidentally select — or style away — the deferred state.
848 + */
849 + private static function defer_autoplay_source( string $tag ): string {
850 + // Already processed (a second pass, or another plugin got there).
851 + if ( false !== stripos( $tag, 'data-xspeed-src' ) ) {
852 + return $tag;
853 + }
854 +
855 + // A background video waits for the visitor's first interaction, not
856 + // just the viewport. It is decoration, and on a hero it is in the
857 + // viewport at once, so the viewport rule loaded it immediately and its
858 + // first frame became the LCP. Held back, the hero text is the LCP and
859 + // the video starts on the first scroll, tap, key or mouse move.
860 + // Checked before autoplay is renamed below.
861 + if ( self::is_background_video_without_poster( $tag ) ) {
862 + /**
863 + * Whether a background video waits for the first interaction.
864 + *
865 + * Return false to load it when it reaches the viewport instead,
866 + * like any other autoplay video.
867 + *
868 + * @param bool $wait Whether the video waits. Default true.
869 + * @param string $tag The <video> opening tag.
870 + */
871 + if ( (bool) apply_filters( 'xspeed_lazy_background_video_waits_for_interaction', true, $tag ) ) {
872 + $tag = self::set_attr( $tag, 'data-xspeed-wait', 'interaction' );
873 + }
874 + }
875 +
876 + $deferred = false;
877 +
878 + // The element's own src, when it has one.
879 + if ( preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#i', $tag, $m ) && '' !== trim( $m[2] ) ) {
880 + $tag = (string) preg_replace(
881 + '#\bsrc\s*=\s*(["\'])(.*?)\1#i',
882 + 'data-xspeed-src="' . esc_attr( $m[2] ) . '"',
883 + $tag,
884 + 1
885 + );
886 + $deferred = true;
887 + }
888 +
889 + if ( ! $deferred ) {
890 + // No src of its own — the <source> children carry it, and those
891 + // are outside this opening tag. Mark the element so the script
892 + // knows to move them, and let it do the work in the DOM where
893 + // the children are actually reachable.
894 + $tag = self::set_attr( $tag, 'data-xspeed-defer-sources', '1' );
895 + }
896 +
897 + // Without this the browser starts fetching the moment a source is
898 + // restored, which is what we want — but it must not autoplay before
899 + // then, and it must not report itself as autoplaying meanwhile.
900 + $tag = (string) preg_replace( '#\sautoplay(?=[\s/>=])#i', ' data-xspeed-autoplay="1"', $tag, 1 );
901 +
902 + // preload="none" as well: belt and braces for the window between
903 + // parse and the observer attaching.
904 + $tag = self::set_attr( $tag, 'preload', 'none' );
905 +
906 + self::$deferred_autoplay = true;
907 +
908 + return $tag;
909 + }
910 +
911 + /**
912 + * Whether a <video> opening tag is a decorative background with no poster.
913 + *
914 + * Autoplay, muted, looping and without controls is how every builder
915 + * marks a background video: nobody watches it, it sits behind the hero
916 + * text. With no poster, nothing paints in its box until the first frame
917 + * decodes, so on a hero the video's first frame becomes the page's LCP.
918 + * On the measured site that was a 5 MB MP4 and a 3.5–3.9 s mobile LCP,
919 + * against 2.3 s with the video out of the way.
920 + *
921 + * Reads autoplay in both spellings: the author's `autoplay`, and the
922 + * `data-xspeed-autoplay` the lazy pass leaves when it defers the source.
923 + * Resource Hints sees the tag after that pass has run.
924 + *
925 + * @param string $tag A <video> opening tag.
926 + */
927 + public static function is_background_video_without_poster( string $tag ): bool {
928 + $has = static function ( string $name ) use ( $tag ): bool {
929 + return (bool) preg_match( '#\s' . $name . '(?=[\s/>=])#i', $tag );
930 + };
931 + if ( ! $has( 'autoplay' ) && ! $has( 'data-xspeed-autoplay' ) ) {
932 + return false;
933 + }
934 + if ( ! $has( 'muted' ) || ! $has( 'loop' ) || $has( 'controls' ) ) {
935 + return false;
936 + }
937 + return ! preg_match( '#\sposter\s*=\s*(?:["\']\s*)?[^"\'\s>]#i', $tag );
938 + }
939 +
940 + /**
941 + * Did this response defer at least one autoplay video? Gates the script
942 + * so a page with no such video ships no extra bytes.
943 + *
944 + * @var bool
945 + */
946 + private static $deferred_autoplay = false;
947 +
948 + /** Whether the viewport script needs to be injected into this response. */
949 + public static function needs_autoplay_script(): bool {
950 + return self::$deferred_autoplay || self::$has_deferred_video_markup;
951 + }
952 +
953 + /**
954 + * Page-builder video blocks that render NO <video> tag server-side.
955 + *
956 + * Essential Blocks' advanced-video, and widgets shaped like it, ship a
957 + * plain <div> carrying the file URL in an attribute and let their own JS
958 + * build the player after load. The PHP pass cannot rewrite what is not
959 + * there, so a page of nine such blocks was completely untouched — which
960 + * is exactly the 44 MB case this feature exists for.
961 + *
962 + * We deliberately do NOT rewrite those attributes. They belong to
963 + * another plugin, whose script reads them on init; renaming one is how
964 + * you get a player that silently never appears. Instead we note that
965 + * such markup is present so the restorer ships, and let its
966 + * MutationObserver catch the <video> the block creates — at which point
967 + * it is an ordinary element we can defer like any other.
968 + *
969 + * @var bool
970 + */
971 + private static $has_deferred_video_markup = false;
972 +
973 + /**
974 + * Does this HTML carry a video URL in an attribute rather than a tag?
975 + *
976 + * Matched on the URL, not on any one plugin's attribute name: `data-url`
977 + * is Essential Blocks, but `data-src`, `data-video-url` and others are
978 + * equally common, and a rule keyed to one vendor would miss the rest.
979 + */
980 + private static function detect_attribute_video( string $html ): void {
981 + if ( self::$has_deferred_video_markup ) {
982 + return;
983 + }
984 + if ( preg_match( '#\sdata-[\w-]+\s*=\s*(["\'])[^"\']*\.(?:mp4|webm|m4v|ogv|mov)(?:\?[^"\']*)?\1#i', $html ) ) {
985 + self::$has_deferred_video_markup = true;
986 + }
987 + }
988 +
989 + /**
990 + * Restore the source when the video reaches the viewport.
991 + *
992 + * Dependency-free and tiny, matching Video_Facade::facade_script(). The
993 + * rootMargin starts the fetch slightly before the element is visible so
994 + * playback begins without a visible stall.
995 + *
996 + * @param bool $after_load Hold autoplay videos until `load` even when they
997 + * are on screen from the start (video_after_load).
998 + * A hero video otherwise starts downloading with
999 + * the page and competes with everything the first
1000 + * paint needs; lab tools also count its bytes
1001 + * toward LCP because the request starts before it.
1002 + * @param string[] $excluded Excluded Images patterns. The server pass
1003 + * leaves a matching <video> alone, but it is still
1004 + * a video[autoplay] to adopt(), so the script has
1005 + * to know them too, and they are the only route
1006 + * for a video a player builds in script.
1007 + */
1008 + public static function autoplay_script( bool $after_load = false, array $excluded = array() ): string {
1009 + $script = <<<'JS'
1010 +(function(){
1011 +var S='video[data-xspeed-src],video[data-xspeed-defer-sources]';
1012 +var AL=0,Q=[],X=[];
1013 +// Autoplay the author asked for, whether the server pass has renamed it yet
1014 +// or not. A player that builds its video in script sets the real attribute.
1015 +function ap(v){return v.hasAttribute('autoplay')||v.hasAttribute('data-xspeed-autoplay');}
1016 +// Matched like the server pass: a case-insensitive substring of the opening
1017 +// tag, plus the source being set, which is not in the tag yet when a player
1018 +// assigns it.
1019 +function excl(v,val){
1020 +if(!X.length)return false;
1021 +var t=(v.outerHTML.split('>')[0]+' '+(val||'')).toLowerCase();
1022 +for(var i=0;i<X.length;i++){if(t.indexOf(X[i])>=0)return true;}
1023 +return false;
1024 +}
1025 +
1026 +/*
1027 + * Intercept the ASSIGNMENT, because observing the DOM is always too late.
1028 + *
1029 + * Measured on a live page: a builder's video player creates nine elements
1030 + * and sets `src` BEFORE inserting them, so a MutationObserver watching for
1031 + * insertions saw zero of them — and the browser had already begun fetching
1032 + * by the time any observer could run. The order is: setAttribute('src'),
1033 + * then setAttribute('preload','auto'), then insert. Only the first of those
1034 + * matters, and it happens off-DOM.
1035 + *
1036 + * So wrap the two ways a source can be set on a media element and hold the
1037 + * value instead of applying it. Nothing else can start a download: a
1038 + * <video> with no resolvable source fetches nothing. The value is stored on
1039 + * the element and handed back by go() when it reaches the viewport.
1040 + *
1041 + * Scoped to <video> only. <audio> is small and usually deliberate, and
1042 + * touching it would change behaviour nobody complained about.
1043 + */
1044 +try{
1045 +var VP=window.HTMLMediaElement&&HTMLMediaElement.prototype;
1046 +var SD=VP&&Object.getOwnPropertyDescriptor(VP,'src');
1047 +var hold=function(el,val){
1048 +if(el.tagName!=='VIDEO')return false;
1049 +if(el.getAttribute('data-xspeed-loaded'))return false; // released: let it through
1050 +if(!val)return false;
1051 +if(el.hasAttribute('data-skip-lazy')||el.hasAttribute('data-no-lazy')||excl(el,val))return false;
1052 +// Already on screen: a player is loading it because it is visible (#548).
1053 +if(el.isConnected&&near(el)&&!(AL&&document.readyState!=='complete'&&ap(el)))return false;
1054 +el.setAttribute('data-xspeed-src',String(val));
1055 +el.setAttribute('data-xspeed-adopted','1');
1056 +return true;
1057 +};
1058 +if(SD&&SD.set){
1059 +Object.defineProperty(VP,'src',{configurable:true,enumerable:SD.enumerable,
1060 +get:function(){return SD.get.call(this);},
1061 +set:function(v){if(hold(this,v))return;return SD.set.call(this,v);}});
1062 +}
1063 +var SA=Element.prototype.setAttribute;
1064 +Element.prototype.setAttribute=function(n,v){
1065 +if(n==='src'&&hold(this,v))return;
1066 +// An eager preload on a held video would re-arm the fetch the moment a
1067 +// source comes back; keep it at none until we release it deliberately.
1068 +if(n==='preload'&&this.tagName==='VIDEO'&&this.getAttribute('data-xspeed-src')&&v!=='none')
1069 +return SA.call(this,'preload','none');
1070 +return SA.call(this,n,v);
1071 +};
1072 +}catch(e){}
1073 +function go(v){
1074 +if(v.getAttribute('data-xspeed-loaded'))return;
1075 +// video_after_load: an autoplay video that is on screen before `load` waits
1076 +// for it. readyState is already 'complete' inside the load handler, so the
1077 +// replay below goes straight through.
1078 +// A video built in script keeps its real autoplay attribute when hold()
1079 +// takes its source, so check both spellings.
1080 +if(AL&&document.readyState!=='complete'&&ap(v)){if(Q.indexOf(v)<0)Q.push(v);return;}
1081 +v.setAttribute('data-xspeed-loaded','1');
1082 +var r=0,s=v.getAttribute('data-xspeed-src');
1083 +if(s){v.setAttribute('src',s);v.removeAttribute('data-xspeed-src');r=1;}
1084 +if(v.getAttribute('data-xspeed-defer-sources')){
1085 +var c=v.querySelectorAll('source[data-xspeed-src]');
1086 +for(var i=0;i<c.length;i++){c[i].setAttribute('src',c[i].getAttribute('data-xspeed-src'));c[i].removeAttribute('data-xspeed-src');r=1;}
1087 +v.removeAttribute('data-xspeed-defer-sources');
1088 +}
1089 +
1090 +if(v.getAttribute('data-xspeed-autoplay')){v.setAttribute('autoplay','');v.removeAttribute('data-xspeed-autoplay');}
1091 +v.removeAttribute('preload');
1092 +// load() picks up the sources we just restored; without it a <video>
1093 +// that has already failed to resolve a source will not retry. Only when
1094 +// something was restored: a player that set its own src on the element
1095 +// meanwhile (Elementor's background video) is already fetching it, and
1096 +// load() would abort that request and start it again.
1097 +if(v.load&&(r||!v.getAttribute('src')))v.load();
1098 +}
1099 +// A multi-format <video> carries no src of its own — the <source> children
1100 +// do, and those sit outside the opening tag PHP rewrote. Strip them here,
1101 +// as early as this script runs, then restore on intersect like the rest.
1102 +function strip(){
1103 +var d=document.querySelectorAll('video[data-xspeed-defer-sources]');
1104 +for(var i=0;i<d.length;i++){
1105 +if(d[i].getAttribute('data-xspeed-loaded'))continue;
1106 +var c=d[i].querySelectorAll('source[src]');
1107 +for(var j=0;j<c.length;j++){c[j].setAttribute('data-xspeed-src',c[j].getAttribute('src'));c[j].removeAttribute('src');}
1108 +if(c.length&&d[i].load)d[i].load();
1109 +}
1110 +}
1111 +// A page-builder block builds its <video> after load, so PHP never saw it
1112 +// and it arrives with a live src and autoplay already set. Defer it here,
1113 +// the same way the server would have, BEFORE the browser gets far into
1114 +// fetching it. Only autoplay videos: anything else is already covered by
1115 +// preload="none" and taking a source from a user-controlled player would
1116 +// break its own play button.
1117 +function adopt(){
1118 +// Any JS-built <video> that would fetch on sight — NOT just autoplay.
1119 +// Measured on a live page: a builder's video block creates nine elements
1120 +// with autoplay=false and preload="auto", so an autoplay-only selector
1121 +// skipped every one of them and 40 MB still downloaded. preload="auto" is
1122 +// the same eager-fetch instruction by another name, and the server pass
1123 +// would have rewritten it to "none" had the element existed in the HTML.
1124 +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])');
1125 +for(var i=0;i<a.length;i++){
1126 +var v=a[i];
1127 +v.setAttribute('data-xspeed-adopted','1');
1128 +// The author's opt-outs and Excluded Images, same as the server pass.
1129 +if(v.hasAttribute('data-skip-lazy')||v.hasAttribute('data-no-lazy')||excl(v))continue;
1130 +// A visible video already in or near the viewport is being started because
1131 +// it is visible — a player with its own lazy loader sets autoplay or preload
1132 +// at exactly that moment (Essential Blocks 6.4.7+). Taking its source then
1133 +// aborted the request and rejected play(). Hold it only when the site asked
1134 +// for autoplay to wait until load and load has not happened yet. (#548)
1135 +if(near(v)&&!(AL&&document.readyState!=='complete'&&ap(v)))continue;
1136 +var auto=v.hasAttribute('autoplay');
1137 +var s=v.getAttribute('src');
1138 +if(s){v.setAttribute('data-xspeed-src',s);v.removeAttribute('src');}
1139 +var c=v.querySelectorAll('source[src]');
1140 +for(var j=0;j<c.length;j++){c[j].setAttribute('data-xspeed-src',c[j].getAttribute('src'));c[j].removeAttribute('src');}
1141 +if(c.length)v.setAttribute('data-xspeed-defer-sources','1');
1142 +// Only remember autoplay for the ones that actually had it — restoring it
1143 +// on a video the author left click-to-play would start playback nobody
1144 +// asked for.
1145 +if(auto){v.removeAttribute('autoplay');v.setAttribute('data-xspeed-autoplay','1');}
1146 +v.setAttribute('preload','none');
1147 +if(v.load)v.load();
1148 +// Watch it now, not on the next full scan: a page whose DOM never goes
1149 +// quiet (typed headings, counters, chat widgets) could otherwise keep a
1150 +// held video blank long after it scrolled into view. (#548)
1151 +watch(v);
1152 +}
1153 +}
1154 +// A background video (data-xspeed-wait) that reaches the viewport is parked
1155 +// here until the visitor first scrolls, taps, types or moves the mouse, then
1156 +// every parked one starts together. See defer_autoplay_source().
1157 +var I=false,P=[],E=['pointerdown','pointermove','touchstart','keydown','wheel','scroll'];
1158 +function interacted(){
1159 +if(I)return;I=true;
1160 +for(var i=0;i<E.length;i++)removeEventListener(E[i],interacted,true);
1161 +for(var j=0;j<P.length;j++)go(P[j]);
1162 +P=[];
1163 +}
1164 +for(var k=0;k<E.length;k++)addEventListener(E[k],interacted,{capture:true,passive:true});
1165 +function reach(v){if(!I&&v.getAttribute('data-xspeed-wait'))P.push(v);else go(v);}
1166 +// In or within 200px of the viewport, and laid out (a hidden popup's video
1167 +// measures 0x0 at the top of the page and must not count as visible).
1168 +function near(v){
1169 +var r=v.getBoundingClientRect();
1170 +if(!r.width&&!r.height)return false;
1171 +var h=window.innerHeight||document.documentElement.clientHeight;
1172 +return r.bottom>-200&&r.top<h+200;
1173 +}
1174 +// One observer for every held video, created on first use.
1175 +var o;
1176 +function io(){
1177 +if(o!==undefined)return o;
1178 +o=('IntersectionObserver'in window)?new IntersectionObserver(function(es){
1179 +for(var i=0;i<es.length;i++){if(es[i].isIntersecting){o.unobserve(es[i].target);reach(es[i].target);}}
1180 +},{rootMargin:'200px'}):null;
1181 +return o;
1182 +}
1183 +function watch(v){var ob=io();if(ob)ob.observe(v);else reach(v);}
1184 +function scan(){
1185 +strip();
1186 +adopt();
1187 +var v=document.querySelectorAll(S);
1188 +for(var j=0;j<v.length;j++)watch(v[j]);
1189 +}
1190 +if(document.readyState!=='loading')scan();else document.addEventListener('DOMContentLoaded',scan);
1191 +// Players that build their <video> after load (page-builder video blocks)
1192 +// must be caught the INSTANT the element lands. A timer loses the race: the
1193 +// browser begins fetching as soon as a src is set. adopt() is idempotent and
1194 +// cheap (one guarded querySelectorAll), so run it synchronously on every
1195 +// mutation. The fuller scan is THROTTLED, not debounced: a debounce reset by
1196 +// every mutation never fired on a page that keeps changing. (#548)
1197 +if(window.MutationObserver){
1198 +var t=0;
1199 +new MutationObserver(function(){
1200 +adopt();
1201 +if(!t)t=setTimeout(function(){t=0;scan();},200);
1202 +}).observe(document.documentElement,{childList:true,subtree:true});
1203 +}
1204 +if(AL)window.addEventListener('load',function(){
1205 +var d=0;function rel(){if(d)return;d=1;var q=Q;Q=[];for(var i=0;i<q.length;i++)go(q[i]);}
1206 +if(window.requestAnimationFrame)requestAnimationFrame(function(){requestAnimationFrame(rel);});
1207 +setTimeout(rel,1500);
1208 +});
1209 +})();
1210 +JS;
1211 + $patterns = array();
1212 + foreach ( $excluded as $pattern ) {
1213 + $pattern = strtolower( trim( (string) $pattern ) );
1214 + if ( '' !== $pattern ) {
1215 + $patterns[] = $pattern;
1216 + }
1217 + }
1218 + $vars = 'var AL=' . ( $after_load ? '1' : '0' ) . ',Q=[],X=' . wp_json_encode( array_values( array_unique( $patterns ) ), JSON_HEX_TAG | JSON_HEX_AMP ) . ';';
1219 + return str_replace( 'var AL=0,Q=[],X=[];', $vars, $script );
1220 + }
1221 +
180 1222 private static function set_attr( string $tag, string $name, string $value, bool $only_if_missing = false ): string {
181 - $pattern = '#\b' . preg_quote( $name, '#' ) . '\s*=\s*(["\'][^"\']*["\']|\S+)#i';
1223 + // Lookbehind, not `\b`: writing `width` onto a tag carrying
1224 + // `data-width="800"` matched the DATA attribute and rewrote it to the
1225 + // file's intrinsic size — corrupting a slider's own configuration and
1226 + // leaving the tag with no real width at all. (#333 review round 3)
1227 + $pattern = '#(?<![-\w])' . preg_quote( $name, '#' ) . '\s*=\s*(["\'][^"\']*["\']|\S+)#i';
182 1228 if ( preg_match( $pattern, $tag ) ) {
183 1229 if ( $only_if_missing ) {
184 1230 return $tag;
185 1231 }
@@ -199,10 +1245,19 @@
199 1245 * filesystem when src points at the uploads dir. Skip when we can't
200 1246 * resolve cheaply — never block the request on a remote getimagesize.
201 1247 */
202 1248 private static function ensure_dimensions( string $tag ): string {
203 - $has_w = (bool) preg_match( '#\bwidth\s*=#i', $tag );
204 - $has_h = (bool) preg_match( '#\bheight\s*=#i', $tag );
1249 + // `\b` sits between `-` and `w`, so a bare `\bwidth=` also matched
1250 + // `data-width=` — a slider's own metadata, not a rendered dimension.
1251 + // The tag then looked half-sized: apply_dimensions() derived the other
1252 + // dimension from the ratio and wrote ONLY that, so a tag carrying
1253 + // `data-width="800"` came out with `height="533"` and no width and
1254 + // laid out at 41x30. Harmless while the URL never resolved; this
1255 + // branch made it resolve, which is what exposed it. Half a pair is
1256 + // worse than none, as the docblock below already says.
1257 + // (#333 review round 3, issue 2)
1258 + $has_w = (bool) preg_match( '#(?<![-\w])width\s*=#i', $tag );
1259 + $has_h = (bool) preg_match( '#(?<![-\w])height\s*=#i', $tag );
205 1260 if ( $has_w && $has_h ) {
206 1261 return $tag;
207 1262 }
208 1263
@@ -210,24 +1265,546 @@
210 1265 // get_post_meta call).
211 1266 if ( preg_match( '#\bclass\s*=\s*["\']([^"\']*)["\']#i', $tag, $cm ) && preg_match( '#wp-image-(\d+)#i', $cm[1], $idm ) ) {
212 1267 $dims = self::dimensions_for_attachment( (int) $idm[1] );
213 1268 if ( $dims ) {
214 - if ( ! $has_w ) {
215 - $tag = self::set_attr( $tag, 'width', (string) $dims[0] );
1269 + return self::apply_dimensions( $tag, $dims, $has_w, $has_h );
1270 + }
1271 + }
1272 +
1273 + // No wp-image-N class — page-builder markup (Essential Blocks and
1274 + // friends) never emits it, which is why the setting silently failed
1275 + // on those images (issue #37). Resolve from the src instead, but only
1276 + // when the tag doesn't already tell us it renders at some other size:
1277 + // stamping the intrinsic file size onto a responsive or CSS-sized
1278 + // image would CREATE the layout shift this feature exists to remove.
1279 + if ( ! self::has_constrained_render( $tag ) ) {
1280 + $url = self::resolvable_image_url( $tag );
1281 + if ( '' !== $url ) {
1282 + $dims = self::dimensions_for_src( $url );
1283 + if ( $dims ) {
1284 + return self::apply_dimensions( $tag, $dims, $has_w, $has_h );
216 1285 }
217 - if ( ! $has_h ) {
218 - $tag = self::set_attr( $tag, 'height', (string) $dims[1] );
1286 + }
1287 + }
1288 +
1289 + // Couldn't resolve. Leave the tag alone — better no dimensions
1290 + // than wrong ones.
1291 + return $tag;
1292 + }
1293 +
1294 + /**
1295 + * The URL to measure an image by: its real `src`, or the lazy-loading
1296 + * attribute holding the URL when `src` is absent or a placeholder.
1297 + *
1298 + * Page-builder sliders (Essential Blocks among them) ship the image with
1299 + * NO `src` at all — the URL lives in `data-lazy`, and their own JS moves
1300 + * it across at runtime. Resolving only from `src` left every one of those
1301 + * images without dimensions (issue #328, the miss that #37 did not cover:
1302 + * that one was about the missing `wp-image-N` class, this one is about the
1303 + * URL not being in `src` in the first place).
1304 + *
1305 + * A placeholder `src` — a data: URI or the 1x1 spacer GIF these libraries
1306 + * use — is treated as absent: measuring it would stamp the spacer's size
1307 + * onto the tag and CREATE a layout shift.
1308 + *
1309 + * Note the explicit `(?<![-\w])src` boundary. `\bsrc=` also matches the
1310 + * tail of `data-src=` and `data-lazy-src=` (a hyphen is a non-word
1311 + * character, so `\b` sits between `-` and `s`), which is why those two
1312 + * attributes happened to work before this method existed while `data-lazy`
1313 + * and `data-original` did not. Relying on that accident meant the URL a
1314 + * tag was measured by depended on how its attribute was spelled.
1315 + *
1316 + * Pure — unit-tested — EXCEPT when `$may_measure` is true and every
1317 + * candidate was refused by name, which is the one branch that touches the
1318 + * filesystem. Callers that are themselves arranging a measurement pass
1319 + * false; see the note at that branch.
1320 + *
1321 + * @param string $tag The <img> tag.
1322 + * @param bool $may_measure Whether a name-refused URL may be settled by
1323 + * reading the file. False for the warm-up
1324 + * collector, which would otherwise deadlock.
1325 + */
1326 + public static function resolvable_image_url( string $tag, bool $may_measure = true ): string {
1327 + $src = '';
1328 + $named_out = '';
1329 + if ( preg_match( '#(?<![-\w])src\s*=\s*["\']([^"\']+)["\']#i', $tag, $m ) ) {
1330 + $src = trim( $m[1] );
1331 + if ( '' !== $src && ! self::is_placeholder_src( $src ) ) {
1332 + return $src;
1333 + }
1334 + }
1335 +
1336 + foreach ( array( 'data-lazy', 'data-src', 'data-lazy-src', 'data-original' ) as $attr ) {
1337 + // Anchor with a negative lookbehind, not `\b` and not
1338 + // whitespace. `\b` sits between `-` and `d`, so a bare
1339 + // `\bdata-src=` also matched the TAIL of `x-data-src=` and took
1340 + // the wrong image's URL — worse than no size, because it reserves
1341 + // a wrongly shaped box and CAUSES the shift.
1342 + //
1343 + // Requiring whitespace instead was my first fix and it was wrong:
1344 + // attributes are not always separated by one (`alt="31"srcset=`
1345 + // is valid), so that anchor silently stopped matching and handed
1346 + // back a size for a tag the guard should have skipped. The
1347 + // lookbehind rejects the same prefixed decoys without depending on
1348 + // spacing, and is what `src` already uses two methods below.
1349 + // (#333 review rounds 2 and 3, issue 1)
1350 + if ( preg_match( '#(?<![-\w])' . preg_quote( $attr, '#' ) . '\s*=\s*["\']([^"\']+)["\']#i', $tag, $m ) ) {
1351 + $url = trim( $m[1] );
1352 + if ( '' === $url ) {
1353 + continue;
219 1354 }
1355 + if ( ! self::is_placeholder_src( $url ) ) {
1356 + return $url;
1357 + }
1358 + // Refused on its NAME. Remember it — if nothing else in the
1359 + // tag resolves, the file itself gets the final say below.
1360 + if ( '' === $named_out ) {
1361 + $named_out = $url;
1362 + }
1363 + }
1364 + }
1365 +
1366 + // Every candidate was refused on its NAME alone. A name is a guess;
1367 + // the file is the fact. Someone who uploads a photograph called
1368 + // `placeholder.jpg` — an entirely ordinary thing to find in a media
1369 + // library — got no dimensions at all, and neither did any of the
1370 + // copies WordPress generates from it, so the layout shift this
1371 + // feature removes came straight back for those images with nothing on
1372 + // screen to explain why. (#333 review round 2, issue 1)
1373 + //
1374 + // Only reached when nothing else in the tag resolved, so the cost is a
1375 + // lookup that was about to be skipped entirely, never an extra one.
1376 + // A genuine stand-in fails this test on its own merits: a data: URI
1377 + // never gets here, and a 1x1 spacer measures 1x1.
1378 + //
1379 + // The lazy attribute is preferred over `src`, matching the order
1380 + // above: when a tag carries both, the lazy one names the real image
1381 + // and `src` holds the stand-in.
1382 + // The warm-up collector passes false here, and must. Deciding this by
1383 + // MEASURING is circular for the caller whose whole job is to arrange
1384 + // the measurement: remote lookups are gated off until `$warming` is
1385 + // true, `$warming` only becomes true inside warm_dimensions(), and
1386 + // warm_dimensions() is never reached because this returned ''. A
1387 + // remote `placeholder.jpg` — a real photograph on a CDN — was warmable
1388 + // before this branch and stopped being, with a failure cached against
1389 + // it for good measure. The collector takes the URL the tag offers and
1390 + // lets warm_dimensions() be the thing that decides.
1391 + // (#333 review round 3, issue 3)
1392 + if ( ! $may_measure ) {
1393 + return '' !== $named_out ? $named_out : $src;
1394 + }
1395 +
1396 + foreach ( array( $named_out, $src ) as $candidate ) {
1397 + if ( '' !== $candidate && self::is_real_image( $candidate ) ) {
1398 + return $candidate;
1399 + }
1400 + }
1401 +
1402 + return '';
1403 + }
1404 +
1405 + /**
1406 + * Does this URL resolve to something too big to be a lazy-load stand-in?
1407 + *
1408 + * The stand-ins this guards against are 1x1 spacers and inline data: URIs.
1409 + * Anything with real extent is a real image, whatever it is called — which
1410 + * is what lets a photograph named `placeholder.jpg` keep its dimensions
1411 + * while `spacer.gif` still loses them.
1412 + *
1413 + * Deliberately conservative: an unresolvable URL returns false, so the
1414 + * name-based verdict stands and the tag is left alone. Better no
1415 + * dimensions than wrong ones. Uses the same resolver (and therefore the
1416 + * same cache) as the normal path, so this costs no extra lookup.
1417 + */
1418 + private static function is_real_image( string $src ): bool {
1419 + $dims = self::dimensions_for_src( $src );
1420 + if ( ! is_array( $dims ) ) {
1421 + return false;
1422 + }
1423 + // Indexed [ width, height ] — the shape apply_dimensions() consumes.
1424 + $w = isset( $dims[0] ) ? (int) $dims[0] : 0;
1425 + $h = isset( $dims[1] ) ? (int) $dims[1] : 0;
1426 +
1427 + // A few pixels either way is still a spacer — some libraries ship a
1428 + // 2x2 or 4x4 rather than a true 1x1. Anything above that has extent a
1429 + // stand-in does not.
1430 + return $w > 4 && $h > 4;
1431 + }
1432 +
1433 + /**
1434 + * True for the stand-in a lazy-loader parks in `src` until its JS swaps
1435 + * the real URL in: an inline data: URI, or a `spacer`/`blank`/`placeholder`
1436 + * asset. Measuring one of these would stamp the spacer's dimensions onto
1437 + * the tag. Pure — unit-tested.
1438 + *
1439 + * Matched on the WHOLE filename stem, not a word inside it. A word-boundary
1440 + * search anywhere in the last segment caught every real image whose name
1441 + * merely contains one of these ordinary words — `blank-space-cover.png`,
1442 + * `placeholder-portrait.png`, `spacer-hero-banner.jpg` — and silently
1443 + * stopped sizing them, which brings back the very layout shift this
1444 + * feature exists to prevent, with nothing on screen to explain it
1445 + * (#333 review, issue 1).
1446 + *
1447 + * A real stand-in is named for what it is and nothing else: `blank.gif`,
1448 + * `spacer.png`, `lazy-loader.svg`, optionally with a dimension or version
1449 + * suffix (`blank-1x1.gif`, `[email protected]`). A descriptive tail is what
1450 + * separates a photograph from a spacer, so the tail is what decides.
1451 + */
1452 + public static function is_placeholder_src( string $src ): bool {
1453 + if ( 0 === stripos( $src, 'data:' ) ) {
1454 + return true;
1455 + }
1456 +
1457 + // Last path segment, without the query string or fragment —
1458 + // `?v=placeholder` is a cache-buster on a real image, not a name.
1459 + // Plain string work on purpose: this method is pure and unit-tested
1460 + // with no WordPress loaded, so wp_parse_url() is not available.
1461 + $path = strtok( $src, '?#' );
1462 + if ( ! is_string( $path ) || '' === $path ) {
1463 + $path = $src;
1464 + }
1465 + $name = strtolower( basename( $path ) );
1466 +
1467 + // Drop the extension, then any trailing dimension/DPR/version marker.
1468 + $stem = preg_replace( '#\.[a-z0-9]+$#', '', $name );
1469 + $stem = (string) preg_replace( '#[-_@]?(?:\d+x\d+|\d+x|x\d+|v\d+|\d+)$#', '', (string) $stem );
1470 + $stem = trim( $stem, '-_.' );
1471 +
1472 + return 1 === preg_match( '#^(?:spacer|blank|placeholder|lazy-?loader|transparent|pixel|dummy)$#', $stem );
1473 + }
1474 +
1475 + /**
1476 + * True when the tag already declares itself the LCP image via
1477 + * `fetchpriority="high"`.
1478 + *
1479 + * Only "high" counts. `fetchpriority="low"` and `="auto"` say the opposite
1480 + * (or nothing), and an image marked low-priority is a perfectly good
1481 + * lazy-load candidate. Pure — unit-tested.
1482 + */
1483 + public static function has_high_fetchpriority( string $tag ): bool {
1484 + return 1 === preg_match( '#\bfetchpriority\s*=\s*["\']?high\b#i', $tag );
1485 + }
1486 +
1487 + /**
1488 + * True when the tag says it renders at a size other than the file's
1489 + * intrinsic one — a `srcset`/`sizes` pair (the browser picks a
1490 + * candidate) or an inline width/height style.
1491 + *
1492 + * Only guards the src-suffix fallback. The `wp-image-N` path stays
1493 + * unguarded: attachment metadata is authoritative, and WordPress'
1494 + * own `wp_filter_content_tags()` adds dimensions to responsive
1495 + * images the same way. Pure — unit-tested.
1496 + */
1497 + public static function has_constrained_render( string $tag ): bool {
1498 + // `\b` sits between `-` and `s`, so a bare \bsrcset also matched
1499 + // `data-srcset` — a lazy attribute the browser has NOT applied yet.
1500 + // That made an unset attribute suppress dimensions on exactly the
1501 + // slider images this feature exists to size, for the same
1502 + // accidental-text-match reason the URL lookup moved away from
1503 + // (#333 review, issue 3).
1504 + //
1505 + // The anchor is a negative lookbehind rather than "start or
1506 + // whitespace": HTML does not require a space between attributes, so
1507 + // `alt="31"srcset="..."` slipped past a whitespace anchor and this
1508 + // guard stopped firing — the tag then got the file's intrinsic size
1509 + // stamped on it while the browser rendered a differently-shaped
1510 + // srcset candidate. (#333 review round 3, issue 1)
1511 + if ( preg_match( '#(?<![-\w])(?:srcset|sizes)\s*=#i', $tag ) ) {
1512 + return true;
1513 + }
1514 + if ( preg_match( '#\bstyle\s*=\s*["\']([^"\']*)["\']#i', $tag, $m ) ) {
1515 + // width/height in the inline style wins over the attribute, so
1516 + // the file's intrinsic size would disagree with the layout.
1517 + return 1 === preg_match( '#(?:^|;)\s*(?:max-)?(?:width|height)\s*:#i', $m[1] );
1518 + }
1519 + return false;
1520 + }
1521 +
1522 + /** @param int[] $dims [width, height]. */
1523 + private static function apply_dimensions( string $tag, array $dims, bool $has_w, bool $has_h ): string {
1524 + // One dimension already present: derive the other from the file's
1525 + // real aspect ratio rather than stamping its intrinsic size.
1526 + //
1527 + // A tag that says width="300" on a 1200x800 file renders 300x200. If
1528 + // we wrote height="800" the browser would reserve a box two and a
1529 + // half times too tall, then snap when the image painted — CREATING
1530 + // the shift this feature exists to remove. Scaling keeps the reserved
1531 + // box the shape the image will actually be.
1532 + if ( $has_w !== $has_h ) {
1533 + if ( $dims[0] <= 0 || $dims[1] <= 0 ) {
220 1534 return $tag;
221 1535 }
1536 + $from = $has_w ? 'width' : 'height';
1537 + $declared = self::attr_int( $tag, $from );
1538 + // A declared value we cannot read in pixels (`50%`, `auto`) means
1539 + // we do not know the rendered size, so there is no ratio to scale
1540 + // from. Stamping the intrinsic size here is exactly the bug this
1541 + // branch exists to avoid, so the tag is left alone.
1542 + if ( $declared <= 0 ) {
1543 + return $tag;
1544 + }
1545 + if ( $has_w ) {
1546 + $height = (int) round( $dims[1] * $declared / $dims[0] );
1547 + return $height > 0 ? self::set_attr( $tag, 'height', (string) $height ) : $tag;
1548 + }
1549 + $width = (int) round( $dims[0] * $declared / $dims[1] );
1550 + return $width > 0 ? self::set_attr( $tag, 'width', (string) $width ) : $tag;
222 1551 }
223 1552
224 - // Couldn't resolve. Leave the tag alone — better no dimensions
225 - // than wrong ones.
1553 + // A header that reported 0 for either side is not a measurement. Half
1554 + // a dimension pair is worse than none: the browser reserves a box of
1555 + // the wrong shape and still shifts when the real image lands.
1556 + if ( $dims[0] <= 0 || $dims[1] <= 0 ) {
1557 + return $tag;
1558 + }
1559 +
1560 + if ( ! $has_w ) {
1561 + $tag = self::set_attr( $tag, 'width', (string) $dims[0] );
1562 + }
1563 + if ( ! $has_h ) {
1564 + $tag = self::set_attr( $tag, 'height', (string) $dims[1] );
1565 + }
226 1566 return $tag;
227 1567 }
228 1568
229 1569 /**
1570 + * Read one numeric attribute off a tag.
1571 + *
1572 + * Returns 0 for anything that is not a plain number — `width="50%"` and
1573 + * `width="auto"` are CSS-ish values whose pixel size we do not know, and
1574 + * scaling from them would invent a box rather than reserve one.
1575 + *
1576 + * @param string $tag The tag.
1577 + * @param string $name Attribute name.
1578 + */
1579 + private static function attr_int( string $tag, string $name ): int {
1580 + // The value must be ENTIRELY digits. Matching a leading run would read
1581 + // `width="50%"` as 50 and scale from a percentage as though it were
1582 + // pixels — inventing a box rather than declining to guess.
1583 + // Lookbehind for the same reason as set_attr(): `\bwidth=` also reads
1584 + // `data-width=`, so a slider's own metadata was scaled from as though
1585 + // it were a rendered dimension.
1586 + if ( ! preg_match( '#(?<![-\w])' . preg_quote( $name, '#' ) . '\s*=\s*(?:"(\d+)"|\'(\d+)\'|(\d+)(?=[\s/>]))#i', $tag, $m ) ) {
1587 + return 0;
1588 + }
1589 + $value = '' !== ( $m[1] ?? '' ) ? $m[1] : ( '' !== ( $m[2] ?? '' ) ? $m[2] : ( $m[3] ?? '' ) );
1590 + return (int) $value;
1591 + }
1592 +
1593 + /**
1594 + * WordPress names resized files `<name>-WxH.<ext>` — when the suffix is
1595 + * present it IS the rendered size, resolvable with zero I/O (works for
1596 + * CDN-hosted copies too). Pure — unit-tested.
1597 + *
1598 + * @return int[]|null [width, height] or null.
1599 + */
1600 + public static function parse_size_suffix( string $src ): ?array {
1601 + $path = (string) preg_replace( '/[?#].*$/', '', $src );
1602 + if ( preg_match( '#-(\d{1,4})x(\d{1,4})\.(?:jpe?g|png|gif|webp|avif)$#i', $path, $m ) ) {
1603 + $w = (int) $m[1];
1604 + $h = (int) $m[2];
1605 + if ( $w > 0 && $h > 0 ) {
1606 + return array( $w, $h );
1607 + }
1608 + }
1609 + return null;
1610 + }
1611 +
1612 + /**
1613 + * Intrinsic size of an image hosted on another domain.
1614 + *
1615 + * An image the site does not host is still an image whose dimensions
1616 + * decide whether the page jumps while it loads. Refusing to look them up
1617 + * was leaving real layout shift unfixed on any site that embeds media from
1618 + * a CDN, a sister site, or a shared asset host — and telling the owner to
1619 + * go and edit their content, which is not a fix a caching plugin should be
1620 + * proud of.
1621 + *
1622 + * The reason for the old refusal was sound but too broad: a page render
1623 + * must never block on somebody else's server. So this fetches only the
1624 + * first few KB — enough for the header of every format WordPress
1625 + * supports — with a short timeout, and caches the answer (successes AND
1626 + * failures) so a URL is fetched once rather than once per pageview.
1627 + *
1628 + * By default it runs only when something has already warmed the cache
1629 + * off-request (the preloader, a cron pass, WP-CLI). A visitor's request
1630 + * therefore never waits on it. A site that would rather pay the cost
1631 + * inline can opt in:
1632 + *
1633 + * add_filter( 'xspeed_lazy_remote_dimensions_inline', '__return_true' );
1634 + *
1635 + * and one that wants nothing fetched from other hosts at all can opt out:
1636 + *
1637 + * add_filter( 'xspeed_lazy_remote_dimensions', '__return_false' );
1638 + *
1639 + * @param string $src Absolute URL on another host.
1640 + * @return int[]|null [width, height] or null when it cannot be resolved.
1641 + */
1642 + private static function remote_dimensions( string $src ): ?array {
1643 + /**
1644 + * Whether to resolve dimensions for images on other hosts at all.
1645 + *
1646 + * @param bool $enabled Default true.
1647 + * @param string $src The image URL.
1648 + */
1649 + if ( ! apply_filters( 'xspeed_lazy_remote_dimensions', true, $src ) ) {
1650 + return null;
1651 + }
1652 +
1653 + if ( ! function_exists( 'wp_remote_get' ) ) {
1654 + return null;
1655 + }
1656 +
1657 + // Only http(s). A data: or blob: src has no server to ask.
1658 + if ( ! preg_match( '#^https?://#i', $src ) ) {
1659 + return null;
1660 + }
1661 +
1662 + /**
1663 + * Whether a front-end request may perform the fetch itself.
1664 + *
1665 + * Off by default: the whole point of the cache is that a visitor
1666 + * never waits on another host. Warm passes (cron, preloader, CLI)
1667 + * set this true for themselves.
1668 + *
1669 + * @param bool $inline Default false.
1670 + */
1671 + $inline = (bool) apply_filters( 'xspeed_lazy_remote_dimensions_inline', self::$warming );
1672 + if ( ! $inline ) {
1673 + return null;
1674 + }
1675 +
1676 + // 32KB covers the header of JPEG, PNG, GIF, WebP and AVIF. Range is a
1677 + // request, not a guarantee — a server that ignores it sends the whole
1678 + // file, which the timeout still bounds.
1679 + $resp = wp_remote_get(
1680 + $src,
1681 + array(
1682 + 'timeout' => 5,
1683 + 'headers' => array( 'Range' => 'bytes=0-32767' ),
1684 + 'user-agent' => 'xSpeed/dimension-probe',
1685 + )
1686 + );
1687 + if ( is_wp_error( $resp ) ) {
1688 + return null;
1689 + }
1690 + $code = (int) wp_remote_retrieve_response_code( $resp );
1691 + if ( 200 !== $code && 206 !== $code ) {
1692 + return null;
1693 + }
1694 +
1695 + $body = (string) wp_remote_retrieve_body( $resp );
1696 + if ( '' === $body ) {
1697 + return null;
1698 + }
1699 +
1700 + // getimagesizefromstring reads the header out of the bytes we already
1701 + // have — no second request, no temp file.
1702 + $size = @getimagesizefromstring( $body ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a truncated or non-image body must degrade to null, not warn.
1703 + if ( is_array( $size ) && ! empty( $size[0] ) && ! empty( $size[1] ) ) {
1704 + return array( (int) $size[0], (int) $size[1] );
1705 + }
1706 + return null;
1707 + }
1708 +
1709 + /**
1710 + * Resolve dimensions from an image URL, cheapest first:
1711 + * 1. `-WxH` filename suffix (no I/O).
1712 + * 2. Intrinsic size of the local file when src is under uploads
1713 + * (getimagesize on the header — no remote fetches, ever).
1714 + * 3. Attachment lookup by URL (uploads-hosted src only).
1715 + * Results — including failures — are cached per URL in a bounded
1716 + * transient so each image pays the lookup once, not per pageview.
1717 + *
1718 + * @return int[]|null [width, height] or null.
1719 + */
1720 + private static function dimensions_for_src( string $src ): ?array {
1721 + $suffix = self::parse_size_suffix( $src );
1722 + if ( $suffix ) {
1723 + return $suffix;
1724 + }
1725 +
1726 + if ( ! function_exists( 'wp_get_upload_dir' ) || ! function_exists( 'get_transient' ) ) {
1727 + return null;
1728 + }
1729 + $uploads = wp_get_upload_dir();
1730 + $baseurl = isset( $uploads['baseurl'] ) ? (string) $uploads['baseurl'] : '';
1731 + $basedir = isset( $uploads['basedir'] ) ? (string) $uploads['basedir'] : '';
1732 +
1733 + // The cache is consulted BEFORE the local/remote split, so a remote
1734 + // image pays its lookup once for the life of the transient rather
1735 + // than once per page render.
1736 + if ( null === self::$src_dims_cache ) {
1737 + $stored = get_transient( 'xspeed_img_dims' );
1738 + self::$src_dims_cache = is_array( $stored ) ? $stored : array();
1739 + }
1740 + $key = md5( $src );
1741 + if ( array_key_exists( $key, self::$src_dims_cache ) ) {
1742 + $hit = self::$src_dims_cache[ $key ];
1743 + if ( is_array( $hit ) ) {
1744 + return $hit;
1745 + }
1746 + // A cached FAILURE, not a cached answer. A front-end render
1747 + // honours it — that is the whole point, one failed lookup must
1748 + // not cost a request on every pageview. A warm pass does NOT:
1749 + // it was asked to resolve these, nothing is waiting on it, and
1750 + // the usual reason for a failure is a moment of bad luck rather
1751 + // than an image that can never be measured.
1752 + //
1753 + // Without this, one slow response poisoned a URL for the life of
1754 + // the transient. It happened on a real site: 15 images cached as
1755 + // failures, and every later warm returned "resolved: 0" while the
1756 + // page kept shifting.
1757 + if ( ! self::$warming || ! self::failure_is_retryable( $hit ) ) {
1758 + return null;
1759 + }
1760 + }
1761 +
1762 + $is_local = '' !== $baseurl && '' !== $basedir && 0 === strpos( $src, $baseurl );
1763 +
1764 + $dims = null;
1765 +
1766 + if ( $is_local ) {
1767 + $relative = (string) preg_replace( '/[?#].*$/', '', substr( $src, strlen( $baseurl ) ) );
1768 + if ( false === strpos( $relative, '..' ) ) {
1769 + $file = $basedir . $relative;
1770 + if ( is_file( $file ) ) {
1771 + $size = @getimagesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-image/corrupt file must degrade to null, not warn.
1772 + if ( is_array( $size ) && ! empty( $size[0] ) && ! empty( $size[1] ) ) {
1773 + $dims = array( (int) $size[0], (int) $size[1] );
1774 + }
1775 + }
1776 + }
1777 +
1778 + // File not on disk (offloaded originals) — one DB lookup by URL.
1779 + if ( null === $dims && function_exists( 'attachment_url_to_postid' ) ) {
1780 + $id = (int) attachment_url_to_postid( $src );
1781 + if ( $id > 0 ) {
1782 + $dims = self::dimensions_for_attachment( $id );
1783 + }
1784 + }
1785 + } else {
1786 + $dims = self::remote_dimensions( $src );
1787 + }
1788 +
1789 + // Cache success AND failure (0), bounded so the blob can't grow
1790 + // unbounded on media-heavy sites.
1791 + if ( count( self::$src_dims_cache ) >= 500 ) {
1792 + self::$src_dims_cache = array_slice( self::$src_dims_cache, 250, null, true );
1793 + }
1794 + // A resolved size is permanent — the file's intrinsic dimensions do
1795 + // not change under the same URL. A failure is a snapshot of one
1796 + // moment, so it is stored as a TIMESTAMP rather than a bare 0 and
1797 + // stops counting after a while. Storing both the same way is what let
1798 + // a transient blip look identical to "this can never be measured".
1799 + self::$src_dims_cache[ $key ] = null === $dims ? time() : $dims;
1800 + if ( function_exists( 'set_transient' ) ) {
1801 + set_transient( 'xspeed_img_dims', self::$src_dims_cache, DAY_IN_SECONDS );
1802 + }
1803 + return $dims;
1804 + }
1805 +
1806 + /**
230 1807 * @return int[]|null [width, height] or null
231 1808 */
232 1809 private static function dimensions_for_attachment( int $attachment_id ): ?array {
233 1810 if ( ! function_exists( 'wp_get_attachment_metadata' ) ) {
@@ -293,11 +1870,116 @@
293 1870 return self::$opts;
294 1871 }
295 1872
296 1873 /**
1874 + * How long a failed lookup is trusted before a warm pass tries again.
1875 + *
1876 + * Long enough that a genuinely unmeasurable URL is not re-fetched on every
1877 + * crawl, short enough that an outage does not cost a day of layout shift.
1878 + */
1879 + private const FAILURE_RETRY_AFTER = 900; // 15 minutes.
1880 +
1881 + /**
1882 + * Whether a stored failure is old enough to be worth retrying.
1883 + *
1884 + * Legacy entries were written as a bare `0` with no timestamp. Those are
1885 + * always retryable: they predate this distinction, and one extra request
1886 + * for each is a far better outcome than leaving a site permanently unable
1887 + * to resolve images it could resolve today.
1888 + *
1889 + * @param mixed $entry Stored cache value.
1890 + */
1891 + private static function failure_is_retryable( $entry ): bool {
1892 + if ( ! is_int( $entry ) || $entry <= 0 ) {
1893 + return true; // legacy `0`, or nonsense — retry.
1894 + }
1895 + return ( time() - $entry ) >= self::FAILURE_RETRY_AFTER;
1896 + }
1897 +
1898 + /**
1899 + * Whether this URL's dimensions are already known (or known-unresolvable).
1900 + *
1901 + * Lets a caller skip URLs that would cost nothing to look up, so a bounded
1902 + * batch spends its budget on images it has not seen. Without this a capped
1903 + * collector re-picks the same first N images every pass — they are always
1904 + * in the same DOM order — and anything past the cap is never resolved at
1905 + * all, however many times the crawl runs.
1906 + *
1907 + * Reads the cache only; never fetches.
1908 + *
1909 + * @param string $src Absolute image URL.
1910 + */
1911 + public static function dimensions_known( string $src ): bool {
1912 + if ( ! function_exists( 'get_transient' ) ) {
1913 + return false;
1914 + }
1915 + if ( null === self::$src_dims_cache ) {
1916 + $stored = get_transient( 'xspeed_img_dims' );
1917 + self::$src_dims_cache = is_array( $stored ) ? $stored : array();
1918 + }
1919 + $key = md5( $src );
1920 + if ( ! array_key_exists( $key, self::$src_dims_cache ) ) {
1921 + return false;
1922 + }
1923 + $hit = self::$src_dims_cache[ $key ];
1924 + if ( is_array( $hit ) ) {
1925 + return true;
1926 + }
1927 + // A failure that has aged out is NOT known — reporting it as known
1928 + // would make the crawl skip the one URL that has become worth
1929 + // retrying.
1930 + return ! self::failure_is_retryable( $hit );
1931 + }
1932 +
1933 + /**
1934 + * Resolve and cache dimensions for a batch of image URLs.
1935 + *
1936 + * Meant for anything running OFF a visitor's request — the preloader
1937 + * crawling the sitemap, a cron pass, `wp xspeed lazy warm-dimensions`.
1938 + * Once warmed, the front end serves the dimensions from cache, so the
1939 + * layout shift is fixed without a single visitor waiting on another host.
1940 + *
1941 + * @param string[] $urls Absolute image URLs.
1942 + * @return int How many were resolved.
1943 + */
1944 + public static function warm_dimensions( array $urls ): int {
1945 + $resolved = 0;
1946 + self::$warming = true;
1947 + try {
1948 + foreach ( array_unique( $urls ) as $url ) {
1949 + if ( ! is_string( $url ) || '' === $url ) {
1950 + continue;
1951 + }
1952 + if ( self::dimensions_for_src( $url ) ) {
1953 + $resolved++;
1954 + }
1955 + }
1956 + } finally {
1957 + // In a finally so a throw mid-batch cannot leave the flag set and
1958 + // silently turn every later front-end render into a fetcher.
1959 + self::$warming = false;
1960 + }
1961 + return $resolved;
1962 + }
1963 +
1964 + /**
297 1965 * Test-only: clear cached opts + counter between assertions.
298 1966 */
299 1967 public static function reset_state(): void {
300 - self::$opts = null;
301 - self::$image_counter = 0;
1968 + self::$opts = null;
1969 + self::$image_counter = 0;
1970 + self::$priority_claimed = false;
1971 + self::$hidden_ranges = array();
1972 + self::$in_hidden = false;
1973 + self::$priority_reserved = false;
1974 + self::$background_counter = 0;
1975 + self::$src_dims_cache = null;
1976 + self::$facade_used = false;
1977 + self::$warming = false;
1978 + // Both gate whether the autoplay restorer is printed. Left set, one
1979 + // page carrying a video would make every later response in the same
1980 + // process ship the script — and, worse for the preloader, a warmed
1981 + // page could inherit a decision made for a different URL.
1982 + self::$deferred_autoplay = false;
1983 + self::$has_deferred_video_markup = false;
302 1984 }
303 1985 }