: str_replace(): Passing null to parameter #2 ($replace) of type array|string is deprecated in
* Interactivity API: WP_Interactivity_API class.
* @subpackage Interactivity API
* Class used to process the Interactivity API on the server.
final class WP_Interactivity_API {
* Holds the mapping of directive attribute names to their processor methods.
private static $directive_processors = array(
'data-wp-interactive' => 'data_wp_interactive_processor',
'data-wp-router-region' => 'data_wp_router_region_processor',
'data-wp-context' => 'data_wp_context_processor',
'data-wp-bind' => 'data_wp_bind_processor',
'data-wp-class' => 'data_wp_class_processor',
'data-wp-style' => 'data_wp_style_processor',
'data-wp-text' => 'data_wp_text_processor',
* `data-wp-each` needs to be processed in the last place because it moves
* the cursor to the end of the processed items to prevent them to be
'data-wp-each' => 'data_wp_each_processor',
* Holds the initial state of the different Interactivity API stores.
* This state is used during the server directive processing. Then, it is
* serialized and sent to the client as part of the interactivity data to be
* recovered during the hydration of the client interactivity stores.
private $state_data = array();
* Holds the configuration required by the different Interactivity API stores.
* This configuration is serialized and sent to the client as part of the
* interactivity data and can be accessed by the client interactivity stores.
private $config_data = array();
* Flag that indicates whether the `data-wp-router-region` directive has
* been found in the HTML and processed.
* The value is saved in a private property of the WP_Interactivity_API
* instance instead of using a static variable inside the processor
* function, which would hold the same value for all instances
* independently of whether they have processed any
* `data-wp-router-region` directive or not.
private $has_processed_router_region = false;
* Stack of namespaces defined by `data-wp-interactive` directives, in
* the order they are processed.
* This is only available during directive processing, otherwise it is `null`.
* @var array<string>|null
private $namespace_stack = null;
* Stack of contexts defined by `data-wp-context` directives, in
* the order they are processed.
* This is only available during directive processing, otherwise it is `null`.
* @var array<array<mixed>>|null
private $context_stack = null;
* Gets and/or sets the initial state of an Interactivity API store for a
* If state for that store namespace already exists, it merges the new
* provided state with the existing one.
* When no namespace is specified, it returns the state defined for the
* current value in the internal namespace stack during a `process_directives` call.
* @since 6.6.0 The `$store_namespace` param is optional.
* @param string $store_namespace Optional. The unique store namespace identifier.
* @param array $state Optional. The array that will be merged with the existing state for the specified
* @return array The current state for the specified store namespace. This will be the updated state if a $state
public function state( ?string $store_namespace = null, ?array $state = null ): array {
if ( ! $store_namespace ) {
__( 'The namespace is required when state data is passed.' ),
if ( null !== $store_namespace ) {
__( 'The namespace should be a non-empty string.' ),
if ( null === $this->namespace_stack ) {
__( 'The namespace can only be omitted during directive processing.' ),
$store_namespace = end( $this->namespace_stack );
if ( ! isset( $this->state_data[ $store_namespace ] ) ) {
$this->state_data[ $store_namespace ] = array();
if ( is_array( $state ) ) {
$this->state_data[ $store_namespace ] = array_replace_recursive(
$this->state_data[ $store_namespace ],
return $this->state_data[ $store_namespace ];
* Gets and/or sets the configuration of the Interactivity API for a given
* If configuration for that store namespace exists, it merges the new
* provided configuration with the existing one.
* @param string $store_namespace The unique store namespace identifier.
* @param array $config Optional. The array that will be merged with the existing configuration for the
* specified store namespace.
* @return array The configuration for the specified store namespace. This will be the updated configuration if a
* $config argument was provided.
public function config( string $store_namespace, array $config = array() ): array {
if ( ! isset( $this->config_data[ $store_namespace ] ) ) {
$this->config_data[ $store_namespace ] = array();
if ( is_array( $config ) ) {
$this->config_data[ $store_namespace ] = array_replace_recursive(
$this->config_data[ $store_namespace ],
return $this->config_data[ $store_namespace ];
* Prints the serialized client-side interactivity data.
* Encodes the config and initial state into JSON and prints them inside a
* script tag of type "application/json". Once in the browser, the state will
* be parsed and used to hydrate the client-side interactivity stores and the
* configuration will be available using a `getConfig` utility.
public function print_client_interactivity_data() {
if ( empty( $this->state_data ) && empty( $this->config_data ) ) {
$interactivity_data = array();
foreach ( $this->config_data as $key => $value ) {
if ( ! empty( $value ) ) {
$config[ $key ] = $value;
if ( ! empty( $config ) ) {
$interactivity_data['config'] = $config;
foreach ( $this->state_data as $key => $value ) {
if ( ! empty( $value ) ) {
if ( ! empty( $state ) ) {
$interactivity_data['state'] = $state;
if ( ! empty( $interactivity_data ) ) {
* This data will be printed as JSON inside a script tag like this:
* <script type="application/json"></script>
* A script tag must be closed by a sequence beginning with `</`. It's impossible to
* close a script tag without using `<`. We ensure that `<` is escaped and `/` can
* remain unescaped, so `</script>` will be printed as `\u003C/script\u00E3`.
* - JSON_HEX_TAG: All < and > are converted to \u003C and \u003E.
* - JSON_UNESCAPED_SLASHES: Don't escape /.
* If the page will use UTF-8 encoding, it's safe to print unescaped unicode:
* - JSON_UNESCAPED_UNICODE: Encode multibyte Unicode characters literally (instead of as `\uXXXX`).
* - JSON_UNESCAPED_LINE_TERMINATORS: The line terminators are kept unescaped when
* JSON_UNESCAPED_UNICODE is supplied. It uses the same behaviour as it was
* before PHP 7.1 without this constant. Available as of PHP 7.1.0.
* The JSON specification requires encoding in UTF-8, so if the generated HTML page
* is not encoded in UTF-8 then it's not safe to include those literals. They must
* be escaped to avoid encoding issues.
* @see https://www.rfc-editor.org/rfc/rfc8259.html for details on encoding requirements.
* @see https://www.php.net/manual/en/json.constants.php for details on these constants.
* @see https://html.spec.whatwg.org/#script-data-state for details on script tag parsing.
$json_encode_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_LINE_TERMINATORS;
if ( ! is_utf8_charset() ) {
$json_encode_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES;
wp_print_inline_script_tag(
'type' => 'application/json',
'id' => 'wp-interactivity-data',
* Returns the latest value on the context stack with the passed namespace.
* When the namespace is omitted, it uses the current namespace on the
* namespace stack during a `process_directives` call.
* @param string $store_namespace Optional. The unique store namespace identifier.
public function get_context( ?string $store_namespace = null ): array {
if ( null === $this->context_stack ) {
__( 'The context can only be read during directive processing.' ),
if ( ! $store_namespace ) {
if ( null !== $store_namespace ) {
__( 'The namespace should be a non-empty string.' ),
$store_namespace = end( $this->namespace_stack );
$context = end( $this->context_stack );
return ( $store_namespace && $context && isset( $context[ $store_namespace ] ) )
? $context[ $store_namespace ]
* Registers the `@wordpress/interactivity` script modules.
public function register_script_modules() {
$suffix = wp_scripts_get_suffix();
wp_register_script_module(
'@wordpress/interactivity',
includes_url( "js/dist/interactivity$suffix.js" )
wp_register_script_module(
'@wordpress/interactivity-router',
includes_url( "js/dist/interactivity-router$suffix.js" ),
array( '@wordpress/interactivity' )
* Adds the necessary hooks for the Interactivity API.
public function add_hooks() {
add_action( 'wp_enqueue_scripts', array( $this, 'register_script_modules' ) );
add_action( 'wp_footer', array( $this, 'print_client_interactivity_data' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'register_script_modules' ) );
add_action( 'admin_print_footer_scripts', array( $this, 'print_client_interactivity_data' ) );
* Processes the interactivity directives contained within the HTML content
* and updates the markup accordingly.
* @param string $html The HTML content to process.
* @return string The processed HTML content. It returns the original content when the HTML contains unbalanced tags.
public function process_directives( string $html ): string {
if ( ! str_contains( $html, 'data-wp-' ) ) {
$this->namespace_stack = array();
$this->context_stack = array();
$result = $this->_process_directives( $html );
$this->namespace_stack = null;
$this->context_stack = null;
return null === $result ? $html : $result;
* Processes the interactivity directives contained within the HTML content
* and updates the markup accordingly.
* It uses the WP_Interactivity_API instance's context and namespace stacks,
* which are shared between all calls.
* This method returns null if the HTML contains unbalanced tags.
* @param string $html The HTML content to process.
* @return string|null The processed HTML content. It returns null when the HTML contains unbalanced tags.
private function _process_directives( string $html ) {
$p = new WP_Interactivity_API_Directives_Processor( $html );
$directive_processor_prefixes = array_keys( self::$directive_processors );
$directive_processor_prefixes_reversed = array_reverse( $directive_processor_prefixes );
* Save the current size for each stack to restore them in case
* the processing finds unbalanced tags.
$namespace_stack_size = count( $this->namespace_stack );
$context_stack_size = count( $this->context_stack );
while ( $p->next_tag( array( 'tag_closers' => 'visit' ) ) ) {
$tag_name = $p->get_tag();
* Directives inside SVG and MATH tags are not processed,
* as they are not compatible with the Tag Processor yet.
* We still process the rest of the HTML.
if ( 'SVG' === $tag_name || 'MATH' === $tag_name ) {
if ( $p->get_attribute_names_with_prefix( 'data-wp-' ) ) {
/* translators: 1: SVG or MATH HTML tag, 2: Namespace of the interactive block. */
$message = sprintf( __( 'Interactivity directives were detected on an incompatible %1$s tag when processing "%2$s". These directives will be ignored in the server side render.' ), $tag_name, end( $this->namespace_stack ) );
_doing_it_wrong( __METHOD__, $message, '6.6.0' );
$p->skip_to_tag_closer();
if ( $p->is_tag_closer() ) {
list( $opening_tag_name, $directives_prefixes ) = end( $tag_stack );
if ( 0 === count( $tag_stack ) || $opening_tag_name !== $tag_name ) {
* If the tag stack is empty or the matching opening tag is not the
* same than the closing tag, it means the HTML is unbalanced and it
// Remove the last tag from the stack.
if ( 0 !== count( $p->get_attribute_names_with_prefix( 'data-wp-each-child' ) ) ) {
* If the tag has a `data-wp-each-child` directive, jump to its closer
* tag because those tags have already been processed.
$p->next_balanced_tag_closer_tag();
$directives_prefixes = array();
// Checks if there is a server directive processor registered for each directive.
foreach ( $p->get_attribute_names_with_prefix( 'data-wp-' ) as $attribute_name ) {
list( $directive_prefix ) = $this->extract_prefix_and_suffix( $attribute_name );
if ( array_key_exists( $directive_prefix, self::$directive_processors ) ) {
$directives_prefixes[] = $directive_prefix;
* If this tag will visit its closer tag, it adds it to the tag stack
* so it can process its closing tag and check for unbalanced tags.
if ( $p->has_and_visits_its_closer_tag() ) {
$tag_stack[] = array( $tag_name, $directives_prefixes );
* If the matching opener tag didn't have any directives, it can skip the
if ( 0 === count( $directives_prefixes ) ) {
// Directive processing might be different depending on if it is entering the tag or exiting it.
'enter' => ! $p->is_tag_closer(),
'exit' => $p->is_tag_closer() || ! $p->has_and_visits_its_closer_tag(),
foreach ( $modes as $mode => $should_run ) {
* Sorts the attributes by the order of the `directives_processor` array
* and checks what directives are present in this element.
$existing_directives_prefixes = array_intersect(
'enter' === $mode ? $directive_processor_prefixes : $directive_processor_prefixes_reversed,
foreach ( $existing_directives_prefixes as $directive_prefix ) {
$func = is_array( self::$directive_processors[ $directive_prefix ] )
? self::$directive_processors[ $directive_prefix ]
: array( $this, self::$directive_processors[ $directive_prefix ] );
call_user_func_array( $func, array( $p, $mode, &$tag_stack ) );
// Reset the namespace and context stacks to their previous values.
array_splice( $this->namespace_stack, $namespace_stack_size );
array_splice( $this->context_stack, $context_stack_size );