PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / trunk
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder vtrunk
2.12.0 2.11.1 2.11.0 2.10.0 2.9.0 2.7.4 2.7.5 2.7.6 2.7.7 2.8.0 2.8.1 2.9.1 trunk 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.2.0 1.2.1 All 78 releases
ablocks / includes / performance / fragment-cache.php

fragment-cache.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder trunk, at includes/performance/fragment-cache.php

400 lines 12.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace ABlocks\Performance;
3
4 if ( ! defined( 'ABSPATH' ) ) {
5 exit;
6 }
7
8 use ABlocks\Helper;
9 use ABlocks\Classes\CacheBackend;
10
11 /**
12 * Performance Suite — fragment cache for FSE template parts.
13 *
14 * A block theme re-renders the header and footer through the full block
15 * pipeline on every request, including on requests the page cache cannot help:
16 * cache misses, the first visitor after a purge, and logged-in traffic. Caching
17 * the rendered HTML of `core/template-part` covers exactly that gap.
18 *
19 * ## The hard part is side effects, not the HTML
20 *
21 * Rendering a block does more than return markup. It enqueues stylesheets and
22 * scripts, it enqueues *script modules* through a registry entirely separate
23 * from wp_scripts(), and it pushes block-support rules into the style engine
24 * store that core later prints as `core-block-supports-inline-css`.
25 * Short-circuiting the render skips all of that, so a naive fragment cache
26 * serves correct-looking HTML with missing CSS and dead JavaScript — on the
27 * second request only, which makes it very hard to attribute.
28 *
29 * Measured here, a first attempt that captured only style and script handles
30 * produced a warm page missing the Interactivity API import map, both module
31 * preloads and all three script-module tags: a navigation block whose mobile
32 * menu rendered perfectly and no longer opened.
33 *
34 * So a stored fragment records four things — style handles, script handles,
35 * script-module ids, and the block-support rules its render contributed — and
36 * serving a fragment replays all four.
37 *
38 * This is why the feature ships default-off and why its correctness is asserted
39 * by comparing whole rendered pages with the cache cold and warm, rather than
40 * by reasoning about which side effects exist. See docs/PAGE-CACHE-PLAN.md.
41 *
42 * ## Personalisation
43 *
44 * Only logged-out renders are cached by default. A header can legitimately
45 * contain a user's name, avatar or cart count, and there is no general way to
46 * detect that from the outside, so sharing one fragment across logged-in users
47 * would leak. Sites that know their header is user-invariant can opt in via
48 * `ablocks/perf/fragment_cache/should_cache`.
49 */
50 class FragmentCache {
51
52 const VERSION_OPTION = 'ablocks_fragment_version';
53 const TRANSIENT_PREFIX = 'ablocks_frag_';
54 const DEFAULT_TTL = 12 * HOUR_IN_SECONDS;
55
56 /**
57 * Maximum fragment size worth storing, in bytes.
58 */
59 const MAX_BYTES = 512000;
60
61 /**
62 * Snapshots taken at pre-render, keyed by block signature, awaiting store.
63 *
64 * @var array<string, array>
65 */
66 private $pending = [];
67
68 public static function init() {
69 if ( is_admin() ) {
70 return;
71 }
72
73 $self = new self();
74
75 // Invalidation is registered unconditionally: the version must keep
76 // advancing even while the feature is off, or switching it back on could
77 // serve fragments built before an edit.
78 foreach ( [ 'save_post', 'deleted_post', 'switch_theme', 'wp_update_nav_menu', 'customize_save_after', 'edited_term' ] as $hook ) {
79 add_action( $hook, [ __CLASS__, 'bump_version' ] );
80 }
81
82 $enabled = (bool) apply_filters(
83 'ablocks/perf/perf_fragment_cache',
84 (bool) Helper::get_settings( 'perf_fragment_cache', false )
85 );
86 if ( ! $enabled ) {
87 return;
88 }
89
90 // Fail closed. Interactive core blocks (Navigation above all) load their
91 // behaviour through the script-module registry, which is separate from
92 // wp_scripts(). Without a way to observe that queue we cannot replay it,
93 // and a fragment served without it renders correct markup with no
94 // JavaScript — a mobile menu that silently stops opening. A slower site
95 // is strictly better than a broken one.
96 if ( ! self::can_track_script_modules() ) {
97 return;
98 }
99
100 add_filter( 'pre_render_block', [ $self, 'maybe_serve' ], 10, 2 );
101 add_filter( 'render_block', [ $self, 'maybe_store' ], PHP_INT_MAX, 2 );
102 }
103
104 /**
105 * Advance the fragment generation, invalidating everything at once.
106 *
107 * A monotonic counter in the key beats deleting transients: it is one option
108 * write regardless of how many fragments exist, and it cannot half-complete.
109 *
110 * Deliberately coarse. `save_post` bumps too, because a template part may
111 * contain a query loop whose output depends on published content, and
112 * silently serving a stale one is worse than a lower hit rate.
113 */
114 public static function bump_version() {
115 CacheBackend::bump_generation( self::VERSION_OPTION );
116 }
117
118 /**
119 * Serve a cached fragment, replaying the side effects its render had.
120 *
121 * @param string|null $pre_render Short-circuit value.
122 * @param array $parsed_block Parsed block.
123 * @return string|null
124 */
125 public function maybe_serve( $pre_render, $parsed_block ) {
126 if ( null !== $pre_render || ! $this->is_cacheable_block( $parsed_block ) ) {
127 return $pre_render;
128 }
129
130 $key = $this->cache_key( $parsed_block );
131 $cached = CacheBackend::get( $key );
132
133 if ( is_array( $cached ) && isset( $cached['html'] ) ) {
134 $this->replay( $cached );
135 return $cached['html'];
136 }
137
138 // Miss: record the current asset state so maybe_store() can work out what
139 // rendering this block adds.
140 $this->pending[ $this->signature( $parsed_block ) ] = $this->snapshot();
141
142 return $pre_render;
143 }
144
145 /**
146 * Store a freshly rendered fragment together with its side effects.
147 *
148 * @param string $content Rendered block HTML.
149 * @param array $parsed_block Parsed block.
150 * @return string
151 */
152 public function maybe_store( $content, $parsed_block ) {
153 $signature = $this->signature( $parsed_block );
154 if ( ! isset( $this->pending[ $signature ] ) ) {
155 return $content;
156 }
157
158 $before = $this->pending[ $signature ];
159 unset( $this->pending[ $signature ] );
160
161 if ( ! $this->should_store( $content ) ) {
162 return $content;
163 }
164
165 $after = $this->snapshot();
166
167 $payload = [
168 'html' => $content,
169 'styles' => array_values( array_diff( $after['styles'], $before['styles'] ) ),
170 'scripts' => array_values( array_diff( $after['scripts'], $before['scripts'] ) ),
171 'modules' => array_values( array_diff( $after['modules'], $before['modules'] ) ),
172 'support_rules' => $this->rules_delta( $before['rules'], $after['rules'] ),
173 ];
174
175 $ttl = (int) apply_filters(
176 'ablocks/perf/fragment_cache/ttl',
177 (int) Helper::get_settings( 'perf_fragment_cache_ttl', self::DEFAULT_TTL )
178 );
179
180 CacheBackend::set( $this->cache_key( $parsed_block ), $payload, $ttl );
181
182 return $content;
183 }
184
185 /**
186 * Re-apply the asset side effects recorded with a fragment.
187 *
188 * @param array $cached Stored payload.
189 */
190 private function replay( $cached ) {
191 foreach ( (array) ( isset( $cached['styles'] ) ? $cached['styles'] : [] ) as $handle ) {
192 wp_enqueue_style( $handle );
193 }
194 foreach ( (array) ( isset( $cached['scripts'] ) ? $cached['scripts'] : [] ) as $handle ) {
195 wp_enqueue_script( $handle );
196 }
197 foreach ( (array) ( isset( $cached['modules'] ) ? $cached['modules'] : [] ) as $module_id ) {
198 // Modules are registered at block-registration time, not render time,
199 // so enqueueing by id after skipping the render still resolves.
200 wp_enqueue_script_module( $module_id );
201 }
202
203 // Block-support rules are pushed back into the style engine's own store
204 // rather than added as inline CSS on a handle of our own.
205 //
206 // The difference is not cosmetic. Enqueueing a handle at replay time
207 // prints those rules earlier in <head> than core would have, and layout
208 // rules like `.wp-container-core-group-is-layout-<hash>` carry the same
209 // specificity (0,1,0) as the generic block styles they are meant to
210 // override. Moving them earlier silently hands ties to the generic rule,
211 // so a cached header could lay out differently from an uncached one.
212 // Returning them to the store lets core emit them in its usual place and
213 // order, which is the only way the cascade is guaranteed to match.
214 $rules = isset( $cached['support_rules'] ) ? (array) $cached['support_rules'] : [];
215 if ( empty( $rules ) || ! class_exists( 'WP_Style_Engine' ) ) {
216 return;
217 }
218 foreach ( $rules as $selector => $declarations ) {
219 if ( ! is_array( $declarations ) || empty( $declarations ) ) {
220 continue;
221 }
222 \WP_Style_Engine::store_css_rule( 'block-supports', (string) $selector, $declarations );
223 }
224 }
225
226 /**
227 * Capture the asset state that rendering can add to.
228 *
229 * @return array{styles:array, scripts:array, modules:array, rules:array}
230 */
231 private function snapshot() {
232 $styles = wp_styles();
233 $scripts = wp_scripts();
234
235 return [
236 'styles' => $styles ? (array) $styles->queue : [],
237 'scripts' => $scripts ? (array) $scripts->queue : [],
238 'modules' => self::script_module_queue(),
239 'rules' => $this->support_rules(),
240 ];
241 }
242
243 /**
244 * Snapshot the style engine's block-support rules as plain arrays.
245 *
246 * Returned as selector => declarations so two snapshots can be compared and
247 * the difference replayed through the public store API.
248 *
249 * @return array<string, array>
250 */
251 private function support_rules() {
252 if ( ! class_exists( 'WP_Style_Engine' ) || ! method_exists( 'WP_Style_Engine', 'get_store' ) ) {
253 return [];
254 }
255
256 $store = \WP_Style_Engine::get_store( 'block-supports' );
257 if ( ! is_object( $store ) || ! method_exists( $store, 'get_all_rules' ) ) {
258 return [];
259 }
260
261 $out = [];
262 foreach ( (array) $store->get_all_rules() as $selector => $rule ) {
263 if ( ! is_object( $rule ) || ! method_exists( $rule, 'get_declarations' ) ) {
264 continue;
265 }
266 $declarations = $rule->get_declarations();
267 if ( is_object( $declarations ) && method_exists( $declarations, 'get_declarations' ) ) {
268 $declarations = $declarations->get_declarations();
269 }
270 $out[ (string) $selector ] = (array) $declarations;
271 }
272
273 return $out;
274 }
275
276 /**
277 * Block-support rules a render added or changed.
278 *
279 * @param array $before Rules before the render.
280 * @param array $after Rules after the render.
281 * @return array<string, array>
282 */
283 private function rules_delta( $before, $after ) {
284 $delta = [];
285 foreach ( $after as $selector => $declarations ) {
286 if ( ! isset( $before[ $selector ] ) || $before[ $selector ] !== $declarations ) {
287 $delta[ $selector ] = $declarations;
288 }
289 }
290 return $delta;
291 }
292
293 /**
294 * Ids of the currently enqueued script modules.
295 *
296 * @return string[]
297 */
298 private static function script_module_queue() {
299 if ( ! self::can_track_script_modules() ) {
300 return [];
301 }
302 $queue = wp_script_modules()->get_queue();
303 return is_array( $queue ) ? array_values( array_map( 'strval', $queue ) ) : [];
304 }
305
306 /**
307 * Can the script-module queue be observed on this WordPress version?
308 *
309 * @return bool
310 */
311 private static function can_track_script_modules() {
312 static $can = null;
313 if ( null !== $can ) {
314 return $can;
315 }
316 $can = function_exists( 'wp_script_modules' )
317 && function_exists( 'wp_enqueue_script_module' )
318 && method_exists( wp_script_modules(), 'get_queue' );
319 return $can;
320 }
321
322 /**
323 * Is this a block worth caching?
324 *
325 * @param array $parsed_block Parsed block.
326 * @return bool
327 */
328 private function is_cacheable_block( $parsed_block ) {
329 if ( empty( $parsed_block['blockName'] ) ) {
330 return false;
331 }
332
333 $blocks = (array) apply_filters( 'ablocks/perf/fragment_cache/blocks', [ 'core/template-part' ] );
334 if ( ! in_array( $parsed_block['blockName'], $blocks, true ) ) {
335 return false;
336 }
337
338 // Contexts where the output is intentionally not the canonical one.
339 if ( is_preview() || is_customize_preview() || is_admin() ) {
340 return false;
341 }
342
343 $should = ! is_user_logged_in();
344
345 return (bool) apply_filters( 'ablocks/perf/fragment_cache/should_cache', $should, $parsed_block );
346 }
347
348 /**
349 * Is this rendered output safe to store?
350 *
351 * @param string $content Rendered HTML.
352 * @return bool
353 */
354 private function should_store( $content ) {
355 if ( ! is_string( $content ) || '' === trim( $content ) ) {
356 return false;
357 }
358 if ( strlen( $content ) > self::MAX_BYTES ) {
359 return false;
360 }
361 // A fragment carrying a nonce would freeze it for the whole TTL. Cheap to
362 // detect, and far better to skip the fragment than to serve a dead token.
363 if ( false !== stripos( $content, '_wpnonce' ) || false !== stripos( $content, 'wp_rest' ) ) {
364 return false;
365 }
366 return true;
367 }
368
369 /**
370 * Stable identity for a parsed block within one request.
371 *
372 * @param array $parsed_block Parsed block.
373 * @return string
374 */
375 private function signature( $parsed_block ) {
376 $attrs = isset( $parsed_block['attrs'] ) ? $parsed_block['attrs'] : [];
377 return md5( $parsed_block['blockName'] . '|' . wp_json_encode( $attrs ) );
378 }
379
380 /**
381 * Transient key for a fragment.
382 *
383 * @param array $parsed_block Parsed block.
384 * @return string
385 */
386 private function cache_key( $parsed_block ) {
387 $parts = [
388 $this->signature( $parsed_block ),
389 get_stylesheet(),
390 CacheBackend::generation( self::VERSION_OPTION ),
391 determine_locale(),
392 is_user_logged_in() ? 'u' . get_current_user_id() : 'anon',
393 ];
394
395 // Transient keys are capped at 172 characters; a hash keeps this well
396 // inside that regardless of theme or locale name length.
397 return self::TRANSIENT_PREFIX . md5( implode( '|', $parts ) );
398 }
399 }
400