PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
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 +107 -20 1.3.7 → 1.4.0 View file →
@@ -714,11 +714,16 @@
714 714 }
715 715
716 716 private static function rewrite_video( array $m ): string {
717 717 $tag = $m[0];
718 - 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' ) ) {
719 721 return $tag;
720 722 }
723 + if ( self::is_excluded( $tag, self::opts() ) ) {
724 + return $tag;
725 + }
721 726 /*
722 727 * An autoplaying video is the one case preload="none" cannot help:
723 728 * browsers fetch an autoplay source regardless of preload, because
724 729 * the author asked for it to start on its own. Setting the attribute
@@ -776,9 +781,14 @@
776 781
777 782 if ( false !== stripos( $tag, 'data-skip-lazy' ) ) {
778 783 return $element;
779 784 }
780 - if ( preg_match( '#\sautoplay(?=[\s/>=])#i', $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 ) ) {
781 791 return $element;
782 792 }
783 793 if ( self::is_excluded( $tag, self::opts() ) ) {
784 794 return $element;
@@ -981,13 +991,38 @@
981 991 *
982 992 * Dependency-free and tiny, matching Video_Facade::facade_script(). The
983 993 * rootMargin starts the fetch slightly before the element is visible so
984 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.
985 1007 */
986 - public static function autoplay_script(): string {
987 - return <<<'JS'
1008 + public static function autoplay_script( bool $after_load = false, array $excluded = array() ): string {
1009 + $script = <<<'JS'
988 1010 (function(){
989 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 +}
990 1025
991 1026 /*
992 1027 * Intercept the ASSIGNMENT, because observing the DOM is always too late.
993 1028 *
@@ -1012,8 +1047,11 @@
1012 1047 var hold=function(el,val){
1013 1048 if(el.tagName!=='VIDEO')return false;
1014 1049 if(el.getAttribute('data-xspeed-loaded'))return false; // released: let it through
1015 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;
1016 1054 el.setAttribute('data-xspeed-src',String(val));
1017 1055 el.setAttribute('data-xspeed-adopted','1');
1018 1056 return true;
1019 1057 };
@@ -1033,14 +1071,20 @@
1033 1071 };
1034 1072 }catch(e){}
1035 1073 function go(v){
1036 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;}
1037 1081 v.setAttribute('data-xspeed-loaded','1');
1038 -var s=v.getAttribute('data-xspeed-src');
1039 -if(s){v.setAttribute('src',s);v.removeAttribute('data-xspeed-src');}
1082 +var r=0,s=v.getAttribute('data-xspeed-src');
1083 +if(s){v.setAttribute('src',s);v.removeAttribute('data-xspeed-src');r=1;}
1040 1084 if(v.getAttribute('data-xspeed-defer-sources')){
1041 1085 var c=v.querySelectorAll('source[data-xspeed-src]');
1042 -for(var i=0;i<c.length;i++){c[i].setAttribute('src',c[i].getAttribute('data-xspeed-src'));c[i].removeAttribute('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;}
1043 1087 v.removeAttribute('data-xspeed-defer-sources');
1044 1088 }
1045 1089
1046 1090 if(v.getAttribute('data-xspeed-autoplay')){v.setAttribute('autoplay','');v.removeAttribute('data-xspeed-autoplay');}
@@ -1045,10 +1089,13 @@
1045 1089
1046 1090 if(v.getAttribute('data-xspeed-autoplay')){v.setAttribute('autoplay','');v.removeAttribute('data-xspeed-autoplay');}
1047 1091 v.removeAttribute('preload');
1048 1092 // load() picks up the sources we just restored; without it a <video>
1049 -// that has already failed to resolve a source will not retry.
1050 -if(v.load)v.load();
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();
1051 1098 }
1052 1099 // A multi-format <video> carries no src of its own — the <source> children
1053 1100 // do, and those sit outside the opening tag PHP rewrote. Strip them here,
1054 1101 // as early as this script runs, then restore on intersect like the rest.
@@ -1077,8 +1124,16 @@
1077 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])');
1078 1125 for(var i=0;i<a.length;i++){
1079 1126 var v=a[i];
1080 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;
1081 1136 var auto=v.hasAttribute('autoplay');
1082 1137 var s=v.getAttribute('src');
1083 1138 if(s){v.setAttribute('data-xspeed-src',s);v.removeAttribute('src');}
1084 1139 var c=v.querySelectorAll('source[src]');
@@ -1089,8 +1144,12 @@
1089 1144 // asked for.
1090 1145 if(auto){v.removeAttribute('autoplay');v.setAttribute('data-xspeed-autoplay','1');}
1091 1146 v.setAttribute('preload','none');
1092 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);
1093 1152 }
1094 1153 }
1095 1154 // A background video (data-xspeed-wait) that reaches the viewport is parked
1096 1155 // here until the visitor first scrolls, taps, types or moves the mouse, then
@@ -1103,34 +1162,62 @@
1103 1162 P=[];
1104 1163 }
1105 1164 for(var k=0;k<E.length;k++)addEventListener(E[k],interacted,{capture:true,passive:true});
1106 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);}
1107 1184 function scan(){
1108 1185 strip();
1109 1186 adopt();
1110 1187 var v=document.querySelectorAll(S);
1111 -if(!('IntersectionObserver'in window)){for(var i=0;i<v.length;i++)reach(v[i]);return;}
1112 -var o=new IntersectionObserver(function(es){
1113 -for(var i=0;i<es.length;i++){if(es[i].isIntersecting){reach(es[i].target);o.unobserve(es[i].target);}}
1114 -},{rootMargin:'200px'});
1115 -for(var j=0;j<v.length;j++)o.observe(v[j]);
1188 +for(var j=0;j<v.length;j++)watch(v[j]);
1116 1189 }
1117 1190 if(document.readyState!=='loading')scan();else document.addEventListener('DOMContentLoaded',scan);
1118 1191 // Players that build their <video> after load (page-builder video blocks)
1119 -// must be caught the INSTANT the element lands. A debounce loses the race:
1120 -// the browser begins fetching as soon as a src is set, so by the time a
1121 -// timer fires the bytes are already committed. adopt() is idempotent and
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
1122 1194 // cheap (one guarded querySelectorAll), so run it synchronously on every
1123 -// mutation and only debounce the fuller scan that attaches observers.
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)
1124 1197 if(window.MutationObserver){
1125 -var t;
1198 +var t=0;
1126 1199 new MutationObserver(function(){
1127 1200 adopt();
1128 -clearTimeout(t);t=setTimeout(scan,200);
1201 +if(!t)t=setTimeout(function(){t=0;scan();},200);
1129 1202 }).observe(document.documentElement,{childList:true,subtree:true});
1130 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 +});
1131 1209 })();
1132 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 );
1133 1220 }
1134 1221
1135 1222 private static function set_attr( string $tag, string $name, string $value, bool $only_if_missing = false ): string {
1136 1223 // Lookbehind, not `\b`: writing `width` onto a tag carrying