PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
← All changes | includes/class-lazy-loader.php +222 -9 1.2.41.3.2 View file →
@@ -642,9 +642,13 @@
642 642 JS;
643 643 }
644 644
645 645 private static function set_attr( string $tag, string $name, string $value, bool $only_if_missing = false ): string {
646 - $pattern = '#\b' . preg_quote( $name, '#' ) . '\s*=\s*(["\'][^"\']*["\']|\S+)#i';
646 + // Lookbehind, not `\b`: writing `width` onto a tag carrying
647 + // `data-width="800"` matched the DATA attribute and rewrote it to the
648 + // file's intrinsic size — corrupting a slider's own configuration and
649 + // leaving the tag with no real width at all. (#333 review round 3)
650 + $pattern = '#(?<![-\w])' . preg_quote( $name, '#' ) . '\s*=\s*(["\'][^"\']*["\']|\S+)#i';
647 651 if ( preg_match( $pattern, $tag ) ) {
648 652 if ( $only_if_missing ) {
649 653 return $tag;
650 654 }
@@ -664,10 +668,19 @@
664 668 * filesystem when src points at the uploads dir. Skip when we can't
665 669 * resolve cheaply — never block the request on a remote getimagesize.
666 670 */
667 671 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 );
672 + // `\b` sits between `-` and `w`, so a bare `\bwidth=` also matched
673 + // `data-width=` — a slider's own metadata, not a rendered dimension.
674 + // The tag then looked half-sized: apply_dimensions() derived the other
675 + // dimension from the ratio and wrote ONLY that, so a tag carrying
676 + // `data-width="800"` came out with `height="533"` and no width and
677 + // laid out at 41x30. Harmless while the URL never resolved; this
678 + // branch made it resolve, which is what exposed it. Half a pair is
679 + // worse than none, as the docblock below already says.
680 + // (#333 review round 3, issue 2)
681 + $has_w = (bool) preg_match( '#(?<![-\w])width\s*=#i', $tag );
682 + $has_h = (bool) preg_match( '#(?<![-\w])height\s*=#i', $tag );
670 683 if ( $has_w && $has_h ) {
671 684 return $tag;
672 685 }
673 686
@@ -685,12 +698,15 @@
685 698 // on those images (issue #37). Resolve from the src instead, but only
686 699 // when the tag doesn't already tell us it renders at some other size:
687 700 // stamping the intrinsic file size onto a responsive or CSS-sized
688 701 // 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 );
702 + if ( ! self::has_constrained_render( $tag ) ) {
703 + $url = self::resolvable_image_url( $tag );
704 + if ( '' !== $url ) {
705 + $dims = self::dimensions_for_src( $url );
706 + if ( $dims ) {
707 + return self::apply_dimensions( $tag, $dims, $has_w, $has_h );
708 + }
693 709 }
694 710 }
695 711
696 712 // Couldn't resolve. Leave the tag alone — better no dimensions
@@ -698,8 +714,189 @@
698 714 return $tag;
699 715 }
700 716
701 717 /**
718 + * The URL to measure an image by: its real `src`, or the lazy-loading
719 + * attribute holding the URL when `src` is absent or a placeholder.
720 + *
721 + * Page-builder sliders (Essential Blocks among them) ship the image with
722 + * NO `src` at all — the URL lives in `data-lazy`, and their own JS moves
723 + * it across at runtime. Resolving only from `src` left every one of those
724 + * images without dimensions (issue #328, the miss that #37 did not cover:
725 + * that one was about the missing `wp-image-N` class, this one is about the
726 + * URL not being in `src` in the first place).
727 + *
728 + * A placeholder `src` — a data: URI or the 1x1 spacer GIF these libraries
729 + * use — is treated as absent: measuring it would stamp the spacer's size
730 + * onto the tag and CREATE a layout shift.
731 + *
732 + * Note the explicit `(?<![-\w])src` boundary. `\bsrc=` also matches the
733 + * tail of `data-src=` and `data-lazy-src=` (a hyphen is a non-word
734 + * character, so `\b` sits between `-` and `s`), which is why those two
735 + * attributes happened to work before this method existed while `data-lazy`
736 + * and `data-original` did not. Relying on that accident meant the URL a
737 + * tag was measured by depended on how its attribute was spelled.
738 + *
739 + * Pure — unit-tested — EXCEPT when `$may_measure` is true and every
740 + * candidate was refused by name, which is the one branch that touches the
741 + * filesystem. Callers that are themselves arranging a measurement pass
742 + * false; see the note at that branch.
743 + *
744 + * @param string $tag The <img> tag.
745 + * @param bool $may_measure Whether a name-refused URL may be settled by
746 + * reading the file. False for the warm-up
747 + * collector, which would otherwise deadlock.
748 + */
749 + public static function resolvable_image_url( string $tag, bool $may_measure = true ): string {
750 + $src = '';
751 + $named_out = '';
752 + if ( preg_match( '#(?<![-\w])src\s*=\s*["\']([^"\']+)["\']#i', $tag, $m ) ) {
753 + $src = trim( $m[1] );
754 + if ( '' !== $src && ! self::is_placeholder_src( $src ) ) {
755 + return $src;
756 + }
757 + }
758 +
759 + foreach ( array( 'data-lazy', 'data-src', 'data-lazy-src', 'data-original' ) as $attr ) {
760 + // Anchor with a negative lookbehind, not `\b` and not
761 + // whitespace. `\b` sits between `-` and `d`, so a bare
762 + // `\bdata-src=` also matched the TAIL of `x-data-src=` and took
763 + // the wrong image's URL — worse than no size, because it reserves
764 + // a wrongly shaped box and CAUSES the shift.
765 + //
766 + // Requiring whitespace instead was my first fix and it was wrong:
767 + // attributes are not always separated by one (`alt="31"srcset=`
768 + // is valid), so that anchor silently stopped matching and handed
769 + // back a size for a tag the guard should have skipped. The
770 + // lookbehind rejects the same prefixed decoys without depending on
771 + // spacing, and is what `src` already uses two methods below.
772 + // (#333 review rounds 2 and 3, issue 1)
773 + if ( preg_match( '#(?<![-\w])' . preg_quote( $attr, '#' ) . '\s*=\s*["\']([^"\']+)["\']#i', $tag, $m ) ) {
774 + $url = trim( $m[1] );
775 + if ( '' === $url ) {
776 + continue;
777 + }
778 + if ( ! self::is_placeholder_src( $url ) ) {
779 + return $url;
780 + }
781 + // Refused on its NAME. Remember it — if nothing else in the
782 + // tag resolves, the file itself gets the final say below.
783 + if ( '' === $named_out ) {
784 + $named_out = $url;
785 + }
786 + }
787 + }
788 +
789 + // Every candidate was refused on its NAME alone. A name is a guess;
790 + // the file is the fact. Someone who uploads a photograph called
791 + // `placeholder.jpg` — an entirely ordinary thing to find in a media
792 + // library — got no dimensions at all, and neither did any of the
793 + // copies WordPress generates from it, so the layout shift this
794 + // feature removes came straight back for those images with nothing on
795 + // screen to explain why. (#333 review round 2, issue 1)
796 + //
797 + // Only reached when nothing else in the tag resolved, so the cost is a
798 + // lookup that was about to be skipped entirely, never an extra one.
799 + // A genuine stand-in fails this test on its own merits: a data: URI
800 + // never gets here, and a 1x1 spacer measures 1x1.
801 + //
802 + // The lazy attribute is preferred over `src`, matching the order
803 + // above: when a tag carries both, the lazy one names the real image
804 + // and `src` holds the stand-in.
805 + // The warm-up collector passes false here, and must. Deciding this by
806 + // MEASURING is circular for the caller whose whole job is to arrange
807 + // the measurement: remote lookups are gated off until `$warming` is
808 + // true, `$warming` only becomes true inside warm_dimensions(), and
809 + // warm_dimensions() is never reached because this returned ''. A
810 + // remote `placeholder.jpg` — a real photograph on a CDN — was warmable
811 + // before this branch and stopped being, with a failure cached against
812 + // it for good measure. The collector takes the URL the tag offers and
813 + // lets warm_dimensions() be the thing that decides.
814 + // (#333 review round 3, issue 3)
815 + if ( ! $may_measure ) {
816 + return '' !== $named_out ? $named_out : $src;
817 + }
818 +
819 + foreach ( array( $named_out, $src ) as $candidate ) {
820 + if ( '' !== $candidate && self::is_real_image( $candidate ) ) {
821 + return $candidate;
822 + }
823 + }
824 +
825 + return '';
826 + }
827 +
828 + /**
829 + * Does this URL resolve to something too big to be a lazy-load stand-in?
830 + *
831 + * The stand-ins this guards against are 1x1 spacers and inline data: URIs.
832 + * Anything with real extent is a real image, whatever it is called — which
833 + * is what lets a photograph named `placeholder.jpg` keep its dimensions
834 + * while `spacer.gif` still loses them.
835 + *
836 + * Deliberately conservative: an unresolvable URL returns false, so the
837 + * name-based verdict stands and the tag is left alone. Better no
838 + * dimensions than wrong ones. Uses the same resolver (and therefore the
839 + * same cache) as the normal path, so this costs no extra lookup.
840 + */
841 + private static function is_real_image( string $src ): bool {
842 + $dims = self::dimensions_for_src( $src );
843 + if ( ! is_array( $dims ) ) {
844 + return false;
845 + }
846 + // Indexed [ width, height ] — the shape apply_dimensions() consumes.
847 + $w = isset( $dims[0] ) ? (int) $dims[0] : 0;
848 + $h = isset( $dims[1] ) ? (int) $dims[1] : 0;
849 +
850 + // A few pixels either way is still a spacer — some libraries ship a
851 + // 2x2 or 4x4 rather than a true 1x1. Anything above that has extent a
852 + // stand-in does not.
853 + return $w > 4 && $h > 4;
854 + }
855 +
856 + /**
857 + * True for the stand-in a lazy-loader parks in `src` until its JS swaps
858 + * the real URL in: an inline data: URI, or a `spacer`/`blank`/`placeholder`
859 + * asset. Measuring one of these would stamp the spacer's dimensions onto
860 + * the tag. Pure — unit-tested.
861 + *
862 + * Matched on the WHOLE filename stem, not a word inside it. A word-boundary
863 + * search anywhere in the last segment caught every real image whose name
864 + * merely contains one of these ordinary words — `blank-space-cover.png`,
865 + * `placeholder-portrait.png`, `spacer-hero-banner.jpg` — and silently
866 + * stopped sizing them, which brings back the very layout shift this
867 + * feature exists to prevent, with nothing on screen to explain it
868 + * (#333 review, issue 1).
869 + *
870 + * A real stand-in is named for what it is and nothing else: `blank.gif`,
871 + * `spacer.png`, `lazy-loader.svg`, optionally with a dimension or version
872 + * suffix (`blank-1x1.gif`, `spacer@2x.png`). A descriptive tail is what
873 + * separates a photograph from a spacer, so the tail is what decides.
874 + */
875 + public static function is_placeholder_src( string $src ): bool {
876 + if ( 0 === stripos( $src, 'data:' ) ) {
877 + return true;
878 + }
879 +
880 + // Last path segment, without the query string or fragment —
881 + // `?v=placeholder` is a cache-buster on a real image, not a name.
882 + // Plain string work on purpose: this method is pure and unit-tested
883 + // with no WordPress loaded, so wp_parse_url() is not available.
884 + $path = strtok( $src, '?#' );
885 + if ( ! is_string( $path ) || '' === $path ) {
886 + $path = $src;
887 + }
888 + $name = strtolower( basename( $path ) );
889 +
890 + // Drop the extension, then any trailing dimension/DPR/version marker.
891 + $stem = preg_replace( '#\.[a-z0-9]+$#', '', $name );
892 + $stem = (string) preg_replace( '#[-_@]?(?:\d+x\d+|\d+x|x\d+|v\d+|\d+)$#', '', (string) $stem );
893 + $stem = trim( $stem, '-_.' );
894 +
895 + return 1 === preg_match( '#^(?:spacer|blank|placeholder|lazy-?loader|transparent|pixel|dummy)$#', $stem );
896 + }
897 +
898 + /**
702 899 * True when the tag already declares itself the LCP image via
703 900 * `fetchpriority="high"`.
704 901 *
705 902 * Only "high" counts. `fetchpriority="low"` and `="auto"` say the opposite
@@ -720,9 +917,22 @@
720 917 * own `wp_filter_content_tags()` adds dimensions to responsive
721 918 * images the same way. Pure — unit-tested.
722 919 */
723 920 public static function has_constrained_render( string $tag ): bool {
724 - if ( preg_match( '#\bsrcset\s*=#i', $tag ) || preg_match( '#\bsizes\s*=#i', $tag ) ) {
921 + // `\b` sits between `-` and `s`, so a bare \bsrcset also matched
922 + // `data-srcset` — a lazy attribute the browser has NOT applied yet.
923 + // That made an unset attribute suppress dimensions on exactly the
924 + // slider images this feature exists to size, for the same
925 + // accidental-text-match reason the URL lookup moved away from
926 + // (#333 review, issue 3).
927 + //
928 + // The anchor is a negative lookbehind rather than "start or
929 + // whitespace": HTML does not require a space between attributes, so
930 + // `alt="31"srcset="..."` slipped past a whitespace anchor and this
931 + // guard stopped firing — the tag then got the file's intrinsic size
932 + // stamped on it while the browser rendered a differently-shaped
933 + // srcset candidate. (#333 review round 3, issue 1)
934 + if ( preg_match( '#(?<![-\w])(?:srcset|sizes)\s*=#i', $tag ) ) {
725 935 return true;
726 936 }
727 937 if ( preg_match( '#\bstyle\s*=\s*["\']([^"\']*)["\']#i', $tag, $m ) ) {
728 938 // width/height in the inline style wins over the attribute, so
@@ -792,9 +1002,12 @@
792 1002 private static function attr_int( string $tag, string $name ): int {
793 1003 // The value must be ENTIRELY digits. Matching a leading run would read
794 1004 // `width="50%"` as 50 and scale from a percentage as though it were
795 1005 // 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 ) ) {
1006 + // Lookbehind for the same reason as set_attr(): `\bwidth=` also reads
1007 + // `data-width=`, so a slider's own metadata was scaled from as though
1008 + // it were a rendered dimension.
1009 + if ( ! preg_match( '#(?<![-\w])' . preg_quote( $name, '#' ) . '\s*=\s*(?:"(\d+)"|\'(\d+)\'|(\d+)(?=[\s/>]))#i', $tag, $m ) ) {
797 1010 return 0;
798 1011 }
799 1012 $value = '' !== ( $m[1] ?? '' ) ? $m[1] : ( '' !== ( $m[2] ?? '' ) ? $m[2] : ( $m[3] ?? '' ) );
800 1013 return (int) $value;