Skip to content

Commit f64aa5a

Browse files
committed
Interactivity API: Print data-wp-html tokens as the wpServerHtml marker.
Encode `WP_Interactivity_Dangerous_HTML` tokens as `{"wpServerHtml":true}` on both paths of `wp_json_encode()`, so the client keeps the server-rendered content of a `data-wp-html` host wherever the reference reaches the token, including `data-wp-each` items read from context. Stop listing tokens reached through state in `derivedStateClosures`, which the marker makes unnecessary. Follow-up to [64386]. Props santosguillamot. Fixes #66246. git-svn-id: https://develop.svn.wordpress.org/trunk@64501 602fd350-edb4-49c9-b593-d223f7449a82
1 parent 1881d63 commit f64aa5a

3 files changed

Lines changed: 110 additions & 74 deletions

File tree

‎src/wp-includes/interactivity-api/class-wp-interactivity-api.php‎

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -706,7 +706,6 @@ private function _process_directives( string $html ) {
706706
* @since 6.6.0 Removed `default_namespace` and `context` arguments.
707707
* @since 6.6.0 Add support for derived state.
708708
* @since 6.9.0 Receive $entry as an argument instead of the directive value string.
709-
* @since 7.2.0 Tracks the path of a token that marks HTML as trusted, reached through state.
710709
*
711710
* @param array $entry An array containing a whole directive entry with its namespace, value, suffix, or unique ID.
712711
* @return mixed|null The result of the evaluation. Null if the reference path doesn't exist or the namespace is falsy.
@@ -732,9 +731,8 @@ private function evaluate( $entry ) {
732731
$path = $should_negate_value ? substr( $path, 1 ) : $path;
733732

734733
// Extracts the value from the store using the reference path.
735-
$path_segments = explode( '.', $path );
736-
$current = $store;
737-
$called_closure = false;
734+
$path_segments = explode( '.', $path );
735+
$current = $store;
738736
foreach ( $path_segments as $index => $path_segment ) {
739737
/*
740738
* Special case for numeric arrays and strings. Add length
@@ -783,8 +781,7 @@ private function evaluate( $entry ) {
783781
*/
784782
array_push( $this->namespace_stack, $ns );
785783
try {
786-
$called_closure = true;
787-
$current = $current();
784+
$current = $current();
788785

789786
/*
790787
* Tracks derived state properties that are accessed during
@@ -818,28 +815,6 @@ private function evaluate( $entry ) {
818815
}
819816
}
820817

821-
/*
822-
* Tracks the tokens that mark HTML as trusted and that a reference
823-
* reaches through state, the way derived state closures are tracked, so
824-
* the client keeps the server-rendered content of a token until its
825-
* store supplies a value. A token reached through context, or through
826-
* the result of a derived state closure, is not tracked: the client has
827-
* no context store to resolve it from, and the closure is tracked
828-
* instead.
829-
*
830-
* @since 7.2.0
831-
*/
832-
if (
833-
'state' === $path_segments[0] &&
834-
! $called_closure &&
835-
$current instanceof WP_Interactivity_Dangerous_HTML
836-
) {
837-
$this->derived_state_closures[ $ns ] ??= array();
838-
if ( ! in_array( $path, $this->derived_state_closures[ $ns ], true ) ) {
839-
$this->derived_state_closures[ $ns ][] = $path;
840-
}
841-
}
842-
843818
// Returns the opposite if it contains a negation operator (!).
844819
return $should_negate_value ? ! $current : $current;
845820
}

‎src/wp-includes/interactivity-api/class-wp-interactivity-dangerous-html.php‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,22 @@
1414
* Create tokens with {@see wp_interactivity_as_dangerous_html()}, which
1515
* documents the risks of marking HTML as trusted.
1616
*
17-
* The HTML is kept in a private property, so encoding a token as JSON never
18-
* includes it. A token restored by unserialization is inert: it holds no HTML,
19-
* so it never renders.
17+
* The HTML is kept in a private property, so it never reaches JSON: a token
18+
* encodes as the marker `{"wpServerHtml":true}`, which tells the client to
19+
* keep the content the element already has. The marker holds no HTML. A token
20+
* restored by unserialization is inert: it holds no HTML, so it never renders,
21+
* and it encodes as the marker too.
22+
*
23+
* Both JSON encoding paths of `wp_json_encode()` print the marker: `json_encode()`
24+
* uses {@see WP_Interactivity_Dangerous_HTML::jsonSerialize()}, and the fallback
25+
* for data that is not valid UTF-8 rebuilds the object by iterating it with
26+
* {@see WP_Interactivity_Dangerous_HTML::getIterator()}.
2027
*
2128
* @since 7.2.0
2229
*
2330
* @access private
2431
*/
25-
final class WP_Interactivity_Dangerous_HTML {
32+
final class WP_Interactivity_Dangerous_HTML implements JsonSerializable, IteratorAggregate {
2633
/**
2734
* The trusted HTML. It is `null` for a token restored by unserialization,
2835
* which never renders.
@@ -73,4 +80,32 @@ public function __wakeup(): void {
7380
'7.2.0'
7481
);
7582
}
83+
84+
/**
85+
* Gets the value that JSON encoding prints in place of the token.
86+
*
87+
* It is the same for every token and holds none of its HTML.
88+
*
89+
* @since 7.2.0
90+
*
91+
* @return array<string, bool> The marker: a single `wpServerHtml` entry set to `true`,
92+
* which `json_encode()` prints as `{"wpServerHtml":true}`.
93+
*/
94+
public function jsonSerialize(): array {
95+
return array( 'wpServerHtml' => true );
96+
}
97+
98+
/**
99+
* Gets the iterator that `foreach` and the fallback of `wp_json_encode()` read.
100+
*
101+
* It yields the same single entry as {@see WP_Interactivity_Dangerous_HTML::jsonSerialize()}
102+
* and none of the token's HTML.
103+
*
104+
* @since 7.2.0
105+
*
106+
* @return Traversable<string, bool> An iterator yielding `wpServerHtml` => `true`, once.
107+
*/
108+
public function getIterator(): Traversable {
109+
return new ArrayIterator( $this->jsonSerialize() );
110+
}
76111
}

‎tests/phpunit/tests/interactivity-api/wpInteractivityAPI-wp-html.php‎

Lines changed: 68 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,8 @@ private function announced_paths(): array {
182182
/**
183183
* Tests that a token renders as the exact content of the host, however the
184184
* reference reaches it, and reaches the output only there: `data-wp-bind`
185-
* does not set it and the client data does not hold it.
185+
* does not set it and the client data holds none of its HTML. A token in
186+
* state prints as the marker `{"wpServerHtml":true}`.
186187
*
187188
* @ticket 66246
188189
*
@@ -191,8 +192,9 @@ private function announced_paths(): array {
191192
* @dataProvider data_ways_to_reach_a_token
192193
*
193194
* @param callable $make_state Callable returning the state to set.
195+
* @param string $printed How `state.html` is expected to print in the client data.
194196
*/
195-
public function test_token_reaches_the_output_only_as_host_content( callable $make_state ) {
197+
public function test_token_reaches_the_output_only_as_host_content( callable $make_state, string $printed ) {
196198
$this->setExpectedIncorrectUsage( 'WP_Interactivity_API::data_wp_bind_processor' );
197199
$this->set_state( $make_state() );
198200

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

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

228229
/**
229230
* Data provider.
230231
*
231-
* @return array<string, array{ 0: callable }> Data provider.
232+
* @return array<string, array{ 0: callable, 1: string }> Data provider.
232233
*/
233234
public static function data_ways_to_reach_a_token(): array {
234235
return array(
235236
'token in state' => array(
236237
static function () {
237238
return array( 'html' => wp_interactivity_as_dangerous_html( self::MARKER_HTML ) );
238239
},
240+
'{"wpServerHtml":true}',
239241
),
240242
'closure returning a token' => array(
241243
static function () {
@@ -245,16 +247,19 @@ static function () {
245247
},
246248
);
247249
},
250+
'{}',
248251
),
249252
'clone of a token' => array(
250253
static function () {
251254
return array( 'html' => clone wp_interactivity_as_dangerous_html( self::MARKER_HTML ) );
252255
},
256+
'{"wpServerHtml":true}',
253257
),
254258
'token created with new' => array(
255259
static function () {
256260
return array( 'html' => new WP_Interactivity_Dangerous_HTML( self::MARKER_HTML ) );
257261
},
262+
'{"wpServerHtml":true}',
258263
),
259264
);
260265
}
@@ -394,6 +399,7 @@ public static function data_values_that_are_not_tokens(): array {
394399
'json_decode object' => array( json_decode( '{"html":"<p>Hi</p>"}' ) ),
395400
'other object' => array( new ArrayObject( array( '<p>Hi</p>' ) ) ),
396401
'json_decode of a token' => array( json_decode( wp_json_encode( new WP_Interactivity_Dangerous_HTML( '<p>Hi</p>' ) ) ) ),
402+
'marker decoded as an array' => array( json_decode( '{"wpServerHtml":true}', true ) ),
397403
'closure returning a string' => array(
398404
static function () {
399405
return '<p>Hi</p>';
@@ -729,11 +735,11 @@ public function test_unbalanced_markup_returns_input_unchanged() {
729735
}
730736

731737
/**
732-
* Tests that a token restored by unserialization does not render but is
733-
* listed like any other token: the host keeps its fallback, exactly one
734-
* notice is raised for the unserialization and one for the decline, the
735-
* markup after the host is processed, and the token prints as an empty
736-
* object. The token that was serialized still renders.
738+
* Tests that a token restored by unserialization does not render but prints
739+
* as the marker like any other token: the host keeps its fallback, exactly
740+
* one notice is raised for the unserialization and one for the decline, and
741+
* the markup after the host is processed. The token that was serialized
742+
* still renders.
737743
*
738744
* @ticket 66246
739745
*
@@ -772,18 +778,18 @@ public function test_unserialized_token_declines_as_not_a_token() {
772778
);
773779
$this->assertCount( 1, $this->html_notice_messages(), 'Expected exactly one data-wp-html notice.' );
774780
$this->assertCount( 2, $this->notices, 'Expected no notice besides those of the unserialization and the decline.' );
775-
$this->assertSame( array( 'test' => array( 'state.html', 'state.original' ) ), $this->announced_paths() );
776781
$this->assertEquals(
777-
(object) array(),
782+
(object) array( 'wpServerHtml' => true ),
778783
json_decode( $this->printed_client_data() )->state->test->html,
779-
'Expected the token to print as an empty object.'
784+
'Expected the token to print as the marker.'
780785
);
781786
}
782787

783788
/**
784-
* Tests that a token in `wp_interactivity_data_wp_context()` reaches neither
785-
* the output nor the client data, and that referencing it from other
786-
* directives neither renders it nor aborts processing.
789+
* Tests that a token in `wp_interactivity_data_wp_context()` prints as the
790+
* marker, so its HTML reaches neither the output nor the client data, and
791+
* that referencing it from other directives neither renders it nor aborts
792+
* processing.
787793
*
788794
* @ticket 66246
789795
*
@@ -809,30 +815,25 @@ public function test_token_in_context_helper_reaches_no_output_or_client_data()
809815
$output,
810816
'Expected the context to hold no HTML to render, and the markup after it to be processed.'
811817
);
812-
$this->assertStringContainsString( '{"h":{}}', $output, 'Expected the token to print as an empty object in the context attribute.' );
818+
$this->assertStringContainsString( '{"h":{"wpServerHtml":true}}', $output, 'Expected the token to print as the marker in the context attribute.' );
813819
$this->assertStringNotContainsString( 'marker-7f3a', $output );
814820
$this->assertStringNotContainsString( 'marker-7f3a', $this->printed_client_data() );
815821
$this->assertCount( 1, $this->html_notice_messages(), 'Expected the value read from context to decline as not a token.' );
816822
}
817823

818824
/**
819-
* Tests that every host renders its token and that a token reached through
820-
* state is listed with the full path of the reference, under the namespace
821-
* the reference resolves in, once per path and namespace. A path is listed
822-
* without a leading `!`, also when only a directive other than
823-
* `data-wp-html` references it.
825+
* Tests that every host renders the token its reference reaches through
826+
* state, at any depth and in any namespace.
824827
*
825828
* @ticket 66246
826829
*
827830
* @covers ::process_directives
828-
* @covers ::filter_script_module_interactivity_data
829831
*/
830-
public function test_token_paths_are_listed_in_full_per_namespace() {
832+
public function test_tokens_render_at_any_depth_and_namespace() {
831833
$this->set_state(
832834
array(
833-
'html' => $this->token(),
834-
'deep' => array( 'er' => array( 'html' => $this->token() ) ),
835-
'negated' => $this->token(),
835+
'html' => $this->token(),
836+
'deep' => array( 'er' => array( 'html' => $this->token() ) ),
836837
)
837838
);
838839
wp_interactivity_state(
@@ -849,18 +850,9 @@ public function test_token_paths_are_listed_in_full_per_namespace() {
849850
$hosts .= '<div data-wp-html="' . $reference . '">Fallback</div>';
850851
$rendered .= '<div data-wp-html="' . $reference . '">' . self::MARKER_HTML . '</div>';
851852
}
852-
$negated = '<div data-wp-class--off="!state.negated">x</div>';
853+
$output = wp_interactivity_process_directives( $this->page( $hosts ) );
853854

854-
$output = wp_interactivity_process_directives( $this->page( $hosts . $negated ) );
855-
856-
$this->assertSame( $this->processed_page( $rendered . $negated ), $output, 'Expected every host to render its token.' );
857-
$this->assertSame(
858-
array(
859-
'test' => array( 'state.deep.er.html', 'state.html', 'state.negated' ),
860-
'other' => array( 'state.html', 'state.deep.er.html' ),
861-
),
862-
$this->announced_paths()
863-
);
855+
$this->assertSame( $this->processed_page( $rendered ), $output, 'Expected every host to render its token.' );
864856
}
865857

866858
/**
@@ -893,15 +885,15 @@ public function test_token_in_a_closure_result_lists_only_the_closure() {
893885

894886
/**
895887
* Tests that tokens reached through `context`, such as the items of
896-
* `data-wp-each`, render without a notice and are not listed, although
897-
* they are held in state.
888+
* `data-wp-each`, render without a notice, add no listed path, and print
889+
* as the marker in the state that holds them.
898890
*
899891
* @ticket 66246
900892
*
901893
* @covers ::process_directives
902894
* @covers ::filter_script_module_interactivity_data
903895
*/
904-
public function test_tokens_reached_through_context_are_not_listed() {
896+
public function test_tokens_reached_through_context_render_and_print_as_the_marker() {
905897
$this->set_state(
906898
array(
907899
'items' => array(
@@ -918,6 +910,40 @@ public function test_tokens_reached_through_context_are_not_listed() {
918910
$this->assertSame( 2, substr_count( $output, '<li data-wp-each-child="test::state.items" data-wp-html="context.item.html">' . self::MARKER_HTML . '</li>' ) );
919911
$this->assertSame( array(), $this->notices, 'Expected no notice.' );
920912
$this->assertSame( array(), $this->announced_paths(), 'Expected no listed path.' );
913+
$this->assertStringContainsString(
914+
'"items":[{"html":{"wpServerHtml":true}},{"html":{"wpServerHtml":true}}]',
915+
$this->printed_client_data(),
916+
'Expected each item token to print as the marker.'
917+
);
918+
}
919+
920+
/**
921+
* Tests that a token prints as the marker on both encoding paths of
922+
* `wp_json_encode()`, including the one taken when the data holds a string
923+
* that is not valid UTF-8, and that iterating a token yields only the
924+
* marker.
925+
*
926+
* @ticket 66246
927+
*
928+
* @covers WP_Interactivity_Dangerous_HTML::jsonSerialize
929+
* @covers WP_Interactivity_Dangerous_HTML::getIterator
930+
*/
931+
public function test_token_prints_as_the_marker_on_both_encoding_paths() {
932+
$token = $this->token();
933+
934+
$this->assertSame( '{"wpServerHtml":true}', wp_json_encode( $token ) );
935+
936+
$data = array(
937+
'bad' => "caf\xE9",
938+
'token' => $token,
939+
);
940+
$this->assertFalse( json_encode( $data ), 'Expected the data to take the fallback path.' );
941+
$fallback = wp_json_encode( $data );
942+
$this->assertIsString( $fallback );
943+
$this->assertStringContainsString( '"token":{"wpServerHtml":true}', $fallback, 'Expected the fallback path to print the marker.' );
944+
$this->assertStringNotContainsString( 'marker-7f3a', $fallback );
945+
946+
$this->assertSame( array( 'wpServerHtml' => true ), iterator_to_array( $token ) );
921947
}
922948

923949
/**

0 commit comments

Comments
 (0)