*/ private $pending = []; public static function init() { if ( is_admin() ) { return; } $self = new self(); // Invalidation is registered unconditionally: the version must keep // advancing even while the feature is off, or switching it back on could // serve fragments built before an edit. foreach ( [ 'save_post', 'deleted_post', 'switch_theme', 'wp_update_nav_menu', 'customize_save_after', 'edited_term' ] as $hook ) { add_action( $hook, [ __CLASS__, 'bump_version' ] ); } $enabled = (bool) apply_filters( 'ablocks/perf/perf_fragment_cache', (bool) Helper::get_settings( 'perf_fragment_cache', false ) ); if ( ! $enabled ) { return; } // Fail closed. Interactive core blocks (Navigation above all) load their // behaviour through the script-module registry, which is separate from // wp_scripts(). Without a way to observe that queue we cannot replay it, // and a fragment served without it renders correct markup with no // JavaScript — a mobile menu that silently stops opening. A slower site // is strictly better than a broken one. if ( ! self::can_track_script_modules() ) { return; } add_filter( 'pre_render_block', [ $self, 'maybe_serve' ], 10, 2 ); add_filter( 'render_block', [ $self, 'maybe_store' ], PHP_INT_MAX, 2 ); } /** * Advance the fragment generation, invalidating everything at once. * * A monotonic counter in the key beats deleting transients: it is one option * write regardless of how many fragments exist, and it cannot half-complete. * * Deliberately coarse. `save_post` bumps too, because a template part may * contain a query loop whose output depends on published content, and * silently serving a stale one is worse than a lower hit rate. */ public static function bump_version() { CacheBackend::bump_generation( self::VERSION_OPTION ); } /** * Serve a cached fragment, replaying the side effects its render had. * * @param string|null $pre_render Short-circuit value. * @param array $parsed_block Parsed block. * @return string|null */ public function maybe_serve( $pre_render, $parsed_block ) { if ( null !== $pre_render || ! $this->is_cacheable_block( $parsed_block ) ) { return $pre_render; } $key = $this->cache_key( $parsed_block ); $cached = CacheBackend::get( $key ); if ( is_array( $cached ) && isset( $cached['html'] ) ) { $this->replay( $cached ); return $cached['html']; } // Miss: record the current asset state so maybe_store() can work out what // rendering this block adds. $this->pending[ $this->signature( $parsed_block ) ] = $this->snapshot(); return $pre_render; } /** * Store a freshly rendered fragment together with its side effects. * * @param string $content Rendered block HTML. * @param array $parsed_block Parsed block. * @return string */ public function maybe_store( $content, $parsed_block ) { $signature = $this->signature( $parsed_block ); if ( ! isset( $this->pending[ $signature ] ) ) { return $content; } $before = $this->pending[ $signature ]; unset( $this->pending[ $signature ] ); if ( ! $this->should_store( $content ) ) { return $content; } $after = $this->snapshot(); $payload = [ 'html' => $content, 'styles' => array_values( array_diff( $after['styles'], $before['styles'] ) ), 'scripts' => array_values( array_diff( $after['scripts'], $before['scripts'] ) ), 'modules' => array_values( array_diff( $after['modules'], $before['modules'] ) ), 'support_rules' => $this->rules_delta( $before['rules'], $after['rules'] ), ]; $ttl = (int) apply_filters( 'ablocks/perf/fragment_cache/ttl', (int) Helper::get_settings( 'perf_fragment_cache_ttl', self::DEFAULT_TTL ) ); CacheBackend::set( $this->cache_key( $parsed_block ), $payload, $ttl ); return $content; } /** * Re-apply the asset side effects recorded with a fragment. * * @param array $cached Stored payload. */ private function replay( $cached ) { foreach ( (array) ( isset( $cached['styles'] ) ? $cached['styles'] : [] ) as $handle ) { wp_enqueue_style( $handle ); } foreach ( (array) ( isset( $cached['scripts'] ) ? $cached['scripts'] : [] ) as $handle ) { wp_enqueue_script( $handle ); } foreach ( (array) ( isset( $cached['modules'] ) ? $cached['modules'] : [] ) as $module_id ) { // Modules are registered at block-registration time, not render time, // so enqueueing by id after skipping the render still resolves. wp_enqueue_script_module( $module_id ); } // Block-support rules are pushed back into the style engine's own store // rather than added as inline CSS on a handle of our own. // // The difference is not cosmetic. Enqueueing a handle at replay time // prints those rules earlier in than core would have, and layout // rules like `.wp-container-core-group-is-layout-` carry the same // specificity (0,1,0) as the generic block styles they are meant to // override. Moving them earlier silently hands ties to the generic rule, // so a cached header could lay out differently from an uncached one. // Returning them to the store lets core emit them in its usual place and // order, which is the only way the cascade is guaranteed to match. $rules = isset( $cached['support_rules'] ) ? (array) $cached['support_rules'] : []; if ( empty( $rules ) || ! class_exists( 'WP_Style_Engine' ) ) { return; } foreach ( $rules as $selector => $declarations ) { if ( ! is_array( $declarations ) || empty( $declarations ) ) { continue; } \WP_Style_Engine::store_css_rule( 'block-supports', (string) $selector, $declarations ); } } /** * Capture the asset state that rendering can add to. * * @return array{styles:array, scripts:array, modules:array, rules:array} */ private function snapshot() { $styles = wp_styles(); $scripts = wp_scripts(); return [ 'styles' => $styles ? (array) $styles->queue : [], 'scripts' => $scripts ? (array) $scripts->queue : [], 'modules' => self::script_module_queue(), 'rules' => $this->support_rules(), ]; } /** * Snapshot the style engine's block-support rules as plain arrays. * * Returned as selector => declarations so two snapshots can be compared and * the difference replayed through the public store API. * * @return array */ private function support_rules() { if ( ! class_exists( 'WP_Style_Engine' ) || ! method_exists( 'WP_Style_Engine', 'get_store' ) ) { return []; } $store = \WP_Style_Engine::get_store( 'block-supports' ); if ( ! is_object( $store ) || ! method_exists( $store, 'get_all_rules' ) ) { return []; } $out = []; foreach ( (array) $store->get_all_rules() as $selector => $rule ) { if ( ! is_object( $rule ) || ! method_exists( $rule, 'get_declarations' ) ) { continue; } $declarations = $rule->get_declarations(); if ( is_object( $declarations ) && method_exists( $declarations, 'get_declarations' ) ) { $declarations = $declarations->get_declarations(); } $out[ (string) $selector ] = (array) $declarations; } return $out; } /** * Block-support rules a render added or changed. * * @param array $before Rules before the render. * @param array $after Rules after the render. * @return array */ private function rules_delta( $before, $after ) { $delta = []; foreach ( $after as $selector => $declarations ) { if ( ! isset( $before[ $selector ] ) || $before[ $selector ] !== $declarations ) { $delta[ $selector ] = $declarations; } } return $delta; } /** * Ids of the currently enqueued script modules. * * @return string[] */ private static function script_module_queue() { if ( ! self::can_track_script_modules() ) { return []; } $queue = wp_script_modules()->get_queue(); return is_array( $queue ) ? array_values( array_map( 'strval', $queue ) ) : []; } /** * Can the script-module queue be observed on this WordPress version? * * @return bool */ private static function can_track_script_modules() { static $can = null; if ( null !== $can ) { return $can; } $can = function_exists( 'wp_script_modules' ) && function_exists( 'wp_enqueue_script_module' ) && method_exists( wp_script_modules(), 'get_queue' ); return $can; } /** * Is this a block worth caching? * * @param array $parsed_block Parsed block. * @return bool */ private function is_cacheable_block( $parsed_block ) { if ( empty( $parsed_block['blockName'] ) ) { return false; } $blocks = (array) apply_filters( 'ablocks/perf/fragment_cache/blocks', [ 'core/template-part' ] ); if ( ! in_array( $parsed_block['blockName'], $blocks, true ) ) { return false; } // Contexts where the output is intentionally not the canonical one. if ( is_preview() || is_customize_preview() || is_admin() ) { return false; } $should = ! is_user_logged_in(); return (bool) apply_filters( 'ablocks/perf/fragment_cache/should_cache', $should, $parsed_block ); } /** * Is this rendered output safe to store? * * @param string $content Rendered HTML. * @return bool */ private function should_store( $content ) { if ( ! is_string( $content ) || '' === trim( $content ) ) { return false; } if ( strlen( $content ) > self::MAX_BYTES ) { return false; } // A fragment carrying a nonce would freeze it for the whole TTL. Cheap to // detect, and far better to skip the fragment than to serve a dead token. if ( false !== stripos( $content, '_wpnonce' ) || false !== stripos( $content, 'wp_rest' ) ) { return false; } return true; } /** * Stable identity for a parsed block within one request. * * @param array $parsed_block Parsed block. * @return string */ private function signature( $parsed_block ) { $attrs = isset( $parsed_block['attrs'] ) ? $parsed_block['attrs'] : []; return md5( $parsed_block['blockName'] . '|' . wp_json_encode( $attrs ) ); } /** * Transient key for a fragment. * * @param array $parsed_block Parsed block. * @return string */ private function cache_key( $parsed_block ) { $parts = [ $this->signature( $parsed_block ), get_stylesheet(), CacheBackend::generation( self::VERSION_OPTION ), determine_locale(), is_user_logged_in() ? 'u' . get_current_user_id() : 'anon', ]; // Transient keys are capped at 172 characters; a hash keeps this well // inside that regardless of theme or locale name length. return self::TRANSIENT_PREFIX . md5( implode( '|', $parts ) ); } }