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
xspeed / includes / modules / Lazy / LazyModule.php

LazyModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.1, at includes/modules/Lazy/LazyModule.php

379 lines 16.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Lazy module — defers img / iframe / video loading via native browser
4 * lazy-load attributes. Also auto-fills missing image dimensions to
5 * prevent CLS.
6 *
7 * What WordPress core does already (since 5.5):
8 * - Adds loading="lazy" to the_content images.
9 *
10 * What this module adds:
11 * - First N images get loading="eager" so the LCP isn't deferred.
12 * - Adds decoding="async" (core doesn't).
13 * - Lazy-loads iframes (core's iframe lazy was reverted).
14 * - preload="none" on <video> (closest thing to native video lazy).
15 * - Auto-fills missing width / height attributes (best CLS win).
16 * - Excludes by substring patterns (src or class match) — useful for
17 * hero banner classes, logo files, etc.
18 *
19 * Tier: Free per FEATURES.md "Images" §1-6 (LiteSpeed parity — all
20 * Free in LS Cache).
21 *
22 * @package XSpeed
23 */
24
25 declare(strict_types=1);
26
27 namespace XSpeed\Modules\Lazy;
28
29 defined( 'ABSPATH' ) || exit;
30
31 use XSpeed\Lazy_Loader;
32 use XSpeed\Module;
33
34 final class LazyModule extends Module {
35
36 public const SLUG = 'lazy';
37 public const TIER = self::TIER_FREE;
38 public const VERSION = '1.0.0';
39
40 public function ui_metadata(): array {
41 return array(
42 'label' => __( 'Media Optimization', 'xspeed' ),
43 'tab_label' => __( 'Lazy Loading', 'xspeed' ), // its own tab on the Media Optimization page
44 'icon' => 'Image',
45 'description' => __( 'Loads images, videos and embeds only when visitors scroll to them.', 'xspeed' ),
46 'group' => 'performance',
47 // Host page: Lazy Loading (this module) + Image Optimization (Pro)
48 // + AI Suggestions (Pro) as tabs — everything a page loads on one
49 // page instead of separate rows (FBS-83633). Fonts is NOT here: it
50 // has its own Optimization card (#86).
51 'custom_panel' => 'MediaPanel',
52 );
53 }
54
55 public function settings_schema(): array {
56 return array(
57 'lazy_images' => array(
58 'type' => 'bool',
59 'default' => true,
60 'label' => __( 'Lazy-load images', 'xspeed' ),
61 'description' => __( 'Images load when the visitor scrolls near them. The first image on the page still loads right away.', 'xspeed' ),
62 ),
63 'lazy_iframes' => array(
64 'type' => 'bool',
65 'default' => true,
66 'label' => __( 'Lazy-load embeds', 'xspeed' ),
67 'description' => __( 'Embedded content such as YouTube videos and maps loads when the visitor scrolls near it.', 'xspeed' ),
68 ),
69 'video_facade' => array(
70 'type' => 'bool',
71 'default' => false,
72 'label' => __( 'Click-to-play videos', 'xspeed' ),
73 'description' => __( 'Shows a preview image with a play button in place of YouTube, Vimeo and your own videos that have a poster image. The video loads only when clicked. Autoplaying videos are left alone.', 'xspeed' ),
74 ),
75 'lazy_videos' => array(
76 'type' => 'bool',
77 'default' => true,
78 'label' => __( 'Lazy-load your own videos', 'xspeed' ),
79 'description' => __( 'Videos uploaded to your site download only when played. An autoplaying video still plays by itself, but its file waits until the video is near the screen. Add data-skip-lazy to a video to leave it alone.', 'xspeed' ),
80 ),
81 'video_after_load' => array(
82 'type' => 'bool',
83 'default' => false,
84 'label' => __( 'Start autoplay videos after page load', 'xspeed' ),
85 'description' => __( 'An autoplaying video, such as a hero background, waits until the page has finished loading, even when it is on screen from the start. Visitors see the poster first, and the video no longer competes with the page\'s images, styles and scripts. Needs "Lazy-load your own videos".', 'xspeed' ),
86 'dependsOn' => array( 'field' => 'lazy_videos' ),
87 ),
88 'lazy_background_images' => array(
89 'type' => 'bool',
90 'default' => false,
91 'label' => __( 'Lazy-load background images', 'xspeed' ),
92 'description' => __( 'Hold back background images set in an element\'s inline style (a Cover or Group block, a page-builder section) until the element is near the screen. Uses a small script; visitors without JavaScript get every background as usual. The first N backgrounds on the page load straight away, like images. Backgrounds set in a stylesheet are not affected.', 'xspeed' ),
93 ),
94 'eager_first_n' => array(
95 'type' => 'int',
96 'default' => 1,
97 'min' => 0,
98 'max' => 10,
99 'label' => __( 'Images to load right away', 'xspeed' ),
100 'description' => __( 'How many images at the top of the page skip lazy loading. Only the first of them also loads at high priority, and images in hidden or closed sections are not counted. Lazy-loaded background images use the same number. 1 suits most sites; 0 lazy-loads every image.', 'xspeed' ),
101 'advanced' => true,
102 // Both counters read this number (class-lazy-loader.php), so it
103 // shows while either kind of lazy loading is on.
104 'dependsOn' => array(
105 'any' => array(
106 array( 'field' => 'lazy_images' ),
107 array( 'field' => 'lazy_background_images' ),
108 ),
109 ),
110 ),
111 'add_missing_dimensions' => array(
112 'type' => 'bool',
113 'default' => true,
114 'label' => __( 'Add missing image sizes', 'xspeed' ),
115 'description' => __( 'Adds width and height to images that lack them, using the media library. This stops the page from jumping as images load.', 'xspeed' ),
116 ),
117 'excluded_images' => array(
118 'type' => 'list',
119 'default' => array(),
120 'item_type' => 'string',
121 'label' => __( 'Excluded images', 'xspeed' ),
122 'description' => __( 'Images, videos and embeds whose tag contains a line here, such as a class or file name, are never lazy-loaded. Useful for logos and hero images.', 'xspeed' ),
123 ),
124 );
125 }
126
127 public function conflicts(): array {
128 return array(
129 array(
130 'plugin' => 'wp-smushit/wp-smush.php',
131 'feature' => 'images.lazyload',
132 'strategy' => \XSpeed\Conflict_Registry::STRATEGY_WARN,
133 'reason' => 'Smush also offers lazy-loading; running both can cause double-rewriting.',
134 ),
135 array(
136 'plugin' => 'a3-lazy-load/a3-lazy-load.php',
137 'feature' => 'images.lazyload',
138 'strategy' => \XSpeed\Conflict_Registry::STRATEGY_REFUSE,
139 'reason' => 'a3 Lazy Load is a dedicated lazy-load plugin; disable it before enabling xSpeed lazy-load.',
140 ),
141 );
142 }
143
144 public function boot(): void {
145 /*
146 * Deferred to `init`. This module reads its own settings to decide
147 * what to hook, and reading settings builds settings_schema(), whose
148 * labels are declared through __(). boot() runs on `plugins_loaded`,
149 * before `after_setup_theme` — the point WordPress 6.7+ treats as the
150 * earliest safe moment to translate — so doing that here fires
151 * _load_textdomain_just_in_time on every request AND resolves the
152 * labels against a domain that is not loaded yet.
153 *
154 * Everything below hooks actions that fire after `init`, so running
155 * one hook later is equivalent.
156 */
157 add_action( 'init', array( $this, 'boot_on_init' ) );
158 }
159
160 /**
161 * The real boot body — see boot() for why it runs on `init`.
162 */
163 public function boot_on_init(): void {
164 // Bail entirely on admin / feed / cron / REST — same scope as
165 // Minifier. Lazy-loading rendered HTML only matters on real
166 // frontend page renders.
167 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
168 return;
169 }
170
171 // Never lazy-load inside a builder editor: the builder measures and
172 // positions elements it expects to be loaded. (#281)
173 if ( \XSpeed\Builder_Editor::is_active() ) {
174 return;
175 }
176
177 $opts = $this->get_settings();
178 $any_enabled = ! empty( $opts['lazy_images'] )
179 || ! empty( $opts['lazy_iframes'] )
180 || ! empty( $opts['lazy_videos'] )
181 || ! empty( $opts['video_facade'] )
182 || ! empty( $opts['lazy_background_images'] )
183 || ! empty( $opts['add_missing_dimensions'] );
184 if ( ! $any_enabled ) {
185 return;
186 }
187
188 // Reset the eager-load budget once per page render, before any
189 // content filter runs, so the "first N images eager" budget is
190 // shared across the featured image + content + avatars rather than
191 // restarting on every filter pass. (FBS-82172 Bug 1)
192 add_action( 'template_redirect', array( Lazy_Loader::class, 'reset_state' ) );
193
194 // Late priority so the_content runs after every other filter
195 // (shortcodes, do_blocks, embeds). Avoids rewriting tags that
196 // haven't been generated yet.
197 add_filter( 'the_content', array( Lazy_Loader::class, 'process_html' ), 999 );
198 add_filter( 'post_thumbnail_html', array( Lazy_Loader::class, 'process_html' ), 999 );
199 add_filter( 'get_avatar', array( Lazy_Loader::class, 'process_html' ), 999 );
200 add_filter( 'widget_text_content', array( Lazy_Loader::class, 'process_html' ), 999 );
201
202 /**
203 * Whether media-library images printed in the footer get srcset
204 * with sizes="auto" and loading="lazy". Core never filters that
205 * markup, so a footer popup ships its images at full size.
206 *
207 * @param bool $enabled Default true while lazy-loading images is on.
208 */
209 if ( ! empty( $opts['lazy_images'] ) && apply_filters( 'xspeed_lazy_footer_images', true ) ) {
210 add_action( 'template_redirect', array( \XSpeed\Footer_Images::class, 'reset_state' ) );
211 add_action( 'get_footer', array( \XSpeed\Footer_Images::class, 'start' ), PHP_INT_MAX, 0 );
212 add_action( 'wp_footer', array( \XSpeed\Footer_Images::class, 'start' ), PHP_INT_MIN, 0 );
213 add_action( 'wp_footer', array( \XSpeed\Footer_Images::class, 'finish' ), PHP_INT_MAX, 0 );
214 }
215
216 // The facade's click handler is printed only on pages that actually
217 // rendered a facade — a page with no embeds should not carry the
218 // script that reveals them.
219 if ( ! empty( $opts['video_facade'] ) ) {
220 add_action( 'wp_footer', array( $this, 'print_facade_script' ), 99 );
221 // The layout rule goes in the HEAD, unconditionally, while the
222 // handler stays conditional in the footer. Whether a facade
223 // renders isn't known until the content filter has run — long
224 // after wp_head — and a layout rule that arrives in the footer
225 // fixes the gap only after the visitor has already seen it.
226 add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_facade_style' ) );
227 // Embeds that never touch the HTML: a builder widget builds its
228 // YouTube iframe from script, so the buffer pass has nothing to
229 // rewrite. Head, priority 1, for the same reason as the autoplay
230 // restorer below — it must be listening before the widget's
231 // script sets a src and commits the fetch.
232 add_action( 'wp_head', array( $this, 'print_observer_script' ), 1 );
233 }
234
235 // Head, because the rule that holds a background back has to apply
236 // before first paint, or the browser has already requested it.
237 if ( ! empty( $opts['lazy_background_images'] ) ) {
238 add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_background_style' ) );
239 add_action( 'wp_head', array( $this, 'print_background_script' ), 1 );
240 }
241
242 // Same conditional-footer treatment for the autoplay restorer: it is
243 // only printed on a response that actually deferred one.
244 if ( ! empty( $opts['lazy_videos'] ) ) {
245 /*
246 * HEAD, not footer — and as early as anything can run.
247 *
248 * A page-builder video block creates its <video> from its own
249 * script. Ours has to be listening BEFORE that happens: printed
250 * in the footer it loaded after the block's script had already
251 * built the player and started the fetch, so the bytes were
252 * committed before we could defer them. Measured on a live page,
253 * our restorer sat ~17KB after the first block script in the
254 * document, and the videos still downloaded on load.
255 *
256 * It costs ~1KB inline and installs only observers, so running it
257 * early is cheap; a MutationObserver on documentElement catches
258 * every <video> the moment it is inserted, whichever script did
259 * the inserting.
260 */
261 add_action( 'wp_head', array( $this, 'print_autoplay_script' ), 1 );
262 }
263 }
264
265 /**
266 * Register the facade's layout rule as an inline style on a
267 * dependency-free handle — ~150 bytes, so a separate file would cost
268 * more than the CSS.
269 */
270 public function enqueue_facade_style(): void {
271 wp_register_style( 'xspeed-video-facade', false, array(), XSPEED_VERSION );
272 wp_enqueue_style( 'xspeed-video-facade' );
273 wp_add_inline_style( 'xspeed-video-facade', \XSpeed\Video_Facade::facade_style() );
274 }
275
276 public function enqueue_background_style(): void {
277 wp_register_style( 'xspeed-lazy-bg', false, array(), XSPEED_VERSION );
278 wp_enqueue_style( 'xspeed-lazy-bg' );
279 wp_add_inline_style( 'xspeed-lazy-bg', Lazy_Loader::background_style() );
280 }
281
282 /**
283 * data-xs-nodelay: Delay JS would otherwise hold this back until the
284 * first interaction, and every held background would stay blank.
285 */
286 public function print_background_script(): void {
287 wp_print_inline_script_tag(
288 Lazy_Loader::background_script(),
289 array(
290 'id' => 'xspeed-lazy-bg',
291 'data-xs-nodelay' => true,
292 )
293 );
294 }
295
296 /**
297 * Emit the click-to-play handler inline. Inline (not enqueued) because
298 * it is ~400 bytes — a separate request would cost more than the code.
299 */
300 public function print_facade_script(): void {
301 // No longer gated on facade_used(): the observer script can build a
302 // facade for a JS-injected embed on a page where the server pass
303 // rendered none, and a facade without its click handler is a play
304 // button that plays nothing. ~400 bytes on facade-less pages is the
305 // cost of never shipping that.
306 wp_print_inline_script_tag( \XSpeed\Video_Facade::facade_script(), array( 'id' => 'xspeed-video-facade' ) );
307 }
308
309 /**
310 * Emit the interceptor for JS-injected embeds (see
311 * Video_Facade::observer_script() for the mechanism and why the
312 * footer is too late).
313 */
314 public function print_observer_script(): void {
315 wp_print_inline_script_tag( \XSpeed\Video_Facade::observer_script(), array( 'id' => 'xspeed-video-facade-observer' ) );
316 }
317
318 /**
319 * Emit the viewport restorer for deferred AUTOPLAY videos.
320 *
321 * Separate from the facade script because the two are independent: a
322 * page can defer an autoplay hero without any click-to-play facade on
323 * it, and vice versa. Both are gated on having actually rewritten
324 * something, so a page with no video ships neither.
325 */
326 public function print_autoplay_script(): void {
327 /*
328 * Deliberately NOT gated on needs_autoplay_script().
329 *
330 * That flag is only meaningful after the content filter has run, and
331 * this prints in wp_head — long before. The facade script above can
332 * afford to be conditional because it only has to be present by the
333 * time a human clicks; this one has to be listening before another
334 * plugin's script builds a <video> and starts fetching it, which
335 * happens well before wp_footer.
336 *
337 * The cost of being unconditional is ~1KB inline on pages with no
338 * video, and the script installs observers only — it does no work
339 * and touches nothing when it finds no autoplay video. That is a
340 * better trade than missing the one case the feature exists for.
341 */
342 $settings = $this->get_settings();
343 $after_load = ! empty( $settings['video_after_load'] );
344 $excluded = is_array( $settings['excluded_images'] ?? null ) ? $settings['excluded_images'] : array();
345 wp_print_inline_script_tag( Lazy_Loader::autoplay_script( $after_load, $excluded ), array( 'id' => 'xspeed-lazy-autoplay' ) );
346 }
347
348 public function cli_commands(): array {
349 return array(
350 array(
351 'name' => 'xspeed lazy',
352 'callback' => array( $this, 'cli_handler' ),
353 'shortdesc' => 'Show which lazy-load toggles are active.',
354 'ai_hint' => 'Which lazy-loading and image optimizations are on (images, iframes, missing width/height)? Use for questions about images loading too early, layout shift (CLS), or offscreen images flagged by PageSpeed.',
355 'synopsis' => array(),
356 ),
357 );
358 }
359
360 public function cli_handler( array $args, array $assoc ): void {
361 $opts = $this->get_settings();
362 foreach ( $opts as $key => $value ) {
363 $display = is_array( $value ) ? implode( ',', $value ) : ( $value ? 'on' : ( is_numeric( $value ) ? (string) $value : 'off' ) );
364 if ( is_int( $value ) ) {
365 $display = (string) $value;
366 }
367 \WP_CLI::log( sprintf( '%-30s %s', $key, $display ) );
368 }
369 }
370
371 /**
372 * Lazy has no master switch -- it is on when any of lazy_images /
373 * lazy_iframes / video_facade / lazy_videos is set. (#363)
374 */
375 public function is_active(): ?bool {
376 return $this->any_bool_flag_on();
377 }
378 }
379