Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 3 additions & 28 deletions src/wp-includes/interactivity-api/class-wp-interactivity-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,6 @@ private function _process_directives( string $html ) {
* @since 6.6.0 Removed `default_namespace` and `context` arguments.
* @since 6.6.0 Add support for derived state.
* @since 6.9.0 Receive $entry as an argument instead of the directive value string.
* @since 7.2.0 Tracks the path of a token that marks HTML as trusted, reached through state.
*
* @param array $entry An array containing a whole directive entry with its namespace, value, suffix, or unique ID.
* @return mixed|null The result of the evaluation. Null if the reference path doesn't exist or the namespace is falsy.
Expand All @@ -732,9 +731,8 @@ private function evaluate( $entry ) {
$path = $should_negate_value ? substr( $path, 1 ) : $path;

// Extracts the value from the store using the reference path.
$path_segments = explode( '.', $path );
$current = $store;
$called_closure = false;
$path_segments = explode( '.', $path );
$current = $store;
foreach ( $path_segments as $index => $path_segment ) {
/*
* Special case for numeric arrays and strings. Add length
Expand Down Expand Up @@ -783,8 +781,7 @@ private function evaluate( $entry ) {
*/
array_push( $this->namespace_stack, $ns );
try {
$called_closure = true;
$current = $current();
$current = $current();

/*
* Tracks derived state properties that are accessed during
Expand Down Expand Up @@ -818,28 +815,6 @@ private function evaluate( $entry ) {
}
}

/*
* Tracks the tokens that mark HTML as trusted and that a reference
* reaches through state, the way derived state closures are tracked, so
* the client keeps the server-rendered content of a token until its
* store supplies a value. A token reached through context, or through
* the result of a derived state closure, is not tracked: the client has
* no context store to resolve it from, and the closure is tracked
* instead.
*
* @since 7.2.0
*/
if (
'state' === $path_segments[0] &&
! $called_closure &&
$current instanceof WP_Interactivity_Dangerous_HTML
) {
$this->derived_state_closures[ $ns ] ??= array();
if ( ! in_array( $path, $this->derived_state_closures[ $ns ], true ) ) {
$this->derived_state_closures[ $ns ][] = $path;
}
}

// Returns the opposite if it contains a negation operator (!).
return $should_negate_value ? ! $current : $current;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,22 @@
* Create tokens with {@see wp_interactivity_as_dangerous_html()}, which
* documents the risks of marking HTML as trusted.
*
* The HTML is kept in a private property, so encoding a token as JSON never
* includes it. A token restored by unserialization is inert: it holds no HTML,
* so it never renders.
* The HTML is kept in a private property, so it never reaches JSON: a token
* encodes as the marker `{"wpServerHtml":true}`, which tells the client to
* keep the content the element already has. The marker holds no HTML. A token
* restored by unserialization is inert: it holds no HTML, so it never renders,
* and it encodes as the marker too.
*
* Both JSON encoding paths of `wp_json_encode()` print the marker: `json_encode()`
* uses {@see WP_Interactivity_Dangerous_HTML::jsonSerialize()}, and the fallback
* for data that is not valid UTF-8 rebuilds the object by iterating it with
* {@see WP_Interactivity_Dangerous_HTML::getIterator()}.
*
* @since 7.2.0
*
* @access private
*/
final class WP_Interactivity_Dangerous_HTML {
final class WP_Interactivity_Dangerous_HTML implements JsonSerializable, IteratorAggregate {
/**
* The trusted HTML. It is `null` for a token restored by unserialization,
* which never renders.
Expand Down Expand Up @@ -73,4 +80,32 @@ public function __wakeup(): void {
'7.2.0'
);
}

/**
* Gets the value that JSON encoding prints in place of the token.
*
* It is the same for every token and holds none of its HTML.
*
* @since 7.2.0
*
* @return array<string, bool> The marker: a single `wpServerHtml` entry set to `true`,
* which `json_encode()` prints as `{"wpServerHtml":true}`.
*/
public function jsonSerialize(): array {
return array( 'wpServerHtml' => true );
}

/**
* Gets the iterator that `foreach` and the fallback of `wp_json_encode()` read.
*
* It yields the same single entry as {@see WP_Interactivity_Dangerous_HTML::jsonSerialize()}
* and none of the token's HTML.
*
* @since 7.2.0
*
* @return Traversable<string, bool> An iterator yielding `wpServerHtml` => `true`, once.
*/
public function getIterator(): Traversable {
return new ArrayIterator( $this->jsonSerialize() );
}
}
110 changes: 68 additions & 42 deletions tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-html.php
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ private function announced_paths(): array {
/**
* Tests that a token renders as the exact content of the host, however the
* reference reaches it, and reaches the output only there: `data-wp-bind`
* does not set it and the client data does not hold it.
* does not set it and the client data holds none of its HTML. A token in
* state prints as the marker `{"wpServerHtml":true}`.
*
* @ticket 66246
*
Expand All @@ -191,8 +192,9 @@ private function announced_paths(): array {
* @dataProvider data_ways_to_reach_a_token
*
* @param callable $make_state Callable returning the state to set.
* @param string $printed How `state.html` is expected to print in the client data.
*/
public function test_token_reaches_the_output_only_as_host_content( callable $make_state ) {
public function test_token_reaches_the_output_only_as_host_content( callable $make_state, string $printed ) {
$this->setExpectedIncorrectUsage( 'WP_Interactivity_API::data_wp_bind_processor' );
$this->set_state( $make_state() );

Expand Down Expand Up @@ -220,22 +222,22 @@ public function test_token_reaches_the_output_only_as_host_content( callable $ma
'Expected only the non-scalar notice of each bound attribute.'
);

$printed = $this->printed_client_data();
$this->assertStringNotContainsString( 'marker-7f3a', $printed, 'Expected the client data to hold no trusted HTML.' );
$this->assertStringContainsString( '"html":{}', $printed, 'Expected the token to print as an empty object.' );
$this->assertStringNotContainsString( 'marker-7f3a', $this->printed_client_data(), 'Expected the client data to hold no trusted HTML.' );
$this->assertStringContainsString( '"html":' . $printed, $this->printed_client_data(), 'Expected state.html to print as given.' );
}

/**
* Data provider.
*
* @return array<string, array{ 0: callable }> Data provider.
* @return array<string, array{ 0: callable, 1: string }> Data provider.
*/
public static function data_ways_to_reach_a_token(): array {
return array(
'token in state' => array(
static function () {
return array( 'html' => wp_interactivity_as_dangerous_html( self::MARKER_HTML ) );
},
'{"wpServerHtml":true}',
),
'closure returning a token' => array(
static function () {
Expand All @@ -245,16 +247,19 @@ static function () {
},
);
},
'{}',
),
'clone of a token' => array(
static function () {
return array( 'html' => clone wp_interactivity_as_dangerous_html( self::MARKER_HTML ) );
},
'{"wpServerHtml":true}',
),
'token created with new' => array(
static function () {
return array( 'html' => new WP_Interactivity_Dangerous_HTML( self::MARKER_HTML ) );
},
'{"wpServerHtml":true}',
),
);
}
Expand Down Expand Up @@ -394,6 +399,7 @@ public static function data_values_that_are_not_tokens(): array {
'json_decode object' => array( json_decode( '{"html":"<p>Hi</p>"}' ) ),
'other object' => array( new ArrayObject( array( '<p>Hi</p>' ) ) ),
'json_decode of a token' => array( json_decode( wp_json_encode( new WP_Interactivity_Dangerous_HTML( '<p>Hi</p>' ) ) ) ),
'marker decoded as an array' => array( json_decode( '{"wpServerHtml":true}', true ) ),
'closure returning a string' => array(
static function () {
return '<p>Hi</p>';
Expand Down Expand Up @@ -729,11 +735,11 @@ public function test_unbalanced_markup_returns_input_unchanged() {
}

/**
* Tests that a token restored by unserialization does not render but is
* listed like any other token: the host keeps its fallback, exactly one
* notice is raised for the unserialization and one for the decline, the
* markup after the host is processed, and the token prints as an empty
* object. The token that was serialized still renders.
* Tests that a token restored by unserialization does not render but prints
* as the marker like any other token: the host keeps its fallback, exactly
* one notice is raised for the unserialization and one for the decline, and
* the markup after the host is processed. The token that was serialized
* still renders.
*
* @ticket 66246
*
Expand Down Expand Up @@ -772,18 +778,18 @@ public function test_unserialized_token_declines_as_not_a_token() {
);
$this->assertCount( 1, $this->html_notice_messages(), 'Expected exactly one data-wp-html notice.' );
$this->assertCount( 2, $this->notices, 'Expected no notice besides those of the unserialization and the decline.' );
$this->assertSame( array( 'test' => array( 'state.html', 'state.original' ) ), $this->announced_paths() );
$this->assertEquals(
(object) array(),
(object) array( 'wpServerHtml' => true ),
json_decode( $this->printed_client_data() )->state->test->html,
'Expected the token to print as an empty object.'
'Expected the token to print as the marker.'
);
}

/**
* Tests that a token in `wp_interactivity_data_wp_context()` reaches neither
* the output nor the client data, and that referencing it from other
* directives neither renders it nor aborts processing.
* Tests that a token in `wp_interactivity_data_wp_context()` prints as the
* marker, so its HTML reaches neither the output nor the client data, and
* that referencing it from other directives neither renders it nor aborts
* processing.
*
* @ticket 66246
*
Expand All @@ -809,30 +815,25 @@ public function test_token_in_context_helper_reaches_no_output_or_client_data()
$output,
'Expected the context to hold no HTML to render, and the markup after it to be processed.'
);
$this->assertStringContainsString( '{"h":{}}', $output, 'Expected the token to print as an empty object in the context attribute.' );
$this->assertStringContainsString( '{"h":{"wpServerHtml":true}}', $output, 'Expected the token to print as the marker in the context attribute.' );
$this->assertStringNotContainsString( 'marker-7f3a', $output );
$this->assertStringNotContainsString( 'marker-7f3a', $this->printed_client_data() );
$this->assertCount( 1, $this->html_notice_messages(), 'Expected the value read from context to decline as not a token.' );
}

/**
* Tests that every host renders its token and that a token reached through
* state is listed with the full path of the reference, under the namespace
* the reference resolves in, once per path and namespace. A path is listed
* without a leading `!`, also when only a directive other than
* `data-wp-html` references it.
* Tests that every host renders the token its reference reaches through
* state, at any depth and in any namespace.
*
* @ticket 66246
*
* @covers ::process_directives
* @covers ::filter_script_module_interactivity_data
*/
public function test_token_paths_are_listed_in_full_per_namespace() {
public function test_tokens_render_at_any_depth_and_namespace() {
$this->set_state(
array(
'html' => $this->token(),
'deep' => array( 'er' => array( 'html' => $this->token() ) ),
'negated' => $this->token(),
'html' => $this->token(),
'deep' => array( 'er' => array( 'html' => $this->token() ) ),
)
);
wp_interactivity_state(
Expand All @@ -849,18 +850,9 @@ public function test_token_paths_are_listed_in_full_per_namespace() {
$hosts .= '<div data-wp-html="' . $reference . '">Fallback</div>';
$rendered .= '<div data-wp-html="' . $reference . '">' . self::MARKER_HTML . '</div>';
}
$negated = '<div data-wp-class--off="!state.negated">x</div>';
$output = wp_interactivity_process_directives( $this->page( $hosts ) );

$output = wp_interactivity_process_directives( $this->page( $hosts . $negated ) );

$this->assertSame( $this->processed_page( $rendered . $negated ), $output, 'Expected every host to render its token.' );
$this->assertSame(
array(
'test' => array( 'state.deep.er.html', 'state.html', 'state.negated' ),
'other' => array( 'state.html', 'state.deep.er.html' ),
),
$this->announced_paths()
);
$this->assertSame( $this->processed_page( $rendered ), $output, 'Expected every host to render its token.' );
}

/**
Expand Down Expand Up @@ -893,15 +885,15 @@ public function test_token_in_a_closure_result_lists_only_the_closure() {

/**
* Tests that tokens reached through `context`, such as the items of
* `data-wp-each`, render without a notice and are not listed, although
* they are held in state.
* `data-wp-each`, render without a notice, add no listed path, and print
* as the marker in the state that holds them.
*
* @ticket 66246
*
* @covers ::process_directives
* @covers ::filter_script_module_interactivity_data
*/
public function test_tokens_reached_through_context_are_not_listed() {
public function test_tokens_reached_through_context_render_and_print_as_the_marker() {
$this->set_state(
array(
'items' => array(
Expand All @@ -918,6 +910,40 @@ public function test_tokens_reached_through_context_are_not_listed() {
$this->assertSame( 2, substr_count( $output, '<li data-wp-each-child="test::state.items" data-wp-html="context.item.html">' . self::MARKER_HTML . '</li>' ) );
$this->assertSame( array(), $this->notices, 'Expected no notice.' );
$this->assertSame( array(), $this->announced_paths(), 'Expected no listed path.' );
$this->assertStringContainsString(
'"items":[{"html":{"wpServerHtml":true}},{"html":{"wpServerHtml":true}}]',
$this->printed_client_data(),
'Expected each item token to print as the marker.'
);
}

/**
* Tests that a token prints as the marker on both encoding paths of
* `wp_json_encode()`, including the one taken when the data holds a string
* that is not valid UTF-8, and that iterating a token yields only the
* marker.
*
* @ticket 66246
*
* @covers WP_Interactivity_Dangerous_HTML::jsonSerialize
* @covers WP_Interactivity_Dangerous_HTML::getIterator
*/
public function test_token_prints_as_the_marker_on_both_encoding_paths() {
$token = $this->token();

$this->assertSame( '{"wpServerHtml":true}', wp_json_encode( $token ) );

$data = array(
'bad' => "caf\xE9",
'token' => $token,
);
$this->assertFalse( json_encode( $data ), 'Expected the data to take the fallback path.' );
$fallback = wp_json_encode( $data );
$this->assertIsString( $fallback );
$this->assertStringContainsString( '"token":{"wpServerHtml":true}', $fallback, 'Expected the fallback path to print the marker.' );
$this->assertStringNotContainsString( 'marker-7f3a', $fallback );

$this->assertSame( array( 'wpServerHtml' => true ), iterator_to_array( $token ) );
}

/**
Expand Down
Loading