PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
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 1.1.3 1.1.4 All 33 releases
← All changes | includes/class-lazy-loader.php +733 -25 1.2.4 → 1.3.7 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 */
@@ -92,13 +132,18 @@
92 132 return '#<' . $name . '\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i';
93 133 };
94 134
95 135 if ( ! empty( $opts['lazy_images'] ) || ! empty( $opts['add_missing_dimensions'] ) ) {
96 - $work = self::apply_pass( $work, $tag_re( 'img' ), array( __CLASS__, 'rewrite_img' ) );
136 + $work = self::apply_img_pass( $work, $tag_re( 'img' ), $opts );
97 137 }
98 138 if ( ! empty( $opts['lazy_iframes'] ) ) {
99 139 $work = self::apply_pass( $work, $tag_re( 'iframe' ), array( __CLASS__, 'rewrite_iframe' ) );
100 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 + }
101 146 // Facade runs AFTER the lazy pass, deliberately. The facade keeps the
102 147 // original tag inside <noscript> as the JS-less fallback, and that
103 148 // fallback should carry loading="lazy" too — running this first would
104 149 // produce an eager iframe for exactly the visitors least able to
@@ -113,9 +158,9 @@
113 158 // unclosed iframe can't make the match run on to a LATER embed's
114 159 // closing tag and eat everything in between; an iframe with no
115 160 // closing tag simply doesn't match and passes through untouched.
116 161 if ( ! empty( $opts['video_facade'] ) ) {
117 - $work = self::apply_pass(
162 + $work = self::apply_facade_pass(
118 163 $work,
119 164 '#(<iframe\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>)((?:(?!</?iframe\b).)*)</iframe\s*>#is',
120 165 array( __CLASS__, 'rewrite_iframe_facade' )
121 166 );
@@ -133,9 +178,9 @@
133 178 // <noscript> as the JS-less fallback, and that copy should carry
134 179 // preload="none" too. Same whole-element, tempered match so an
135 180 // unclosed <video> passes through rather than eating siblings.
136 181 if ( ! empty( $opts['video_facade'] ) ) {
137 - $work = self::apply_pass(
182 + $work = self::apply_facade_pass(
138 183 $work,
139 184 '#(<video\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>)((?:(?!</?video\b).)*)</video\s*>#is',
140 185 array( __CLASS__, 'rewrite_video_facade' )
141 186 );
@@ -144,8 +189,272 @@
144 189 return self::restore_safe_blocks( $work, $stubs );
145 190 }
146 191
147 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 + /**
148 457 * Run one rewrite pass, keeping the input if PCRE bails.
149 458 *
150 459 * preg_replace_callback() returns null when it hits the backtrack or
151 460 * recursion limit — on a large page that would otherwise blank the
@@ -185,8 +494,10 @@
185 494 || false !== stripos( $tag, 'data-no-lazy' )
186 495 || self::has_high_fetchpriority( $tag )
187 496 || self::is_excluded( $tag, $opts );
188 497
498 + $self_hidden = self::tag_is_hidden( $tag, 'img' );
499 +
189 500 if ( $skip_lazy && ! empty( $opts['lazy_images'] ) ) {
190 501 // An EXCLUDED image is one the user marked as above-the-fold (a
191 502 // hero/logo) — the opposite of lazy. WordPress core adds
192 503 // `loading="lazy"` to images by default (since 5.5), so merely
@@ -193,24 +504,46 @@
193 504 // *skipping* our lazy pass would leave core's lazy attribute on
194 505 // the LCP hero and tank LCP. Actively make it eager +
195 506 // high-priority so an excluded hero loads immediately.
196 507 $tag = self::set_attr( $tag, 'loading', 'eager' );
197 - $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 + }
198 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 );
199 526 } elseif ( ! empty( $opts['lazy_images'] ) ) {
200 527 // Above-the-fold skip: first N images get loading="eager"
201 528 // 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 );
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 ) );
212 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 + }
213 546 }
214 547
215 548 if ( ! empty( $opts['add_missing_dimensions'] ) ) {
216 549 $tag = self::ensure_dimensions( $tag );
@@ -218,8 +551,103 @@
218 551
219 552 return $tag;
220 553 }
221 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 +
222 650 private static function rewrite_iframe( array $m ): string {
223 651 $tag = $m[0];
224 652 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
225 653 return $tag;
@@ -413,8 +841,29 @@
413 841 if ( false !== stripos( $tag, 'data-xspeed-src' ) ) {
414 842 return $tag;
415 843 }
416 844
845 + // A background video waits for the visitor's first interaction, not
846 + // just the viewport. It is decoration, and on a hero it is in the
847 + // viewport at once, so the viewport rule loaded it immediately and its
848 + // first frame became the LCP. Held back, the hero text is the LCP and
849 + // the video starts on the first scroll, tap, key or mouse move.
850 + // Checked before autoplay is renamed below.
851 + if ( self::is_background_video_without_poster( $tag ) ) {
852 + /**
853 + * Whether a background video waits for the first interaction.
854 + *
855 + * Return false to load it when it reaches the viewport instead,
856 + * like any other autoplay video.
857 + *
858 + * @param bool $wait Whether the video waits. Default true.
859 + * @param string $tag The <video> opening tag.
860 + */
861 + if ( (bool) apply_filters( 'xspeed_lazy_background_video_waits_for_interaction', true, $tag ) ) {
862 + $tag = self::set_attr( $tag, 'data-xspeed-wait', 'interaction' );
863 + }
864 + }
865 +
417 866 $deferred = false;
418 867
419 868 // The element's own src, when it has one.
420 869 if ( preg_match( '#\bsrc\s*=\s*(["\'])(.*?)\1#i', $tag, $m ) && '' !== trim( $m[2] ) ) {
@@ -449,8 +898,37 @@
449 898 return $tag;
450 899 }
451 900
452 901 /**
902 + * Whether a <video> opening tag is a decorative background with no poster.
903 + *
904 + * Autoplay, muted, looping and without controls is how every builder
905 + * marks a background video: nobody watches it, it sits behind the hero
906 + * text. With no poster, nothing paints in its box until the first frame
907 + * decodes, so on a hero the video's first frame becomes the page's LCP.
908 + * On the measured site that was a 5 MB MP4 and a 3.5–3.9 s mobile LCP,
909 + * against 2.3 s with the video out of the way.
910 + *
911 + * Reads autoplay in both spellings: the author's `autoplay`, and the
912 + * `data-xspeed-autoplay` the lazy pass leaves when it defers the source.
913 + * Resource Hints sees the tag after that pass has run.
914 + *
915 + * @param string $tag A <video> opening tag.
916 + */
917 + public static function is_background_video_without_poster( string $tag ): bool {
918 + $has = static function ( string $name ) use ( $tag ): bool {
919 + return (bool) preg_match( '#\s' . $name . '(?=[\s/>=])#i', $tag );
920 + };
921 + if ( ! $has( 'autoplay' ) && ! $has( 'data-xspeed-autoplay' ) ) {
922 + return false;
923 + }
924 + if ( ! $has( 'muted' ) || ! $has( 'loop' ) || $has( 'controls' ) ) {
925 + return false;
926 + }
927 + return ! preg_match( '#\sposter\s*=\s*(?:["\']\s*)?[^"\'\s>]#i', $tag );
928 + }
929 +
930 + /**
453 931 * Did this response defer at least one autoplay video? Gates the script
454 932 * so a page with no such video ships no extra bytes.
455 933 *
456 934 * @var bool
@@ -613,15 +1091,27 @@
613 1091 v.setAttribute('preload','none');
614 1092 if(v.load)v.load();
615 1093 }
616 1094 }
1095 +// A background video (data-xspeed-wait) that reaches the viewport is parked
1096 +// here until the visitor first scrolls, taps, types or moves the mouse, then
1097 +// every parked one starts together. See defer_autoplay_source().
1098 +var I=false,P=[],E=['pointerdown','pointermove','touchstart','keydown','wheel','scroll'];
1099 +function interacted(){
1100 +if(I)return;I=true;
1101 +for(var i=0;i<E.length;i++)removeEventListener(E[i],interacted,true);
1102 +for(var j=0;j<P.length;j++)go(P[j]);
1103 +P=[];
1104 +}
1105 +for(var k=0;k<E.length;k++)addEventListener(E[k],interacted,{capture:true,passive:true});
1106 +function reach(v){if(!I&&v.getAttribute('data-xspeed-wait'))P.push(v);else go(v);}
617 1107 function scan(){
618 1108 strip();
619 1109 adopt();
620 1110 var v=document.querySelectorAll(S);
621 -if(!('IntersectionObserver'in window)){for(var i=0;i<v.length;i++)go(v[i]);return;}
1111 +if(!('IntersectionObserver'in window)){for(var i=0;i<v.length;i++)reach(v[i]);return;}
622 1112 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);}}
1113 +for(var i=0;i<es.length;i++){if(es[i].isIntersecting){reach(es[i].target);o.unobserve(es[i].target);}}
624 1114 },{rootMargin:'200px'});
625 1115 for(var j=0;j<v.length;j++)o.observe(v[j]);
626 1116 }
627 1117 if(document.readyState!=='loading')scan();else document.addEventListener('DOMContentLoaded',scan);
@@ -642,9 +1132,13 @@
642 1132 JS;
643 1133 }
644 1134
645 1135 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';
1136 + // Lookbehind, not `\b`: writing `width` onto a tag carrying
1137 + // `data-width="800"` matched the DATA attribute and rewrote it to the
1138 + // file's intrinsic size — corrupting a slider's own configuration and
1139 + // leaving the tag with no real width at all. (#333 review round 3)
1140 + $pattern = '#(?<![-\w])' . preg_quote( $name, '#' ) . '\s*=\s*(["\'][^"\']*["\']|\S+)#i';
647 1141 if ( preg_match( $pattern, $tag ) ) {
648 1142 if ( $only_if_missing ) {
649 1143 return $tag;
650 1144 }
@@ -664,10 +1158,19 @@
664 1158 * filesystem when src points at the uploads dir. Skip when we can't
665 1159 * resolve cheaply — never block the request on a remote getimagesize.
666 1160 */
667 1161 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 );
1162 + // `\b` sits between `-` and `w`, so a bare `\bwidth=` also matched
1163 + // `data-width=` — a slider's own metadata, not a rendered dimension.
1164 + // The tag then looked half-sized: apply_dimensions() derived the other
1165 + // dimension from the ratio and wrote ONLY that, so a tag carrying
1166 + // `data-width="800"` came out with `height="533"` and no width and
1167 + // laid out at 41x30. Harmless while the URL never resolved; this
1168 + // branch made it resolve, which is what exposed it. Half a pair is
1169 + // worse than none, as the docblock below already says.
1170 + // (#333 review round 3, issue 2)
1171 + $has_w = (bool) preg_match( '#(?<![-\w])width\s*=#i', $tag );
1172 + $has_h = (bool) preg_match( '#(?<![-\w])height\s*=#i', $tag );
670 1173 if ( $has_w && $has_h ) {
671 1174 return $tag;
672 1175 }
673 1176
@@ -685,12 +1188,15 @@
685 1188 // on those images (issue #37). Resolve from the src instead, but only
686 1189 // when the tag doesn't already tell us it renders at some other size:
687 1190 // stamping the intrinsic file size onto a responsive or CSS-sized
688 1191 // 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 );
1192 + if ( ! self::has_constrained_render( $tag ) ) {
1193 + $url = self::resolvable_image_url( $tag );
1194 + if ( '' !== $url ) {
1195 + $dims = self::dimensions_for_src( $url );
1196 + if ( $dims ) {
1197 + return self::apply_dimensions( $tag, $dims, $has_w, $has_h );
1198 + }
693 1199 }
694 1200 }
695 1201
696 1202 // Couldn't resolve. Leave the tag alone — better no dimensions
@@ -698,8 +1204,189 @@
698 1204 return $tag;
699 1205 }
700 1206
701 1207 /**
1208 + * The URL to measure an image by: its real `src`, or the lazy-loading
1209 + * attribute holding the URL when `src` is absent or a placeholder.
1210 + *
1211 + * Page-builder sliders (Essential Blocks among them) ship the image with
1212 + * NO `src` at all — the URL lives in `data-lazy`, and their own JS moves
1213 + * it across at runtime. Resolving only from `src` left every one of those
1214 + * images without dimensions (issue #328, the miss that #37 did not cover:
1215 + * that one was about the missing `wp-image-N` class, this one is about the
1216 + * URL not being in `src` in the first place).
1217 + *
1218 + * A placeholder `src` — a data: URI or the 1x1 spacer GIF these libraries
1219 + * use — is treated as absent: measuring it would stamp the spacer's size
1220 + * onto the tag and CREATE a layout shift.
1221 + *
1222 + * Note the explicit `(?<![-\w])src` boundary. `\bsrc=` also matches the
1223 + * tail of `data-src=` and `data-lazy-src=` (a hyphen is a non-word
1224 + * character, so `\b` sits between `-` and `s`), which is why those two
1225 + * attributes happened to work before this method existed while `data-lazy`
1226 + * and `data-original` did not. Relying on that accident meant the URL a
1227 + * tag was measured by depended on how its attribute was spelled.
1228 + *
1229 + * Pure — unit-tested — EXCEPT when `$may_measure` is true and every
1230 + * candidate was refused by name, which is the one branch that touches the
1231 + * filesystem. Callers that are themselves arranging a measurement pass
1232 + * false; see the note at that branch.
1233 + *
1234 + * @param string $tag The <img> tag.
1235 + * @param bool $may_measure Whether a name-refused URL may be settled by
1236 + * reading the file. False for the warm-up
1237 + * collector, which would otherwise deadlock.
1238 + */
1239 + public static function resolvable_image_url( string $tag, bool $may_measure = true ): string {
1240 + $src = '';
1241 + $named_out = '';
1242 + if ( preg_match( '#(?<![-\w])src\s*=\s*["\']([^"\']+)["\']#i', $tag, $m ) ) {
1243 + $src = trim( $m[1] );
1244 + if ( '' !== $src && ! self::is_placeholder_src( $src ) ) {
1245 + return $src;
1246 + }
1247 + }
1248 +
1249 + foreach ( array( 'data-lazy', 'data-src', 'data-lazy-src', 'data-original' ) as $attr ) {
1250 + // Anchor with a negative lookbehind, not `\b` and not
1251 + // whitespace. `\b` sits between `-` and `d`, so a bare
1252 + // `\bdata-src=` also matched the TAIL of `x-data-src=` and took
1253 + // the wrong image's URL — worse than no size, because it reserves
1254 + // a wrongly shaped box and CAUSES the shift.
1255 + //
1256 + // Requiring whitespace instead was my first fix and it was wrong:
1257 + // attributes are not always separated by one (`alt="31"srcset=`
1258 + // is valid), so that anchor silently stopped matching and handed
1259 + // back a size for a tag the guard should have skipped. The
1260 + // lookbehind rejects the same prefixed decoys without depending on
1261 + // spacing, and is what `src` already uses two methods below.
1262 + // (#333 review rounds 2 and 3, issue 1)
1263 + if ( preg_match( '#(?<![-\w])' . preg_quote( $attr, '#' ) . '\s*=\s*["\']([^"\']+)["\']#i', $tag, $m ) ) {
1264 + $url = trim( $m[1] );
1265 + if ( '' === $url ) {
1266 + continue;
1267 + }
1268 + if ( ! self::is_placeholder_src( $url ) ) {
1269 + return $url;
1270 + }
1271 + // Refused on its NAME. Remember it — if nothing else in the
1272 + // tag resolves, the file itself gets the final say below.
1273 + if ( '' === $named_out ) {
1274 + $named_out = $url;
1275 + }
1276 + }
1277 + }
1278 +
1279 + // Every candidate was refused on its NAME alone. A name is a guess;
1280 + // the file is the fact. Someone who uploads a photograph called
1281 + // `placeholder.jpg` — an entirely ordinary thing to find in a media
1282 + // library — got no dimensions at all, and neither did any of the
1283 + // copies WordPress generates from it, so the layout shift this
1284 + // feature removes came straight back for those images with nothing on
1285 + // screen to explain why. (#333 review round 2, issue 1)
1286 + //
1287 + // Only reached when nothing else in the tag resolved, so the cost is a
1288 + // lookup that was about to be skipped entirely, never an extra one.
1289 + // A genuine stand-in fails this test on its own merits: a data: URI
1290 + // never gets here, and a 1x1 spacer measures 1x1.
1291 + //
1292 + // The lazy attribute is preferred over `src`, matching the order
1293 + // above: when a tag carries both, the lazy one names the real image
1294 + // and `src` holds the stand-in.
1295 + // The warm-up collector passes false here, and must. Deciding this by
1296 + // MEASURING is circular for the caller whose whole job is to arrange
1297 + // the measurement: remote lookups are gated off until `$warming` is
1298 + // true, `$warming` only becomes true inside warm_dimensions(), and
1299 + // warm_dimensions() is never reached because this returned ''. A
1300 + // remote `placeholder.jpg` — a real photograph on a CDN — was warmable
1301 + // before this branch and stopped being, with a failure cached against
1302 + // it for good measure. The collector takes the URL the tag offers and
1303 + // lets warm_dimensions() be the thing that decides.
1304 + // (#333 review round 3, issue 3)
1305 + if ( ! $may_measure ) {
1306 + return '' !== $named_out ? $named_out : $src;
1307 + }
1308 +
1309 + foreach ( array( $named_out, $src ) as $candidate ) {
1310 + if ( '' !== $candidate && self::is_real_image( $candidate ) ) {
1311 + return $candidate;
1312 + }
1313 + }
1314 +
1315 + return '';
1316 + }
1317 +
1318 + /**
1319 + * Does this URL resolve to something too big to be a lazy-load stand-in?
1320 + *
1321 + * The stand-ins this guards against are 1x1 spacers and inline data: URIs.
1322 + * Anything with real extent is a real image, whatever it is called — which
1323 + * is what lets a photograph named `placeholder.jpg` keep its dimensions
1324 + * while `spacer.gif` still loses them.
1325 + *
1326 + * Deliberately conservative: an unresolvable URL returns false, so the
1327 + * name-based verdict stands and the tag is left alone. Better no
1328 + * dimensions than wrong ones. Uses the same resolver (and therefore the
1329 + * same cache) as the normal path, so this costs no extra lookup.
1330 + */
1331 + private static function is_real_image( string $src ): bool {
1332 + $dims = self::dimensions_for_src( $src );
1333 + if ( ! is_array( $dims ) ) {
1334 + return false;
1335 + }
1336 + // Indexed [ width, height ] — the shape apply_dimensions() consumes.
1337 + $w = isset( $dims[0] ) ? (int) $dims[0] : 0;
1338 + $h = isset( $dims[1] ) ? (int) $dims[1] : 0;
1339 +
1340 + // A few pixels either way is still a spacer — some libraries ship a
1341 + // 2x2 or 4x4 rather than a true 1x1. Anything above that has extent a
1342 + // stand-in does not.
1343 + return $w > 4 && $h > 4;
1344 + }
1345 +
1346 + /**
1347 + * True for the stand-in a lazy-loader parks in `src` until its JS swaps
1348 + * the real URL in: an inline data: URI, or a `spacer`/`blank`/`placeholder`
1349 + * asset. Measuring one of these would stamp the spacer's dimensions onto
1350 + * the tag. Pure — unit-tested.
1351 + *
1352 + * Matched on the WHOLE filename stem, not a word inside it. A word-boundary
1353 + * search anywhere in the last segment caught every real image whose name
1354 + * merely contains one of these ordinary words — `blank-space-cover.png`,
1355 + * `placeholder-portrait.png`, `spacer-hero-banner.jpg` — and silently
1356 + * stopped sizing them, which brings back the very layout shift this
1357 + * feature exists to prevent, with nothing on screen to explain it
1358 + * (#333 review, issue 1).
1359 + *
1360 + * A real stand-in is named for what it is and nothing else: `blank.gif`,
1361 + * `spacer.png`, `lazy-loader.svg`, optionally with a dimension or version
1362 + * suffix (`blank-1x1.gif`, `[email protected]`). A descriptive tail is what
1363 + * separates a photograph from a spacer, so the tail is what decides.
1364 + */
1365 + public static function is_placeholder_src( string $src ): bool {
1366 + if ( 0 === stripos( $src, 'data:' ) ) {
1367 + return true;
1368 + }
1369 +
1370 + // Last path segment, without the query string or fragment —
1371 + // `?v=placeholder` is a cache-buster on a real image, not a name.
1372 + // Plain string work on purpose: this method is pure and unit-tested
1373 + // with no WordPress loaded, so wp_parse_url() is not available.
1374 + $path = strtok( $src, '?#' );
1375 + if ( ! is_string( $path ) || '' === $path ) {
1376 + $path = $src;
1377 + }
1378 + $name = strtolower( basename( $path ) );
1379 +
1380 + // Drop the extension, then any trailing dimension/DPR/version marker.
1381 + $stem = preg_replace( '#\.[a-z0-9]+$#', '', $name );
1382 + $stem = (string) preg_replace( '#[-_@]?(?:\d+x\d+|\d+x|x\d+|v\d+|\d+)$#', '', (string) $stem );
1383 + $stem = trim( $stem, '-_.' );
1384 +
1385 + return 1 === preg_match( '#^(?:spacer|blank|placeholder|lazy-?loader|transparent|pixel|dummy)$#', $stem );
1386 + }
1387 +
1388 + /**
702 1389 * True when the tag already declares itself the LCP image via
703 1390 * `fetchpriority="high"`.
704 1391 *
705 1392 * Only "high" counts. `fetchpriority="low"` and `="auto"` say the opposite
@@ -720,9 +1407,22 @@
720 1407 * own `wp_filter_content_tags()` adds dimensions to responsive
721 1408 * images the same way. Pure — unit-tested.
722 1409 */
723 1410 public static function has_constrained_render( string $tag ): bool {
724 - if ( preg_match( '#\bsrcset\s*=#i', $tag ) || preg_match( '#\bsizes\s*=#i', $tag ) ) {
1411 + // `\b` sits between `-` and `s`, so a bare \bsrcset also matched
1412 + // `data-srcset` — a lazy attribute the browser has NOT applied yet.
1413 + // That made an unset attribute suppress dimensions on exactly the
1414 + // slider images this feature exists to size, for the same
1415 + // accidental-text-match reason the URL lookup moved away from
1416 + // (#333 review, issue 3).
1417 + //
1418 + // The anchor is a negative lookbehind rather than "start or
1419 + // whitespace": HTML does not require a space between attributes, so
1420 + // `alt="31"srcset="..."` slipped past a whitespace anchor and this
1421 + // guard stopped firing — the tag then got the file's intrinsic size
1422 + // stamped on it while the browser rendered a differently-shaped
1423 + // srcset candidate. (#333 review round 3, issue 1)
1424 + if ( preg_match( '#(?<![-\w])(?:srcset|sizes)\s*=#i', $tag ) ) {
725 1425 return true;
726 1426 }
727 1427 if ( preg_match( '#\bstyle\s*=\s*["\']([^"\']*)["\']#i', $tag, $m ) ) {
728 1428 // width/height in the inline style wins over the attribute, so
@@ -792,9 +1492,12 @@
792 1492 private static function attr_int( string $tag, string $name ): int {
793 1493 // The value must be ENTIRELY digits. Matching a leading run would read
794 1494 // `width="50%"` as 50 and scale from a percentage as though it were
795 1495 // 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 ) ) {
1496 + // Lookbehind for the same reason as set_attr(): `\bwidth=` also reads
1497 + // `data-width=`, so a slider's own metadata was scaled from as though
1498 + // it were a rendered dimension.
1499 + if ( ! preg_match( '#(?<![-\w])' . preg_quote( $name, '#' ) . '\s*=\s*(?:"(\d+)"|\'(\d+)\'|(\d+)(?=[\s/>]))#i', $tag, $m ) ) {
797 1500 return 0;
798 1501 }
799 1502 $value = '' !== ( $m[1] ?? '' ) ? $m[1] : ( '' !== ( $m[2] ?? '' ) ? $m[2] : ( $m[3] ?? '' ) );
800 1503 return (int) $value;
@@ -1176,8 +1879,13 @@
1176 1879 */
1177 1880 public static function reset_state(): void {
1178 1881 self::$opts = null;
1179 1882 self::$image_counter = 0;
1883 + self::$priority_claimed = false;
1884 + self::$hidden_ranges = array();
1885 + self::$in_hidden = false;
1886 + self::$priority_reserved = false;
1887 + self::$background_counter = 0;
1180 1888 self::$src_dims_cache = null;
1181 1889 self::$facade_used = false;
1182 1890 self::$warming = false;
1183 1891 // Both gate whether the autoplay restorer is printed. Left set, one