diff --git a/includes/blocks.php b/includes/blocks.php index 5a64c415..db245170 100644 --- a/includes/blocks.php +++ b/includes/blocks.php @@ -899,6 +899,10 @@ function acf_enqueue_block_assets() { ) ); + // React StrictMode is only active in the block editor on WP 6.9+. + global $wp_version; + $is_wp_69_plus = version_compare( $wp_version, '6.9', '>=' ); + // Get block types. $block_types = array_map( function ( $block ) { @@ -914,6 +918,9 @@ function ( $block ) { array( 'blockTypes' => array_values( $block_types ), 'postType' => get_post_type(), + // Key is intentionally PascalCase to match `acf.get('StrictMode')` in the + // compiled `acf-pro-blocks.js`. Do not normalize to camelCase. + 'StrictMode' => $is_wp_69_plus, ) ); @@ -985,9 +992,39 @@ function acf_enqueue_block_type_assets( $block_type ) { } } +/** + * Decodes the block `query` ajax arg, which may arrive as either an array + * or a JSON-encoded string depending on how the block editor JS serializes + * the request. Falls back to an empty array when decoding fails or yields + * a non-array value. + * + * @since SCF 6.5.4 + * + * @param mixed $query The raw query arg. + * @return array + */ +function acf_decode_block_query_arg( $query ) { + if ( is_array( $query ) ) { + return $query; + } + + if ( ! is_string( $query ) ) { + return array(); + } + + $decoded = json_decode( wp_unslash( $query ), true ); + + return is_array( $decoded ) ? $decoded : array(); +} + /** * Handles the ajax request for block data. * + * The `query` arg may arrive as a JSON-encoded string instead of an array + * (the block editor JS may serialize it that way). It is normalized via + * `acf_decode_block_query_arg()` to avoid a fatal TypeError on PHP 8.4 + * when accessing offsets on a string. + * * @since ACF 5.7.13 * * @return void @@ -1031,6 +1068,12 @@ function acf_ajax_fetch_block() { $raw_context = $args['context']; $post_id = $args['post_id']; + // `$query` flows through `acf_sanitize_request_args()` (so a JSON string + // is `wp_kses`-filtered before we see it). Safe today because callers only + // boolean-test keys like `preview`/`form`/`validate`; revisit if a string + // value ever gets read out of `$query`. + $query = acf_decode_block_query_arg( $query ); + // Bail early if no block. if ( ! $block ) { wp_send_json_error(); diff --git a/tests/php/includes/test-blocks-query-decoding.php b/tests/php/includes/test-blocks-query-decoding.php new file mode 100644 index 00000000..515d87f4 --- /dev/null +++ b/tests/php/includes/test-blocks-query-decoding.php @@ -0,0 +1,135 @@ +assertIsArray( $result ); + $this->assertSame( 'post', $result['post_type'] ); + $this->assertSame( 3, $result['posts_per_page'] ); + } + + /** + * A slashed JSON string (as it would arrive in $_REQUEST) is decoded. + */ + public function test_slashed_json_string_is_decoded() { + $result = acf_decode_block_query_arg( wp_slash( '{"preview":true,"form":false}' ) ); + + $this->assertIsArray( $result ); + $this->assertTrue( $result['preview'] ); + $this->assertFalse( $result['form'] ); + } + + /** + * A nested JSON object matching real query keys decodes correctly. + */ + public function test_nested_json_object_decodes() { + $json = '{"preview":true,"form":true,"validate":false,"meta":{"foo":"bar"}}'; + $result = acf_decode_block_query_arg( $json ); + + $this->assertIsArray( $result ); + $this->assertTrue( $result['preview'] ); + $this->assertSame( 'bar', $result['meta']['foo'] ); + } + + /** + * An array is returned unchanged. + */ + public function test_array_is_returned_unchanged() { + $query = array( + 'post_type' => 'page', + 'posts_per_page' => 5, + ); + + $result = acf_decode_block_query_arg( $query ); + + $this->assertSame( $query, $result ); + } + + /** + * Invalid JSON falls back to an empty array. + */ + public function test_invalid_json_falls_back_to_empty_array() { + $this->assertSame( array(), acf_decode_block_query_arg( 'not valid json {{{' ) ); + } + + /** + * An empty string falls back to an empty array. + */ + public function test_empty_string_falls_back_to_empty_array() { + $this->assertSame( array(), acf_decode_block_query_arg( '' ) ); + } + + /** + * A JSON string encoding a scalar (not an array/object) falls back to an empty array. + * + * @dataProvider provide_json_scalars + * + * @param string $json A JSON-encoded scalar. + */ + public function test_json_scalar_falls_back_to_empty_array( $json ) { + $this->assertSame( array(), acf_decode_block_query_arg( $json ) ); + } + + /** + * Data provider for JSON scalars. + * + * @return array + */ + public function provide_json_scalars() { + return array( + 'string' => array( '"just a string"' ), + 'true' => array( 'true' ), + 'false' => array( 'false' ), + 'null' => array( 'null' ), + 'number' => array( '42' ), + ); + } + + /** + * Non-string, non-array values fall back to an empty array. + * + * @dataProvider provide_non_string_non_array + * + * @param mixed $value Input value. + */ + public function test_non_string_non_array_falls_back_to_empty_array( $value ) { + $this->assertSame( array(), acf_decode_block_query_arg( $value ) ); + } + + /** + * Data provider for non-string, non-array inputs. + * + * @return array + */ + public function provide_non_string_non_array() { + return array( + 'null' => array( null ), + 'integer' => array( 42 ), + 'bool' => array( true ), + 'object' => array( new stdClass() ), + ); + } +}