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 +150 -3 1.4.1 → 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