← All changes
|
includes/modules/TurboRender/TurboRenderModule.php
+150
-3
1.4.0
→
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 |
| @@ -99,8 +105,11 @@ | ||
| 99 | 105 | |
| 100 | 106 | /** Fallback: a child holding this share of its siblings' combined bytes is a wrapper to unwrap, not a section. */ |
| 101 | 107 | private const FALLBACK_DOMINANT = 0.6; |
| 102 | 108 | |
| 109 | + /** Our own output-buffer nesting level, when we had to open one. */ | |
| 110 | + private ?int $buffer_level = null; | |
| 111 | + | |
| 103 | 112 | public function ui_metadata(): array { |
| 104 | 113 | return array( |
| 105 | 114 | 'label' => __( 'Turbo Render', 'xspeed' ), |
| 106 | 115 | 'icon' => 'Layers', |
| @@ -122,9 +131,9 @@ | ||
| 122 | 131 | 'default' => self::DEFAULT_SKIP_FIRST, |
| 123 | 132 | 'min' => 1, |
| 124 | 133 | 'max' => 10, |
| 125 | 134 | 'label' => __( 'Sections to render immediately', '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' ), | |
| 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' ), | |
| 127 | 136 | 'dependsOn' => array( 'field' => 'enabled' ), |
| 128 | 137 | ), |
| 129 | 138 | 'section_classes' => array( |
| 130 | 139 | 'type' => 'list', |
| @@ -164,11 +173,89 @@ | ||
| 164 | 173 | // <head>, this pass rewrites body sections — ordering only matters in |
| 165 | 174 | // that our injected <style> must survive, and later passes never |
| 166 | 175 | // strip inline styles. |
| 167 | 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 ); | |
| 168 | 184 | } |
| 169 | 185 | |
| 170 | 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 | + /** | |
| 171 | 258 | * Stamp below-fold top-level sections and inject the one style block. |
| 172 | 259 | * |
| 173 | 260 | * @param mixed $html Final page buffer. |
| 174 | 261 | * @return mixed |
| @@ -179,8 +266,13 @@ | ||
| 179 | 266 | } |
| 180 | 267 | if ( false === stripos( $html, '</head>' ) ) { |
| 181 | 268 | return $html; // Not a full document (fragment, feed, JSON). |
| 182 | 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 | + } | |
| 183 | 275 | if ( function_exists( 'is_user_logged_in' ) && is_user_logged_in() ) { |
| 184 | 276 | return $html; |
| 185 | 277 | } |
| 186 | 278 | // A measurement fetch (`?xspeed_css=off` — the render a CSS |
| @@ -227,8 +319,15 @@ | ||
| 227 | 319 | } |
| 228 | 320 | |
| 229 | 321 | $excluded = $this->excluded_pattern(); |
| 230 | 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 | + | |
| 231 | 330 | $seen = 0; |
| 232 | 331 | $edits = array(); |
| 233 | 332 | foreach ( $offsets as $offset ) { |
| 234 | 333 | ++$seen; |
| @@ -254,8 +353,11 @@ | ||
| 254 | 353 | $span_end = self::element_end( $masked, $offset ); |
| 255 | 354 | if ( null !== $span_end && false !== stripos( substr( $masked, $offset, $span_end - $offset ), '<iframe' ) ) { |
| 256 | 355 | continue; |
| 257 | 356 | } |
| 357 | + if ( self::breaks_under_containment( $masked, $offset, $span_end, $sorted, $doc_end ) ) { | |
| 358 | + continue; | |
| 359 | + } | |
| 258 | 360 | $edits[] = $offset; |
| 259 | 361 | } |
| 260 | 362 | |
| 261 | 363 | if ( empty( $edits ) ) { |
| @@ -484,8 +586,53 @@ | ||
| 484 | 586 | $pending = null; |
| 485 | 587 | } |
| 486 | 588 | } |
| 487 | 589 | return $children; |
| 590 | + } | |
| 591 | + | |
| 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 ); | |
| 488 | 635 | } |
| 489 | 636 | |
| 490 | 637 | /** |
| 491 | 638 | * The offset just past an element's close tag — the same tag walk as |