PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
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 +244 -9 1.3.5 → 1.4.1 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,16 +122,26 @@
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 ),
137 + 'excluded_classes' => array(
138 + 'type' => 'list',
139 + 'default' => array(),
140 + 'label' => __( 'Excluded classes', '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' ),
143 + ),
117 144 );
118 145 }
119 146
120 147 public function boot(): void {
@@ -198,8 +225,10 @@
198 225 if ( empty( $offsets ) && apply_filters( 'xspeed_turbo_render_fallback', true ) ) {
199 226 $offsets = self::main_children( $masked, $skip );
200 227 }
201 228
229 + $excluded = $this->excluded_pattern();
230 +
202 231 $seen = 0;
203 232 $edits = array();
204 233 foreach ( $offsets as $offset ) {
205 234 ++$seen;
@@ -209,11 +238,24 @@
209 238 $end = self::tag_end( $masked, $offset );
210 239 if ( null === $end ) {
211 240 continue; // Unterminated tag at EOF — never stamp it.
212 241 }
213 - if ( false !== stripos( substr( $html, $offset, $end - $offset ), 'data-xspeed-turbo' ) ) {
242 + $open_tag = substr( $html, $offset, $end - $offset );
243 + if ( false !== stripos( $open_tag, 'data-xspeed-turbo' ) ) {
214 244 continue;
215 245 }
246 + if ( '' !== $excluded && preg_match( $excluded, $open_tag ) ) {
247 + continue; // Opted out — an overlap design the stamp would clip.
248 + }
249 + // `content-visibility` implies paint containment: it clips
250 + // content that overhangs the section, and skipped iframes can
251 + // blank or reload when the section re-renders. An embed holder
252 + // (map, video) is never worth deferring — skip it. (Found live:
253 + // a Kadence maps container painted over the card overlapping it.)
254 + $span_end = self::element_end( $masked, $offset );
255 + if ( null !== $span_end && false !== stripos( substr( $masked, $offset, $span_end - $offset ), '<iframe' ) ) {
256 + continue;
257 + }
216 258 $edits[] = $offset;
217 259 }
218 260
219 261 if ( empty( $edits ) ) {
@@ -236,8 +278,25 @@
236 278 */
237 279 $px = max( 100, (int) apply_filters( 'xspeed_turbo_render_intrinsic_px', self::DEFAULT_INTRINSIC_PX ) );
238 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>';
239 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 +
240 299 $head_end = stripos( $html, '</head>' );
241 300 return substr_replace( $html, $style, (int) $head_end, 0 );
242 301 }
243 302
@@ -250,13 +309,49 @@
250 309 * Filter the class names identifying a top-level page section.
251 310 *
252 311 * @param string[] $classes
253 312 */
254 - $classes = (array) apply_filters( 'xspeed_turbo_render_classes', $classes );
313 + return self::sanitize_classes( (array) apply_filters( 'xspeed_turbo_render_classes', $classes ) );
314 + }
255 315
256 - // Class names end up inside a regex alternation; anything that is
257 - // not a plausible CSS class token is dropped rather than escaped
258 - // into something surprising.
316 + /** @return string[] */
317 + private function excluded_classes(): array {
318 + $classes = $this->get_setting( 'excluded_classes', array() );
319 + $classes = is_array( $classes ) ? $classes : array();
320 +
321 + /**
322 + * Filter the class names whose sections are never stamped.
323 + *
324 + * @param string[] $classes
325 + */
326 + return self::sanitize_classes( (array) apply_filters( 'xspeed_turbo_render_excluded_classes', $classes ) );
327 + }
328 +
329 + /**
330 + * The exclusion regex for an open tag, or '' when nothing is excluded.
331 + * Token-bounded, unlike the section scan: an exclusion is a user-typed
332 + * remedy, and "card" silently matching "cardigan-grid" would make it
333 + * look like the setting does nothing.
334 + */
335 + private function excluded_pattern(): string {
336 + $classes = $this->excluded_classes();
337 + if ( empty( $classes ) ) {
338 + return '';
339 + }
340 + return '#\bclass\s*=\s*(["\'])[^"\']*(?<![A-Za-z0-9_-])(?:'
341 + . implode( '|', array_map( 'preg_quote', $classes ) )
342 + . ')(?![A-Za-z0-9_-])[^"\']*\1#i';
343 + }
344 +
345 + /**
346 + * Class names end up inside a regex alternation; anything that is not a
347 + * plausible CSS class token is dropped rather than escaped into
348 + * something surprising.
349 + *
350 + * @param string[] $classes
351 + * @return string[]
352 + */
353 + private static function sanitize_classes( array $classes ): array {
259 354 return array_values(
260 355 array_filter(
261 356 array_map( 'strval', $classes ),
262 357 static fn( string $c ): bool => (bool) preg_match( '/^[A-Za-z0-9_-]+$/', $c )
@@ -392,8 +487,61 @@
392 487 return $children;
393 488 }
394 489
395 490 /**
491 + * The offset just past an element's close tag — the same tag walk as
492 + * element_children(), seeded with the element's own tag, so implicitly
493 + * closed tags don't desync it. Null when the element never closes (or
494 + * the markup is too broken to tell), in which case the caller keeps
495 + * the old behavior rather than guessing at a span.
496 + *
497 + * @param string $masked Markup with script/textarea/noscript/comments nulled.
498 + * @param int $offset Byte offset of the element's `<`.
499 + * @return int|null
500 + */
501 + private static function element_end( string $masked, int $offset ): ?int {
502 + static $void = array( 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr' );
503 + static $implicit = array( 'p', 'li', 'dt', 'dd', 'td', 'th', 'tr', 'option', 'optgroup' );
504 +
505 + if ( ! preg_match( '#\G<([a-zA-Z][a-zA-Z0-9-]*)#', $masked, $open, 0, $offset ) ) {
506 + return null;
507 + }
508 + $cursor = self::tag_end( $masked, $offset );
509 + if ( null === $cursor ) {
510 + return null;
511 + }
512 + $stack = array( strtolower( (string) $open[1] ) );
513 + while ( preg_match( '#<(/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|\'[^\']*\'|[^>"\'])*)>#', $masked, $t, PREG_OFFSET_CAPTURE, $cursor ) ) {
514 + $cursor = (int) $t[0][1] + strlen( (string) $t[0][0] );
515 + $closing = '' !== $t[1][0];
516 + $tag = strtolower( (string) $t[2][0] );
517 + if ( in_array( $tag, $void, true ) ) {
518 + continue;
519 + }
520 + if ( ! $closing ) {
521 + if ( in_array( $tag, $implicit, true ) && end( $stack ) === $tag ) {
522 + array_pop( $stack );
523 + }
524 + $stack[] = $tag;
525 + continue;
526 + }
527 + if ( ! in_array( $tag, $stack, true ) ) {
528 + if ( in_array( $tag, $implicit, true ) ) {
529 + continue; // Stray </p>-style close: harmless, skip it.
530 + }
531 + return null; // A close for an ancestor: the element never closed.
532 + }
533 + while ( ! empty( $stack ) && array_pop( $stack ) !== $tag ) {
534 + continue;
535 + }
536 + if ( empty( $stack ) ) {
537 + return $cursor;
538 + }
539 + }
540 + return null;
541 + }
542 +
543 + /**
396 544 * The offset just past an open tag's `>`, quote-aware — a raw strpos
397 545 * would stop at a `>` inside an attribute value.
398 546 *
399 547 * @param string $masked Markup with script/textarea/noscript/comments nulled.
@@ -406,8 +554,93 @@
406 554 }
407 555 return $offset + strlen( $m[0] );
408 556 }
409 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 );
641 + }
642 +
410 643 private static function mask( string $html ): string {
411 644 return (string) preg_replace_callback(
412 645 '#' . self::MASKED_SPANS . '#is',
413 646 static fn( array $m ): string => str_repeat( "\0", strlen( $m[0] ) ),
@@ -438,6 +671,8 @@
438 671 $enabled = (bool) $this->get_setting( 'enabled', false );
439 672 \WP_CLI::log( 'Turbo Render: ' . ( $enabled ? 'enabled' : 'disabled' ) );
440 673 \WP_CLI::log( 'Immediate sections: first ' . (int) $this->get_setting( 'skip_first', self::DEFAULT_SKIP_FIRST ) . ' sections' );
441 674 \WP_CLI::log( 'Section classes: ' . implode( ', ', $this->section_classes() ) );
675 + $excluded = $this->excluded_classes();
676 + \WP_CLI::log( 'Excluded classes: ' . ( empty( $excluded ) ? '(none)' : implode( ', ', $excluded ) ) );
442 677 }
443 678 }