← 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( |