Debugging Complex Bottlenecks in Full Site Editing (FSE) Block Themes and theme.json Using Custom Action and Filter Hooks
Leveraging WordPress Hooks for Deep FSE and theme.json Performance Analysis
Full Site Editing (FSE) and the underlying `theme.json` specification introduce a powerful, yet complex, paradigm for WordPress theme development. As themes become more sophisticated, leveraging the Site Editor’s capabilities and the granular control offered by `theme.json`, performance bottlenecks can emerge that are not immediately obvious through standard profiling tools. These bottlenecks often stem from intricate interactions between block rendering, style generation, and the core WordPress rendering pipeline. This post delves into advanced debugging techniques using custom action and filter hooks to pinpoint and resolve performance issues within FSE block themes and their `theme.json` configurations.
Identifying `theme.json` Processing Bottlenecks
The `theme.json` file is parsed and processed extensively during the WordPress request lifecycle, influencing everything from global styles to block-specific settings. Slowdowns here can manifest as increased page load times, particularly on the front end and within the Site Editor itself. We can hook into the processes that load and apply `theme.json` settings to measure their execution time.
Timing `wp_get_global_styles` and `wp_get_theme_json`
The functions `wp_get_theme_json()` and `wp_get_global_styles()` are central to retrieving and processing theme configuration. By wrapping these calls with timing mechanisms, we can isolate their performance impact.
Custom Debugging Plugin Setup
First, let’s establish a simple debugging plugin. Create a directory `my-fse-debugger` in your `wp-content/plugins/` directory and add a main plugin file, `my-fse-debugger.php`.
/*
Plugin Name: My FSE Debugger
Description: Advanced debugging tools for FSE and theme.json performance.
Version: 1.0
Author: Antigravity
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
// Define a constant for easier conditional loading
define( 'MY_FSE_DEBUGGER_ACTIVE', true );
// Include other debugger files
require_once plugin_dir_path( __FILE__ ) . 'includes/class-my-fse-debugger.php';
require_once plugin_dir_path( __FILE__ ) . 'includes/class-my-fse-timing.php';
function run_my_fse_debugger() {
$debugger = new My_FSE_Debugger();
$debugger->run();
}
run_my_fse_debugger();
Timing `theme.json` Retrieval
Create `includes/class-my-fse-timing.php` to house our timing logic. We’ll use a simple start/stop mechanism and log the duration.
<?php
/**
* Class My_FSE_Timing
*
* Handles timing of specific WordPress actions and filters.
*/
class My_FSE_Timing {
private static $timings = array();
private static $start_times = array();
/**
* Starts a timer for a given identifier.
*
* @param string $identifier A unique name for the timer.
*/
public static function start( string $identifier ) {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE ) {
return;
}
self::$start_times[ $identifier ] = microtime( true );
}
/**
* Stops a timer and records the duration.
*
* @param string $identifier The identifier of the timer to stop.
*/
public static function stop( string $identifier ) {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE || ! isset( self::$start_times[ $identifier ] ) ) {
return;
}
$end_time = microtime( true );
$duration = ( $end_time - self::$start_times[ $identifier ] ) * 1000; // Duration in milliseconds
if ( ! isset( self::$timings[ $identifier ] ) ) {
self::$timings[ $identifier ] = 0;
}
self::$timings[ $identifier ] += $duration;
unset( self::$start_times[ $identifier ] );
}
/**
* Gets all recorded timings.
*
* @return array
*/
public static function get_timings(): array {
return self::$timings;
}
/**
* Logs timings to the debug log.
*/
public static function log_timings() {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE || empty( self::$timings ) ) {
return;
}
error_log( '--- FSE Debugger Timings ---' );
foreach ( self::$timings as $identifier => $duration ) {
error_log( sprintf( '%s: %.2f ms', $identifier, $duration ) );
}
error_log( '----------------------------' );
}
}
Hooking into `theme.json` Processing
Now, in `includes/class-my-fse-debugger.php`, we’ll add hooks to time the core `theme.json` retrieval functions. We’ll use the `all` hook to catch any function that runs, and then filter by function name.
<?php
/**
* Class My_FSE_Debugger
*
* Main class for FSE debugging features.
*/
class My_FSE_Debugger {
public function run() {
// Hook into the 'all' action to catch all actions and filters.
// This is broad but effective for identifying specific function calls.
add_action( 'all', array( $this, 'log_all_actions' ), 10, 0 );
// Hook into shutdown to log timings at the end of the request.
add_action( 'shutdown', array( 'My_FSE_Timing', 'log_timings' ) );
// Specific hooks for theme.json processing
add_filter( 'theme_json_data', array( $this, 'time_theme_json_data' ), 10, 1 );
add_filter( 'theme_json_data_file', array( $this, 'time_theme_json_data_file' ), 10, 1 );
add_filter( 'get_theme_data', array( $this, 'time_get_theme_data' ), 10, 2 ); // Less direct, but can be related
add_filter( 'wp_get_global_styles', array( $this, 'time_wp_get_global_styles' ), 10, 1 );
}
/**
* Logs all actions and filters executed.
* This is a very verbose hook and should be used judiciously.
* We filter for specific functions related to theme.json.
*
* @param string $tag The name of the action or filter.
*/
public function log_all_actions( string $tag ) {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE ) {
return;
}
// Target specific functions related to theme.json processing
$target_functions = array(
'wp_get_theme_json',
'get_theme_data',
'wp_get_global_styles',
'theme_json_data_file',
'theme_json_data',
);
if ( in_array( $tag, $target_functions, true ) ) {
My_FSE_Timing::start( 'hook_execution_' . $tag );
// We can't directly time the function call itself here without wrapping it,
// but we can time the execution of *this* callback.
// The filters below provide more precise timing for the actual function outputs.
}
}
/**
* Times the output of wp_get_theme_json.
*
* @param array $theme_json The theme.json data.
* @return array
*/
public function time_theme_json_data( array $theme_json ): array {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE ) {
return $theme_json;
}
My_FSE_Timing::stop( 'hook_execution_theme_json_data' ); // Stop the broad hook if it started
My_FSE_Timing::start( 'wp_get_theme_json_output' );
// We stop this timer in a shutdown hook or a subsequent filter if possible,
// but for simplicity, we'll assume this is the end of its processing for now.
// A more robust solution would involve a custom wrapper.
// For now, let's rely on the shutdown hook to log timings.
return $theme_json;
}
/**
* Times the output of wp_get_theme_json_data_file.
*
* @param string $file_path The path to the theme.json file.
* @return string
*/
public function time_theme_json_data_file( string $file_path ): string {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE ) {
return $file_path;
}
My_FSE_Timing::stop( 'hook_execution_theme_json_data_file' );
My_FSE_Timing::start( 'wp_get_theme_json_data_file_output' );
return $file_path;
}
/**
* Times the output of get_theme_data.
*
* @param array $theme_data The theme data.
* @param string $theme_root_path The path to the theme directory.
* @return array
*/
public function time_get_theme_data( array $theme_data, string $theme_root_path ): array {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE ) {
return $theme_data;
}
My_FSE_Timing::stop( 'hook_execution_get_theme_data' );
My_FSE_Timing::start( 'get_theme_data_output' );
return $theme_data;
}
/**
* Times the output of wp_get_global_styles.
*
* @param array $global_styles The global styles array.
* @return array
*/
public function time_wp_get_global_styles( array $global_styles ): array {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE ) {
return $global_styles;
}
My_FSE_Timing::stop( 'hook_execution_wp_get_global_styles' );
My_FSE_Timing::start( 'wp_get_global_styles_output' );
// This filter is applied *after* the function has executed and returned its value.
// To time the function itself, we'd need to hook into its internal execution.
// For now, we'll rely on the 'all' hook and the shutdown log.
return $global_styles;
}
}
With this setup, activating the “My FSE Debugger” plugin and then visiting your site (front-end or Site Editor) will trigger these timing hooks. The results will be logged to your PHP error log (typically `wp-content/debug.log` if `WP_DEBUG_LOG` is enabled in `wp-config.php`).
Analyzing `theme.json` Structure and Complexity
A large or deeply nested `theme.json` can inherently lead to longer processing times. While the timing hooks above will show the overall duration, understanding the structure is key. The `theme_json` filter provides access to the processed data before it’s fully applied.
<?php
// Add this to includes/class-my-fse-debugger.php within the My_FSE_Debugger class
/**
* Analyzes and logs the structure of theme.json data.
*
* @param array $theme_json The theme.json data.
* @return array
*/
public function analyze_theme_json_structure( array $theme_json ): array {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE ) {
return $theme_json;
}
// Log the number of top-level sections
$section_count = count( $theme_json );
error_log( sprintf( 'FSE Debugger: theme.json has %d top-level sections.', $section_count ) );
// Log the size of specific sections (e.g., settings, styles)
if ( isset( $theme_json['settings'] ) ) {
$settings_size = count( $theme_json['settings'] );
error_log( sprintf( 'FSE Debugger: theme.json settings section has %d entries.', $settings_size ) );
}
if ( isset( $theme_json['styles'] ) ) {
$styles_size = count( $theme_json['styles'] );
error_log( sprintf( 'FSE Debugger: theme.json styles section has %d entries.', $styles_size ) );
}
// You could recursively traverse the array to count nested elements,
// but this can be very verbose. For deep analysis, consider a dedicated JSON analysis tool.
return $theme_json;
}
// And add this to the run() method:
// add_filter( 'theme_json_data', array( $this, 'analyze_theme_json_structure' ), 15, 1 ); // Use a slightly higher priority
This analysis can reveal if your `theme.json` is excessively large or has an unusually deep structure, which might indicate opportunities for refactoring or simplifying your theme’s design system.
Debugging Block Rendering Performance
Block rendering is a primary source of performance issues in FSE themes. Each block’s `render_callback` or `save` method contributes to the HTML output. Identifying slow-rendering blocks requires a different approach, focusing on the rendering process itself.
Timing Block Rendering with `render_block`
The `render_block` filter is the most direct way to intercept and time the rendering of individual blocks. We can wrap the block’s rendering process within our timing mechanism.
<?php
// Add this to includes/class-my-fse-debugger.php within the My_FSE_Debugger class
/**
* Times the rendering of individual blocks.
*
* @param string $block_content The rendered block content.
* @param array $block The block attributes.
* @return string
*/
public function time_block_rendering( string $block_content, array $block ): string {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE ) {
return $block_content;
}
// Avoid timing our own debugger blocks or very common, fast blocks if needed.
if ( isset( $block['blockName'] ) && ( str_starts_with( $block['blockName'], 'my-fse-debugger/' ) || in_array( $block['blockName'], array( 'core/paragraph', 'core/heading' ), true ) ) ) {
return $block_content;
}
$block_name = $block['blockName'] ?? 'unknown';
$block_id = $block['attrs']['id'] ?? uniqid(); // Use block ID if available for more specific logging
// Start timing *before* the block content is generated.
// The filter is applied *after* the content is generated, so we need a different strategy.
// A more advanced approach would involve hooking into `do_blocks` or wrapping the block rendering function itself.
// For this filter, we can time the *processing* of the block's attributes and its potential modification.
// To time the actual rendering, we need to hook earlier.
// Let's use a different approach: hook into `do_blocks` or `render_block_data`.
// The `render_block` filter is applied *after* rendering.
return $block_content;
}
// Add this to the run() method:
// add_filter( 'render_block', array( $this, 'time_block_rendering' ), 10, 2 );
The `render_block` filter is applied *after* the block has been rendered. To accurately time the rendering process itself, we need to hook into the process that *initiates* rendering. The `render_block_data` filter, which runs before `render_block`, is a better candidate for timing the preparation phase. For timing the actual `render_callback` or `save` function execution, we’d need to wrap those functions or use a more advanced technique.
Timing Block Rendering with `render_block_data` and Custom Wrappers
A more effective strategy for timing block rendering involves hooking into `render_block_data` and then using a custom filter to time the actual rendering function call.
<?php
// Add this to includes/class-my-fse-debugger.php within the My_FSE_Debugger class
/**
* Hooks into render_block_data to prepare for timing block rendering.
*
* @param array $block_data The block data.
* @return array
*/
public function prepare_block_timing( array $block_data ): array {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE ) {
return $block_data;
}
if ( ! isset( $block_data['blockName'] ) ) {
return $block_data;
}
$block_name = $block_data['blockName'];
// Avoid timing our own debugger blocks or very common, fast blocks.
if ( str_starts_with( $block_name, 'my-fse-debugger/' ) || in_array( $block_name, array( 'core/paragraph', 'core/heading' ), true ) ) {
return $block_data;
}
My_FSE_Timing::start( 'block_render_' . $block_name );
// Add a filter that will be triggered *after* the block is rendered.
// This is a bit of a hack, but it allows us to stop the timer.
add_filter( 'render_block', array( $this, 'stop_block_timing' ), 9999, 2 ); // High priority to run last
return $block_data;
}
/**
* Stops the timer for block rendering.
*
* @param string $block_content The rendered block content.
* @param array $block The block attributes.
* @return string
*/
public function stop_block_timing( string $block_content, array $block ): string {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE ) {
return $block_content;
}
if ( ! isset( $block['blockName'] ) ) {
return $block_content;
}
$block_name = $block['blockName'];
// Only stop timers we've started.
if ( isset( My_FSE_Timing::$start_times[ 'block_render_' . $block_name ] ) ) {
My_FSE_Timing::stop( 'block_render_' . $block_name );
// Remove the filter to avoid it running on subsequent blocks in the same request.
remove_filter( 'render_block', array( $this, 'stop_block_timing' ), 9999 );
}
return $block_content;
}
// Add this to the run() method:
// add_filter( 'render_block_data', array( $this, 'prepare_block_timing' ), 10, 1 );
This approach uses `render_block_data` to start the timer and then a high-priority `render_block` filter to stop it. This gives a more accurate measurement of the time spent rendering each block. The output in `debug.log` will now include entries like `block_render_core/post-template: 15.23 ms`.
Analyzing Block Dependencies and Registrations
Sometimes, performance issues aren’t in the rendering itself but in how blocks are registered or how their dependencies are enqueued. While less common for FSE themes (as blocks are typically self-contained), complex custom blocks might still have issues.
<?php
// Add this to includes/class-my-fse-debugger.php within the My_FSE_Debugger class
/**
* Logs block registration details.
*/
public function log_block_registrations() {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE ) {
return;
}
$registered_blocks = WP_Block_Type_Registry::get_instance()->get_all_registered();
error_log( sprintf( 'FSE Debugger: Found %d registered block types.', count( $registered_blocks ) ) );
foreach ( $registered_blocks as $block_name => $block_type ) {
// Log details about each block, e.g., if it's a server-side rendered block.
$is_registered_on_save = method_exists( $block_type, 'get_script_handle' ) && ! empty( $block_type->get_script_handle() );
$is_server_side = $block_type->is_registered_on_save() && ! $is_registered_on_save; // Heuristic
error_log( sprintf(
'FSE Debugger: Block "%s" - Server-side: %s, Editor Script: %s',
$block_name,
$is_server_side ? 'Yes' : 'No',
$block_type->get_script_handle() ?: 'None'
) );
}
}
// Add this to the run() method:
// add_action( 'init', array( $this, 'log_block_registrations' ), 100 ); // Run late in init
This can help identify if you have an excessive number of server-side rendered blocks or if blocks are incorrectly registered, leading to unnecessary processing.
Debugging Style Generation and Application
FSE themes generate a significant amount of CSS, both from `theme.json` and block styles. Performance issues can arise from overly complex CSS generation or inefficient application of styles.
Timing `wp_add_inline_style` and `wp_enqueue_style`
The functions responsible for enqueuing and adding inline styles are critical. We can hook into these to measure their execution time.
<?php
// Add this to includes/class-my-fse-debugger.php within the My_FSE_Debugger class
/**
* Times the addition of inline styles.
*
* @param string $html The HTML output.
* @param string $handle The stylesheet handle.
* @param string $data The inline CSS data.
* @return string
*/
public function time_add_inline_style( string $html, string $handle, string $data ): string {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE ) {
return $html;
}
// Avoid timing our own debugger styles if any.
if ( 'my-fse-debugger-styles' === $handle ) {
return $html;
}
My_FSE_Timing::start( 'add_inline_style_' . $handle );
// The filter is applied *after* the style is added.
// To time the actual addition, we'd need to hook into the internal wp_add_inline_style function.
// For now, we'll rely on the shutdown log to capture the duration of the filter execution itself.
return $html;
}
/**
* Times the enqueuing of stylesheets.
*
* @param string $hook_suffix The current admin page hook suffix.
*/
public function time_enqueue_style( string $handle, ?string $src = null, ?array $deps = null, ?string $ver = false, ?string $media = 'all' ) {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE ) {
return;
}
// Avoid timing our own debugger styles.
if ( 'my-fse-debugger-styles' === $handle ) {
return;
}
My_FSE_Timing::start( 'enqueue_style_' . $handle );
// This hook is tricky as wp_enqueue_style doesn't have a direct filter *around* its execution.
// We can hook into `wp_print_styles` or `admin_print_styles` to time the *output* of enqueued styles.
// For timing the enqueue call itself, we'd need to wrap the function.
}
// Add these to the run() method:
// add_filter( 'style_loader_tag', array( $this, 'time_add_inline_style' ), 10, 3 ); // This filter is applied when the <link> tag is generated.
// We need a better hook for wp_add_inline_style. Let's use a custom wrapper.
// A more robust approach for wp_add_inline_style:
add_action( 'wp_print_styles', array( $this, 'start_inline_style_timing' ), 1 );
add_action( 'wp_print_styles', array( $this, 'stop_inline_style_timing' ), 999 );
public function start_inline_style_timing() {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE ) {
return;
}
// This is a heuristic: we assume styles printed here are potentially slow.
// A more precise method would involve hooking into the internal `wp_add_inline_style` calls.
// For now, we'll just mark the start of the style printing phase.
My_FSE_Timing::start( 'wp_print_styles_phase' );
}
public function stop_inline_style_timing() {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE ) {
return;
}
My_FSE_Timing::stop( 'wp_print_styles_phase' );
}
// For enqueue_style, we can hook into the actual printing of the styles.
add_action( 'wp_print_styles', array( $this, 'start_enqueue_style_timing' ), 5 );
add_action( 'wp_print_styles', array( $this, 'stop_enqueue_style_timing' ), 995 );
public function start_enqueue_style_timing() {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE ) {
return;
}
My_FSE_Timing::start( 'wp_enqueue_styles_phase' );
}
public function stop_enqueue_style_timing() {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE ) {
return;
}
My_FSE_Timing::stop( 'wp_enqueue_styles_phase' );
}
The `wp_print_styles` hook is a good place to time the overall phase of style printing. For more granular timing of individual `wp_add_inline_style` calls, you would need to hook into the internal WordPress functions, which is more complex and prone to breaking with core updates. The current approach provides a good overview of the style generation overhead.
Debugging Site Editor Specific Issues
The Site Editor (Gutenberg) has its own rendering pipeline and asset loading. Debugging performance here often involves understanding how blocks and styles are loaded within the editor’s JavaScript environment, but we can still leverage PHP hooks for backend-related performance.
Timing `rest_prepare_block_type`
When the Site Editor loads, it makes REST API requests to fetch block type information, including their `theme.json` settings. The `rest_prepare_block_type` filter allows us to inspect and potentially time this process.
<?php
// Add this to includes/class-my-fse-debugger.php within the My_FSE_Debugger class
/**
* Times the preparation of block types for the REST API.
*
* @param WP_REST_Response $response The response object.
* @param WP_Block_Type $block_type The block type object.
* @param WP_REST_Request $request The REST request object.
* @return WP_REST_Response
*/
public function time_rest_prepare_block_type( WP_REST_Response $response, WP_Block_Type $block_type, WP_REST_Request $request ): WP_REST_Response {
if ( ! defined( 'MY_FSE_DEBUGGER_ACTIVE' ) || ! MY_FSE_DEBUGGER_ACTIVE ) {
return $response;
}
// Only time requests related to block types, typically for the Site Editor.
if ( '/wp/v2/block-types' === $request->get_route() ) {
$block_name = $block_type->name;
My_FSE_Timing::start( 'rest_prepare_block_type_' . $block_name );
// We stop this timer in a subsequent filter or shutdown hook.
// For simplicity, let's rely on the shutdown log.
}
return $response;
}
// Add this to the run() method:
// add_filter( 'rest_prepare_block_type', array( $this, 'time_rest_prepare_block_type' ), 10, 3 );
This hook will log timings for each block type being prepared for the REST API. If you see excessively long times for specific blocks, it indicates that their server-side registration or data retrieval is slow, impacting the Site Editor’s load time.
Advanced Techniques: Custom Profiling Wrappers
For truly granular control and to time functions that don’t have convenient filters applied around their core execution, you can create custom wrappers. This is more involved but offers the highest precision.
Wrapping `wp_get_theme_json`
Let’s create a