PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 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 All 36 releases
← All changes | includes/modules/TurboRender/TurboRenderModule.php +268 -3 1.3.7 → 1.4.2 View file →
@@ -61,10 +61,16 @@
61 61 'ct-section', // Oxygen.
62 62 'fl-row', // Beaver Builder.
63 63 );
64 64
65 - /** Sections presumed above the fold and left untouched. */
66 - private const DEFAULT_SKIP_FIRST = 2;
65 + /**
66 + * Sections presumed above the fold and left untouched. Three, not two:
67 + * the only other automatic implementation (WP Rocket's lazy render)
68 + * refuses anything within 1800px of the viewport bottom, and until the
69 + * fold is measured a section count should err the same way. Only a
70 + * fresh install sees the change — a stored value is kept as is.
71 + */
72 + private const DEFAULT_SKIP_FIRST = 3;
67 73
68 74 /**
69 75 * Pre-first-render size estimate (px) for contain-intrinsic-size.
70 76 * Only the scrollbar sees it, and only until a section has rendered
@@ -75,8 +81,24 @@
75 81 /**
76 82 * Spans whose markup is text, not the page: a section tag inside a
77 83 * JS template string or a comment must not be stamped.
78 84 */
85 + /**
86 + * Background images inside a deferred section stay off until the
87 + * section is near the viewport. Scoped to `.xs-tbg`, which only the
88 + * script below sets: without JavaScript, or without
89 + * IntersectionObserver, nothing is held. Screen only, so a printed page
90 + * keeps every background.
91 + */
92 + private const HOLD_STYLE = '<style id="xspeed-turbo-hold">@media screen{.xs-tbg [data-xspeed-turbo]:not([data-xspeed-near]),.xs-tbg [data-xspeed-turbo]:not([data-xspeed-near]) *{background-image:none!important}}</style>';
93 +
94 + /**
95 + * Releases a section's backgrounds 1000px before it scrolls into view.
96 + * data-xs-nodelay keeps Delay JS from holding it until the first
97 + * interaction, which would leave every deferred background blank.
98 + */
99 + private const HOLD_SCRIPT = '<script id="xspeed-turbo-hold-js" data-xs-nodelay>(function(d){if(!("IntersectionObserver" in window))return;d.documentElement.classList.add("xs-tbg");var io=new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){e.target.setAttribute("data-xspeed-near","");io.unobserve(e.target);}});},{rootMargin:"1000px 0px"});function go(){d.querySelectorAll("[data-xspeed-turbo]").forEach(function(s){io.observe(s);});}if(d.readyState!=="loading")go();else d.addEventListener("DOMContentLoaded",go);})(document);</script>';
100 +
79 101 private const MASKED_SPANS = '<script\b[^>]*>.*?</script>|<textarea\b[^>]*>.*?</textarea>|<noscript\b[^>]*>.*?</noscript>|<!--.*?-->';
80 102
81 103 /** Fallback: a child spanning less bytes than this is decoration (an empty notices div, a spacer), not a section. */
82 104 private const FALLBACK_MIN_SPAN = 150;
@@ -83,8 +105,11 @@
83 105
84 106 /** Fallback: a child holding this share of its siblings' combined bytes is a wrapper to unwrap, not a section. */
85 107 private const FALLBACK_DOMINANT = 0.6;
86 108
109 + /** Our own output-buffer nesting level, when we had to open one. */
110 + private ?int $buffer_level = null;
111 +
87 112 public function ui_metadata(): array {
88 113 return array(
89 114 'label' => __( 'Turbo Render', 'xspeed' ),
90 115 'icon' => 'Layers',
@@ -106,9 +131,9 @@
106 131 'default' => self::DEFAULT_SKIP_FIRST,
107 132 'min' => 1,
108 133 'max' => 10,
109 134 'label' => __( 'Sections to render immediately', 'xspeed' ),
110 - 'description' => __( 'How many sections at the top are drawn right away. The default of 2 suits most pages; raise it if a section near the top appears late.', 'xspeed' ),
135 + 'description' => __( 'How many sections at the top are drawn right away. The default of 3 suits most pages; raise it if a section near the top appears late.', 'xspeed' ),
111 136 'dependsOn' => array( 'field' => 'enabled' ),
112 137 ),
113 138 'section_classes' => array(
114 139 'type' => 'list',
@@ -148,11 +173,89 @@
148 173 // <head>, this pass rewrites body sections — ordering only matters in
149 174 // that our injected <style> must survive, and later passes never
150 175 // strip inline styles.
151 176 add_filter( 'xspeed_cache_final_html', array( $this, 'process' ), 8 );
177 + // That filter fires only when the page cache stores a page. With the
178 + // cache off, or on a URL it excludes, nothing else hands us the HTML,
179 + // so open our own buffer then (#507), the way Combine CSS does (#195).
180 + // Combine's buffer opens first and is the outer one, so this pass
181 + // runs before it there: harmless, since we only add a <style> to
182 + // <head>, which Combine leaves in place.
183 + add_action( 'template_redirect', array( $this, 'maybe_buffer' ), 1 );
152 184 }
153 185
154 186 /**
187 + * Buffer the page ourselves when the page cache will not.
188 + *
189 + * Stands down on the same requests Combine CSS's buffer does, and when
190 + * the page cache is buffering: it will call process() through its filter,
191 + * and a second buffer would only copy the page again.
192 + */
193 + public function maybe_buffer(): void {
194 + if ( is_admin() || wp_doing_ajax() || wp_doing_cron()
195 + || ( defined( 'REST_REQUEST' ) && REST_REQUEST )
196 + || ( defined( 'WP_CLI' ) && WP_CLI )
197 + || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST )
198 + || ( function_exists( 'is_feed' ) && is_feed() )
199 + || ( function_exists( 'is_user_logged_in' ) && is_user_logged_in() )
200 + || \XSpeed\Builder_Editor::is_active() ) {
201 + return; // process() never stamps for a signed-in visitor either.
202 + }
203 + if ( class_exists( '\\XSpeed\\Cache' ) && \XSpeed\Cache::is_buffering() ) {
204 + return;
205 + }
206 + if ( $this->request_opted_out() ) {
207 + return;
208 + }
209 + ob_start( array( $this, 'filter_buffer' ) );
210 + $this->buffer_level = ob_get_level();
211 + add_action( 'shutdown', array( $this, 'close_buffer' ), 0 );
212 + }
213 +
214 + /**
215 + * Pages the site has marked as not-for-optimizing, which Turbo Render
216 + * leaves alone even with the page cache off.
217 + *
218 + * With the cache on, a page reached Turbo Render only by being cached, so
219 + * the cache's URL exclusions (cart, checkout, my-account by default, plus
220 + * whatever the site added) kept it off transactional pages. Its own
221 + * buffer must keep that promise, or turning on Turbo Render would start
222 + * deferring sections of a checkout. The WooCommerce conditionals cover a
223 + * store whose cart or checkout lives at a translated slug the URL list
224 + * does not name.
225 + */
226 + public function request_opted_out(): bool {
227 + foreach ( array( 'is_cart', 'is_checkout', 'is_account_page' ) as $fn ) {
228 + if ( function_exists( $fn ) && $fn() ) {
229 + return true;
230 + }
231 + }
232 + if ( ! class_exists( '\\XSpeed\\Settings_Manager' ) || ! class_exists( '\\XSpeed\\Cache' ) ) {
233 + return false;
234 + }
235 + $cache = \XSpeed\Settings_Manager::get( 'cache' );
236 + $excluded = is_array( $cache['excluded_urls'] ?? null ) ? $cache['excluded_urls'] : array();
237 + // The cache's own matcher: it keeps percent-escapes that
238 + // sanitize_text_field() would delete, so an entry for a non-ASCII
239 + // slug matches here too.
240 + return array() !== $excluded && \XSpeed\Cache::request_matches_exclusions( $excluded );
241 + }
242 +
243 + /** ob_start() callback. */
244 + public function filter_buffer( string $buffer ): string {
245 + $out = $this->process( $buffer );
246 + return is_string( $out ) ? $out : $buffer;
247 + }
248 +
249 + /** Flush only the buffer we opened. */
250 + public function close_buffer(): void {
251 + if ( null !== $this->buffer_level && ob_get_level() >= $this->buffer_level ) {
252 + ob_end_flush();
253 + $this->buffer_level = null;
254 + }
255 + }
256 +
257 + /**
155 258 * Stamp below-fold top-level sections and inject the one style block.
156 259 *
157 260 * @param mixed $html Final page buffer.
158 261 * @return mixed
@@ -163,8 +266,13 @@
163 266 }
164 267 if ( false === stripos( $html, '</head>' ) ) {
165 268 return $html; // Not a full document (fragment, feed, JSON).
166 269 }
270 + // Already processed: our buffer and the cache filter can both see a
271 + // page, and stamping twice would add a second style block.
272 + if ( false !== strpos( $html, '<style id="xspeed-turbo">' ) ) {
273 + return $html;
274 + }
167 275 if ( function_exists( 'is_user_logged_in' ) && is_user_logged_in() ) {
168 276 return $html;
169 277 }
170 278 // A measurement fetch (`?xspeed_css=off` — the render a CSS
@@ -211,8 +319,15 @@
211 319 }
212 320
213 321 $excluded = $this->excluded_pattern();
214 322
323 + // Document order and the end of <body>, the fallback span bound for
324 + // the containment check below.
325 + $sorted = $offsets;
326 + sort( $sorted );
327 + $body_end = stripos( $masked, '</body>' );
328 + $doc_end = false === $body_end ? strlen( $masked ) : (int) $body_end;
329 +
215 330 $seen = 0;
216 331 $edits = array();
217 332 foreach ( $offsets as $offset ) {
218 333 ++$seen;
@@ -238,8 +353,11 @@
238 353 $span_end = self::element_end( $masked, $offset );
239 354 if ( null !== $span_end && false !== stripos( substr( $masked, $offset, $span_end - $offset ), '<iframe' ) ) {
240 355 continue;
241 356 }
357 + if ( self::breaks_under_containment( $masked, $offset, $span_end, $sorted, $doc_end ) ) {
358 + continue;
359 + }
242 360 $edits[] = $offset;
243 361 }
244 362
245 363 if ( empty( $edits ) ) {
@@ -262,8 +380,25 @@
262 380 */
263 381 $px = max( 100, (int) apply_filters( 'xspeed_turbo_render_intrinsic_px', self::DEFAULT_INTRINSIC_PX ) );
264 382 $style = '<style id="xspeed-turbo">[data-xspeed-turbo]{content-visibility:auto;contain-intrinsic-size:auto ' . $px . 'px}@media print{[data-xspeed-turbo]{content-visibility:visible}}</style>';
265 383
384 + /**
385 + * Filter whether a deferred section's images and CSS backgrounds wait
386 + * until the visitor scrolls near it.
387 + *
388 + * content-visibility skips a section's rendering, not its downloads:
389 + * the browser still fetched every background a stylesheet gave it,
390 + * and eager images in it. On a live Kadence page a 117 KB row
391 + * background 3,600px down loaded before the hero heading painted,
392 + * and PageSpeed counts every byte that lands before LCP.
393 + *
394 + * @param bool $hold Default true.
395 + */
396 + if ( apply_filters( 'xspeed_turbo_render_hold_media', true ) ) {
397 + $html = self::lazy_section_images( $html );
398 + $style .= self::HOLD_STYLE . self::HOLD_SCRIPT;
399 + }
400 +
266 401 $head_end = stripos( $html, '</head>' );
267 402 return substr_replace( $html, $style, (int) $head_end, 0 );
268 403 }
269 404
@@ -454,8 +589,53 @@
454 589 return $children;
455 590 }
456 591
457 592 /**
593 + * Whether stamping this section would break something inside it.
594 + *
595 + * `content-visibility: auto` applies layout, style and paint
596 + * containment even while the section is on screen and rendered. Two
597 + * kinds of content break under that:
598 + *
599 + * - an SVG `<use>`: paint containment breaks sprite references (WP
600 + * Rocket skips these too);
601 + * - an inline `position: fixed` or `sticky`: the section becomes the
602 + * containing block, so a back-to-top button or a sticky bar is
603 + * positioned against the section instead of the viewport.
604 + *
605 + * The span runs to the element's close tag. When element_end() can't
606 + * pair it, the span runs to the next candidate (or `</body>` for the
607 + * last one) instead: overshooting into a sibling only skips more, which
608 + * is the safe direction. Read from the masked copy, so text in a script
609 + * (`el.style.position = 'fixed'`) never counts. (#478)
610 + *
611 + * @param string $masked Markup with script/textarea/noscript/comments nulled.
612 + * @param int $offset The candidate's `<`.
613 + * @param int|null $span_end element_end() for this candidate.
614 + * @param int[] $sorted Every candidate offset, ascending.
615 + * @param int $doc_end Offset of `</body>`, or the buffer length.
616 + */
617 + private static function breaks_under_containment( string $masked, int $offset, ?int $span_end, array $sorted, int $doc_end ): bool {
618 + $stop = $span_end;
619 + if ( null === $stop ) {
620 + $stop = $doc_end;
621 + foreach ( $sorted as $next ) {
622 + if ( $next > $offset ) {
623 + $stop = $next;
624 + break;
625 + }
626 + }
627 + }
628 + $span = substr( $masked, $offset, max( 0, $stop - $offset ) );
629 + // A <style> element in the body often carries rules for other parts
630 + // of the page (a sticky header rule printed by a page builder), so it
631 + // says nothing about this section. Inline style="" attributes do.
632 + $span = (string) preg_replace( '#<style\b[^>]*>.*?</style>#is', '', $span );
633 +
634 + return (bool) preg_match( '#<use[\s/>]|position\s*:\s*(?:-webkit-)?(?:fixed|sticky)\b#i', $span );
635 + }
636 +
637 + /**
458 638 * The offset just past an element's close tag — the same tag walk as
459 639 * element_children(), seeded with the element's own tag, so implicitly
460 640 * closed tags don't desync it. Null when the element never closes (or
461 641 * the markup is too broken to tell), in which case the caller keeps
@@ -519,8 +699,93 @@
519 699 if ( ! preg_match( '#\G<(/?)[a-zA-Z][a-zA-Z0-9-]*(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#', $masked, $m, 0, $offset ) ) {
520 700 return null;
521 701 }
522 702 return $offset + strlen( $m[0] );
703 + }
704 +
705 + /**
706 + * Make the images inside stamped sections lazy.
707 + *
708 + * A stamped section is below the fold by Turbo Render's own count, so
709 + * loading="eager" there buys nothing: the Lazy module's eager slot had
710 + * gone to the first image of a slider 5,600px down. Left alone: an
711 + * image marked fetchpriority="high", data-skip-lazy or data-no-lazy,
712 + * one matching Lazy's Excluded Images, and any loading value other
713 + * than eager.
714 + */
715 + private static function lazy_section_images( string $html ): string {
716 + $masked = self::mask( $html );
717 + if ( ! preg_match_all( '#<[a-zA-Z][a-zA-Z0-9-]*\s+data-xspeed-turbo=""#', $masked, $opens, PREG_OFFSET_CAPTURE ) ) {
718 + return $html;
719 + }
720 + $spans = array();
721 + $until = -1;
722 + foreach ( $opens[0] as $open ) {
723 + $start = (int) $open[1];
724 + if ( $start < $until ) {
725 + continue; // Nested inside a span already taken.
726 + }
727 + $end = self::element_end( $masked, $start );
728 + if ( null === $end ) {
729 + continue;
730 + }
731 + $spans[] = array( $start, $end );
732 + $until = $end;
733 + }
734 + if ( empty( $spans ) || ! preg_match_all( '#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i', $masked, $imgs, PREG_OFFSET_CAPTURE ) ) {
735 + return $html;
736 + }
737 +
738 + $lazy = \XSpeed\Settings_Manager::get( 'lazy' );
739 + $excluded = is_array( $lazy ) && is_array( $lazy['excluded_images'] ?? null ) ? $lazy['excluded_images'] : array();
740 +
741 + $edits = array();
742 + foreach ( $imgs[0] as $img ) {
743 + $at = (int) $img[1];
744 + $in = false;
745 + foreach ( $spans as $span ) {
746 + if ( $at > $span[0] && $at < $span[1] ) {
747 + $in = true;
748 + break;
749 + }
750 + }
751 + if ( ! $in ) {
752 + continue;
753 + }
754 + $tag = substr( $html, $at, strlen( (string) $img[0] ) );
755 + $new = self::lazy_img( $tag, $excluded );
756 + if ( $new !== $tag ) {
757 + $edits[] = array( $at, strlen( $tag ), $new );
758 + }
759 + }
760 + foreach ( array_reverse( $edits ) as $edit ) {
761 + $html = substr_replace( $html, $edit[2], $edit[0], $edit[1] );
762 + }
763 + return $html;
764 + }
765 +
766 + /**
767 + * @param string $tag One <img> tag.
768 + * @param string[] $excluded Lazy's Excluded Images patterns.
769 + */
770 + private static function lazy_img( string $tag, array $excluded ): string {
771 + if ( false !== stripos( $tag, 'data-skip-lazy' ) || false !== stripos( $tag, 'data-no-lazy' )
772 + || \XSpeed\Lazy_Loader::has_high_fetchpriority( $tag ) ) {
773 + return $tag;
774 + }
775 + foreach ( $excluded as $pattern ) {
776 + $pattern = (string) $pattern;
777 + if ( '' !== $pattern && false !== stripos( $tag, $pattern ) ) {
778 + return $tag;
779 + }
780 + }
781 + if ( preg_match( '#(?<![-\w])loading\s*=\s*(["\']?)([^"\'\s>]*)\1#i', $tag, $m, PREG_OFFSET_CAPTURE ) ) {
782 + if ( 'eager' !== strtolower( (string) $m[2][0] ) ) {
783 + return $tag;
784 + }
785 + return substr_replace( $tag, 'loading="lazy"', (int) $m[0][1], strlen( (string) $m[0][0] ) );
786 + }
787 + return (string) preg_replace( '#^<img\b#i', '<img loading="lazy"', $tag, 1 );
523 788 }
524 789
525 790 private static function mask( string $html ): string {
526 791 return (string) preg_replace_callback(