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/modules/TurboRender/TurboRenderModule.php +128 -5 1.3.6 → 1.4.0 View file →
@@ -75,8 +75,24 @@
75 75 /**
76 76 * Spans whose markup is text, not the page: a section tag inside a
77 77 * JS template string or a comment must not be stamped.
78 78 */
79 + /**
80 + * Background images inside a deferred section stay off until the
81 + * section is near the viewport. Scoped to `.xs-tbg`, which only the
82 + * script below sets: without JavaScript, or without
83 + * IntersectionObserver, nothing is held. Screen only, so a printed page
84 + * keeps every background.
85 + */
86 + 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>';
87 +
88 + /**
89 + * Releases a section's backgrounds 1000px before it scrolls into view.
90 + * data-xs-nodelay keeps Delay JS from holding it until the first
91 + * interaction, which would leave every deferred background blank.
92 + */
93 + 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>';
94 +
79 95 private const MASKED_SPANS = '<script\b[^>]*>.*?</script>|<textarea\b[^>]*>.*?</textarea>|<noscript\b[^>]*>.*?</noscript>|<!--.*?-->';
80 96
81 97 /** Fallback: a child spanning less bytes than this is decoration (an empty notices div, a spacer), not a section. */
82 98 private const FALLBACK_MIN_SPAN = 150;
@@ -87,9 +103,10 @@
87 103 public function ui_metadata(): array {
88 104 return array(
89 105 'label' => __( 'Turbo Render', 'xspeed' ),
90 106 'icon' => 'Layers',
91 - 'description' => __( 'Paints the top of your page first and brings the rest in as visitors scroll. Every section still loads in full.', 'xspeed' ),
107 + 'description' => __( 'Shows the top of each page first and the rest as visitors scroll.', 'xspeed' ),
108 + 'group' => 'performance',
92 109 );
93 110 }
94 111
95 112 public function settings_schema(): array {
@@ -97,9 +114,9 @@
97 114 'enabled' => array(
98 115 'type' => 'bool',
99 116 'default' => false,
100 117 'label' => __( 'Render as visitors scroll', 'xspeed' ),
101 - 'description' => __( 'Lets the browser spend its first moments on what visitors actually see, then render the lower sections just before they scroll into view. The content of your page is unchanged — it simply arrives in a smarter order.', 'xspeed' ),
118 + 'description' => __( 'The browser draws what visitors see first and draws lower sections just before they scroll to them. Your content does not change.', 'xspeed' ),
102 119 ),
103 120 'skip_first' => array(
104 121 'type' => 'int',
105 122 'default' => self::DEFAULT_SKIP_FIRST,
@@ -105,21 +122,25 @@
105 122 'default' => self::DEFAULT_SKIP_FIRST,
106 123 'min' => 1,
107 124 'max' => 10,
108 125 'label' => __( 'Sections to render immediately', 'xspeed' ),
109 - 'description' => __( 'How many sections at the top of the page are rendered right away, before any deferring begins. The default of 2 suits most layouts. Increase it if a section near the top of your page appears a moment late.', 'xspeed' ),
126 + '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' ),
127 + 'dependsOn' => array( 'field' => 'enabled' ),
110 128 ),
111 129 'section_classes' => array(
112 130 'type' => 'list',
113 131 'default' => self::DEFAULT_CLASSES,
114 132 'label' => __( 'Section classes', 'xspeed' ),
115 - 'description' => __( 'Tells xSpeed which parts of your page count as sections. The defaults cover Elementor, Divi, Bricks, Oxygen, and Beaver Builder — and when none of them match, xSpeed falls back to your page\'s own top-level sections automatically. Add a class here only if you want to target something specific.', 'xspeed' ),
133 + 'description' => __( 'CSS classes that mark a section. The defaults cover the main page builders, and other themes are detected on their own.', 'xspeed' ),
134 + 'advanced' => true,
135 + 'dependsOn' => array( 'field' => 'enabled' ),
116 136 ),
117 137 'excluded_classes' => array(
118 138 'type' => 'list',
119 139 'default' => array(),
120 140 'label' => __( 'Excluded classes', 'xspeed' ),
121 - 'description' => __( 'Sections with any of these classes always render right away. Use this when a design overlaps its neighbours — a card that hangs over the section below it gets cut off at the boundary when either section is deferred.', 'xspeed' ),
141 + 'description' => __( 'Sections with any of these classes are always drawn right away. Add one here if part of a section gets cut off where it overlaps the next.', 'xspeed' ),
142 + 'dependsOn' => array( 'field' => 'enabled' ),
122 143 ),
123 144 );
124 145 }
125 146
@@ -257,8 +278,25 @@
257 278 */
258 279 $px = max( 100, (int) apply_filters( 'xspeed_turbo_render_intrinsic_px', self::DEFAULT_INTRINSIC_PX ) );
259 280 $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>';
260 281
282 + /**
283 + * Filter whether a deferred section's images and CSS backgrounds wait
284 + * until the visitor scrolls near it.
285 + *
286 + * content-visibility skips a section's rendering, not its downloads:
287 + * the browser still fetched every background a stylesheet gave it,
288 + * and eager images in it. On a live Kadence page a 117 KB row
289 + * background 3,600px down loaded before the hero heading painted,
290 + * and PageSpeed counts every byte that lands before LCP.
291 + *
292 + * @param bool $hold Default true.
293 + */
294 + if ( apply_filters( 'xspeed_turbo_render_hold_media', true ) ) {
295 + $html = self::lazy_section_images( $html );
296 + $style .= self::HOLD_STYLE . self::HOLD_SCRIPT;
297 + }
298 +
261 299 $head_end = stripos( $html, '</head>' );
262 300 return substr_replace( $html, $style, (int) $head_end, 0 );
263 301 }
264 302
@@ -514,8 +552,93 @@
514 552 if ( ! preg_match( '#\G<(/?)[a-zA-Z][a-zA-Z0-9-]*(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#', $masked, $m, 0, $offset ) ) {
515 553 return null;
516 554 }
517 555 return $offset + strlen( $m[0] );
556 + }
557 +
558 + /**
559 + * Make the images inside stamped sections lazy.
560 + *
561 + * A stamped section is below the fold by Turbo Render's own count, so
562 + * loading="eager" there buys nothing: the Lazy module's eager slot had
563 + * gone to the first image of a slider 5,600px down. Left alone: an
564 + * image marked fetchpriority="high", data-skip-lazy or data-no-lazy,
565 + * one matching Lazy's Excluded Images, and any loading value other
566 + * than eager.
567 + */
568 + private static function lazy_section_images( string $html ): string {
569 + $masked = self::mask( $html );
570 + if ( ! preg_match_all( '#<[a-zA-Z][a-zA-Z0-9-]*\s+data-xspeed-turbo=""#', $masked, $opens, PREG_OFFSET_CAPTURE ) ) {
571 + return $html;
572 + }
573 + $spans = array();
574 + $until = -1;
575 + foreach ( $opens[0] as $open ) {
576 + $start = (int) $open[1];
577 + if ( $start < $until ) {
578 + continue; // Nested inside a span already taken.
579 + }
580 + $end = self::element_end( $masked, $start );
581 + if ( null === $end ) {
582 + continue;
583 + }
584 + $spans[] = array( $start, $end );
585 + $until = $end;
586 + }
587 + if ( empty( $spans ) || ! preg_match_all( '#<img\b(?:"[^"]*"|\'[^\']*\'|[^>"\'])*>#i', $masked, $imgs, PREG_OFFSET_CAPTURE ) ) {
588 + return $html;
589 + }
590 +
591 + $lazy = \XSpeed\Settings_Manager::get( 'lazy' );
592 + $excluded = is_array( $lazy ) && is_array( $lazy['excluded_images'] ?? null ) ? $lazy['excluded_images'] : array();
593 +
594 + $edits = array();
595 + foreach ( $imgs[0] as $img ) {
596 + $at = (int) $img[1];
597 + $in = false;
598 + foreach ( $spans as $span ) {
599 + if ( $at > $span[0] && $at < $span[1] ) {
600 + $in = true;
601 + break;
602 + }
603 + }
604 + if ( ! $in ) {
605 + continue;
606 + }
607 + $tag = substr( $html, $at, strlen( (string) $img[0] ) );
608 + $new = self::lazy_img( $tag, $excluded );
609 + if ( $new !== $tag ) {
610 + $edits[] = array( $at, strlen( $tag ), $new );
611 + }
612 + }
613 + foreach ( array_reverse( $edits ) as $edit ) {
614 + $html = substr_replace( $html, $edit[2], $edit[0], $edit[1] );
615 + }
616 + return $html;
617 + }
618 +
619 + /**
620 + * @param string $tag One <img> tag.
621 + * @param string[] $excluded Lazy's Excluded Images patterns.
622 + */
623 + private static function lazy_img( string $tag, array $excluded ): string {
624 + if ( false !== stripos( $tag, 'data-skip-lazy' ) || false !== stripos( $tag, 'data-no-lazy' )
625 + || \XSpeed\Lazy_Loader::has_high_fetchpriority( $tag ) ) {
626 + return $tag;
627 + }
628 + foreach ( $excluded as $pattern ) {
629 + $pattern = (string) $pattern;
630 + if ( '' !== $pattern && false !== stripos( $tag, $pattern ) ) {
631 + return $tag;
632 + }
633 + }
634 + if ( preg_match( '#(?<![-\w])loading\s*=\s*(["\']?)([^"\'\s>]*)\1#i', $tag, $m, PREG_OFFSET_CAPTURE ) ) {
635 + if ( 'eager' !== strtolower( (string) $m[2][0] ) ) {
636 + return $tag;
637 + }
638 + return substr_replace( $tag, 'loading="lazy"', (int) $m[0][1], strlen( (string) $m[0][0] ) );
639 + }
640 + return (string) preg_replace( '#^<img\b#i', '<img loading="lazy"', $tag, 1 );
518 641 }
519 642
520 643 private static function mask( string $html ): string {
521 644 return (string) preg_replace_callback(