Skip to content
Open
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
43 changes: 43 additions & 0 deletions includes/blocks.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand All @@ -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,
)
);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
135 changes: 135 additions & 0 deletions tests/php/includes/test-blocks-query-decoding.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php
/**
* Tests for acf_decode_block_query_arg().
*
* @package wordpress/secure-custom-fields
*/

use WorDBless\BaseTestCase;

/**
* Class Test_Blocks_Query_Decoding
*
* Verifies acf_decode_block_query_arg() — the helper used by
* acf_ajax_fetch_block() to normalize the `query` ajax arg, which
* may arrive as either an array or a JSON-encoded string. Without
* this normalization PHP 8.4 raises a fatal TypeError when offsets
* are accessed on a string.
*
* @group blocks
*/
class Test_Blocks_Query_Decoding extends BaseTestCase {

/**
* A JSON object string is decoded to an array.
*/
public function test_json_object_string_is_decoded_to_array() {
$result = acf_decode_block_query_arg( '{"post_type":"post","posts_per_page":3}' );

$this->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() ),
);
}
}
Loading