PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.4
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 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 All 30 releases
xspeed / includes / modules / RenderSkip / RenderSkipModule.php

RenderSkipModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.4, at includes/modules/RenderSkip/RenderSkipModule.php

271 lines 10.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Render Skip — stamp below-fold page sections with
4 * `content-visibility: auto` so the browser skips their style & layout
5 * work until scroll approaches.
6 *
7 * Why this exists: on large-DOM builder pages the dominant share of TBT
8 * is Style & Layout, not script execution — measured live on a
9 * 2,003-element Elementor homepage: 661ms Style & Layout, biggest long
10 * task 543ms attributed to the document itself. JS delay/defer cannot
11 * touch that cost; `content-visibility` is the only browser primitive
12 * that skips rendering work for off-screen subtrees. Measured effect on
13 * that page: TBT 635ms -> 22-63ms across five runs.
14 *
15 * How: a buffer pass over the final HTML finds top-level section
16 * containers by class, leaves the first N alone (the above-fold
17 * estimate), and stamps the rest with a `data-xspeed-cv` attribute. One
18 * inline <style> gives every stamped section
19 * `content-visibility: auto` + `contain-intrinsic-size: auto <est>` —
20 * the `auto` keyword remembers the real rendered size after first
21 * paint, so the estimate only matters before a section has ever been
22 * rendered, and a print stylesheet forces everything visible.
23 *
24 * Tier: Free (FEATURES.md Core Web Vitals #4 — the heuristic tier; the
25 * fold-beacon-measured variant is the Pro half of that row).
26 *
27 * @package XSpeed
28 */
29
30 declare(strict_types=1);
31
32 namespace XSpeed\Modules\RenderSkip;
33
34 defined( 'ABSPATH' ) || exit;
35
36 use XSpeed\Module;
37
38 final class RenderSkipModule extends Module {
39
40 public const SLUG = 'render-skip';
41 public const TIER = self::TIER_FREE;
42 public const VERSION = '1.0.0';
43
44 /**
45 * Top-level section containers, by class. Deliberately the TOP-LEVEL
46 * spellings only — `elementor-top-section` (legacy sections) and
47 * `e-parent` (flexbox containers) — never `elementor-section` /
48 * `e-con`, which also match nested wrappers and would stamp inside
49 * the sections we skip as above-fold.
50 */
51 private const DEFAULT_CLASSES = array( 'elementor-top-section', 'e-parent' );
52
53 /** Sections presumed above the fold and left untouched. */
54 private const DEFAULT_SKIP_FIRST = 2;
55
56 /**
57 * Pre-first-render size estimate (px) for contain-intrinsic-size.
58 * Only the scrollbar sees it, and only until a section has rendered
59 * once — `contain-intrinsic-size: auto` then remembers the real size.
60 */
61 private const DEFAULT_INTRINSIC_PX = 800;
62
63 /**
64 * Spans whose markup is text, not the page: a section tag inside a
65 * JS template string or a comment must not be stamped.
66 */
67 private const MASKED_SPANS = '<script\b[^>]*>.*?</script>|<textarea\b[^>]*>.*?</textarea>|<noscript\b[^>]*>.*?</noscript>|<!--.*?-->';
68
69 public function ui_metadata(): array {
70 return array(
71 'label' => __( 'Render Skip', 'xspeed' ),
72 'icon' => 'Layers',
73 'description' => __( 'Skip the browser\'s style & layout work for below-fold sections until the visitor scrolls near them — cuts main-thread blocking time on long builder pages without changing the content.', 'xspeed' ),
74 );
75 }
76
77 public function settings_schema(): array {
78 return array(
79 'enabled' => array(
80 'type' => 'bool',
81 'default' => false,
82 'label' => __( 'Skip below-fold rendering', 'xspeed' ),
83 'description' => __( 'Stamp below-fold sections with content-visibility: auto so the browser defers their style and layout work until scroll approaches. Above-fold sections are never touched.', 'xspeed' ),
84 ),
85 'skip_first' => array(
86 'type' => 'int',
87 'default' => self::DEFAULT_SKIP_FIRST,
88 'min' => 1,
89 'max' => 10,
90 'label' => __( 'Above-fold sections', 'xspeed' ),
91 'description' => __( 'How many top-level sections from the top of the page are treated as above the fold and left untouched. Raise this if a section near the top appears late.', 'xspeed' ),
92 ),
93 'section_classes' => array(
94 'type' => 'list',
95 'default' => self::DEFAULT_CLASSES,
96 'label' => __( 'Section classes', 'xspeed' ),
97 'description' => __( 'Class names that identify a top-level page section. Defaults cover Elementor sections and flexbox containers; add your theme\'s section class for non-Elementor pages.', 'xspeed' ),
98 ),
99 );
100 }
101
102 public function boot(): void {
103 if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) {
104 return;
105 }
106 // Deferred to `init`: reading the enabled flag builds
107 // settings_schema(), whose labels go through __(), and boot() runs
108 // on plugins_loaded — before WP 6.7 considers translation loading
109 // safe (same reasoning as BloatModule::boot()).
110 add_action( 'init', array( $this, 'boot_on_init' ) );
111 }
112
113 /** The real boot body — see boot() for why it runs on `init`. */
114 public function boot_on_init(): void {
115 if ( ! $this->get_setting( 'enabled', false ) ) {
116 return;
117 }
118 // After the CSS passes (combiner @5, a Pro CSS pass @6): they rewrite
119 // <head>, this pass rewrites body sections — ordering only matters in
120 // that our injected <style> must survive, and later passes never
121 // strip inline styles.
122 add_filter( 'xspeed_cache_final_html', array( $this, 'process' ), 8 );
123 }
124
125 /**
126 * Stamp below-fold top-level sections and inject the one style block.
127 *
128 * @param mixed $html Final page buffer.
129 * @return mixed
130 */
131 public function process( $html ) {
132 if ( ! is_string( $html ) || '' === $html ) {
133 return $html;
134 }
135 if ( false === stripos( $html, '</head>' ) ) {
136 return $html; // Not a full document (fragment, feed, JSON).
137 }
138 if ( function_exists( 'is_user_logged_in' ) && is_user_logged_in() ) {
139 return $html;
140 }
141 // A measurement fetch (`?xspeed_css=off` — the render a CSS
142 // generator reads) must see every section RENDERED. Shipped without
143 // this guard, a renderer measured a page whose below-fold sections
144 // the browser had skipped, judged their CSS unused, and pruned it —
145 // mobile CLS went 0.00 → 0.37 because the fold's own sizing rules
146 // were gone. The param is a shared contract (a Pro CSS module
147 // defines it), matched here by name because Free never references
148 // Pro code.
149 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only bypass detection; changes nothing.
150 if ( isset( $_GET['xspeed_css'] ) && 'off' === sanitize_text_field( wp_unslash( $_GET['xspeed_css'] ) ) ) {
151 return $html;
152 }
153
154 $classes = $this->section_classes();
155 if ( empty( $classes ) ) {
156 return $html;
157 }
158
159 /**
160 * Filter how many top-level sections stay untouched as above-fold.
161 *
162 * @param int $skip_first
163 */
164 $skip = max( 1, (int) apply_filters( 'xspeed_render_skip_after', (int) $this->get_setting( 'skip_first', self::DEFAULT_SKIP_FIRST ) ) );
165
166 $masked = self::mask( $html );
167 $pattern = '#<(?:section|div|footer|main|article)\b[^>]*\bclass\s*=\s*(["\'])[^"\']*(?:' . implode( '|', array_map( 'preg_quote', $classes ) ) . ')[^"\']*\1[^>]*>#i';
168 if ( ! preg_match_all( $pattern, $masked, $m, PREG_OFFSET_CAPTURE ) ) {
169 return $html;
170 }
171
172 $stamped = 0;
173 $seen = 0;
174 $edits = array();
175 foreach ( $m[0] as $hit ) {
176 ++$seen;
177 if ( $seen <= $skip ) {
178 continue;
179 }
180 $offset = (int) $hit[1];
181 $tag = substr( $html, $offset, strlen( (string) $hit[0] ) );
182 if ( false !== stripos( $tag, 'data-xspeed-cv' ) ) {
183 continue;
184 }
185 $edits[] = $offset;
186 ++$stamped;
187 }
188
189 if ( 0 === $stamped ) {
190 return $html;
191 }
192
193 // Highest offset first, so earlier offsets stay valid as we splice.
194 // The attribute goes right after the tag name — the one spot
195 // guaranteed not to sit inside another attribute's value.
196 rsort( $edits );
197 foreach ( $edits as $offset ) {
198 $gap = (int) strcspn( $html, " \t\r\n/>", $offset + 1 );
199 $html = substr_replace( $html, ' data-xspeed-cv=""', $offset + 1 + $gap, 0 );
200 }
201
202 /**
203 * Filter the pre-first-render intrinsic size estimate, in pixels.
204 *
205 * @param int $px
206 */
207 $px = max( 100, (int) apply_filters( 'xspeed_render_skip_intrinsic_px', self::DEFAULT_INTRINSIC_PX ) );
208 $style = '<style id="xspeed-cv">[data-xspeed-cv]{content-visibility:auto;contain-intrinsic-size:auto ' . $px . 'px}@media print{[data-xspeed-cv]{content-visibility:visible}}</style>';
209
210 $head_end = stripos( $html, '</head>' );
211 return substr_replace( $html, $style, (int) $head_end, 0 );
212 }
213
214 /** @return string[] */
215 private function section_classes(): array {
216 $classes = $this->get_setting( 'section_classes', self::DEFAULT_CLASSES );
217 $classes = is_array( $classes ) ? array_values( array_filter( array_map( 'strval', $classes ) ) ) : self::DEFAULT_CLASSES;
218
219 /**
220 * Filter the class names identifying a top-level page section.
221 *
222 * @param string[] $classes
223 */
224 $classes = (array) apply_filters( 'xspeed_render_skip_classes', $classes );
225
226 // Class names end up inside a regex alternation; anything that is
227 // not a plausible CSS class token is dropped rather than escaped
228 // into something surprising.
229 return array_values(
230 array_filter(
231 array_map( 'strval', $classes ),
232 static fn( string $c ): bool => (bool) preg_match( '/^[A-Za-z0-9_-]+$/', $c )
233 )
234 );
235 }
236
237 private static function mask( string $html ): string {
238 return (string) preg_replace_callback(
239 '#' . self::MASKED_SPANS . '#is',
240 static fn( array $m ): string => str_repeat( "\0", strlen( $m[0] ) ),
241 $html
242 );
243 }
244
245 public function cli_commands(): array {
246 return array(
247 array(
248 'name' => 'xspeed render-skip status',
249 'callback' => array( $this, 'cli_status' ),
250 'shortdesc' => 'Show below-fold render-skip status.',
251 'ai_hint' => 'Is content-visibility stamping of below-fold sections on, and with what above-fold allowance? Use when diagnosing TBT / main-thread style & layout cost on long builder pages.',
252 'synopsis' => array(),
253 ),
254 );
255 }
256
257 /**
258 * `wp xspeed render-skip status`.
259 *
260 * @param array $args Positional args (unused).
261 * @param array $assoc Associative args (unused).
262 */
263 public function cli_status( array $args, array $assoc ): void {
264 unset( $args, $assoc );
265 $enabled = (bool) $this->get_setting( 'enabled', false );
266 \WP_CLI::log( 'Render skip: ' . ( $enabled ? 'enabled' : 'disabled' ) );
267 \WP_CLI::log( 'Above-fold skip: first ' . (int) $this->get_setting( 'skip_first', self::DEFAULT_SKIP_FIRST ) . ' sections' );
268 \WP_CLI::log( 'Section classes: ' . implode( ', ', $this->section_classes() ) );
269 }
270 }
271