From 84156f70097e810953b2f2e7b88724b2a51dd531 Mon Sep 17 00:00:00 2001 From: Adrian Morales Rubio Date: Fri, 20 Mar 2026 14:48:40 +0100 Subject: [PATCH 1/4] Fix block editor rendering: query decoding and StrictMode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for block rendering in the Gutenberg editor: 1. Decode $query when sent as JSON string in acf_ajax_fetch_block(). The block editor JS may serialize the query parameter as a JSON string, but the PHP code assumes it is already an array. On PHP 8.4 this causes a fatal TypeError: "Cannot access offset of type string on string". 2. Enable StrictMode flag in localized block editor data. The block JS already has code to handle React 18 StrictMode (mount → unmount → remount), gated behind acf.get('StrictMode'), but the flag was never set. Without it, blocks inserted in the editor show an infinite spinner because setState is skipped when the component is remounted after StrictMode cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) --- includes/blocks.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/includes/blocks.php b/includes/blocks.php index 5a64c415..9c2e50cc 100644 --- a/includes/blocks.php +++ b/includes/blocks.php @@ -914,6 +914,7 @@ function ( $block ) { array( 'blockTypes' => array_values( $block_types ), 'postType' => get_post_type(), + 'StrictMode' => true, ) ); @@ -1027,6 +1028,17 @@ function acf_ajax_fetch_block() { $block = $args['block']; $query = $args['query']; + + // Decode query if sent as a JSON string instead of an array. + // The block editor JS may serialize the query parameter as JSON, + // which causes a fatal TypeError on PHP 8.4 when accessing offsets. + if ( is_string( $query ) ) { + $query = json_decode( wp_unslash( $query ), true ); + if ( ! is_array( $query ) ) { + $query = array(); + } + } + $client_id = $args['clientId']; $raw_context = $args['context']; $post_id = $args['post_id']; From 155efebee2ce2e397e41f9cacc378eb652877c0d Mon Sep 17 00:00:00 2001 From: Adrian Morales Rubio Date: Fri, 20 Mar 2026 15:17:21 +0100 Subject: [PATCH 2/4] Move query decode block after aligned assignments to fix phpcs Co-Authored-By: Claude Opus 4.6 (1M context) --- includes/blocks.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/includes/blocks.php b/includes/blocks.php index 9c2e50cc..6f8ad035 100644 --- a/includes/blocks.php +++ b/includes/blocks.php @@ -1028,6 +1028,9 @@ function acf_ajax_fetch_block() { $block = $args['block']; $query = $args['query']; + $client_id = $args['clientId']; + $raw_context = $args['context']; + $post_id = $args['post_id']; // Decode query if sent as a JSON string instead of an array. // The block editor JS may serialize the query parameter as JSON, @@ -1039,10 +1042,6 @@ function acf_ajax_fetch_block() { } } - $client_id = $args['clientId']; - $raw_context = $args['context']; - $post_id = $args['post_id']; - // Bail early if no block. if ( ! $block ) { wp_send_json_error(); From 7555b72462d658aadc7cbf9ec4a1b8a0de36fe93 Mon Sep 17 00:00:00 2001 From: Adrian Morales Rubio Date: Thu, 2 Apr 2026 16:02:05 +0200 Subject: [PATCH 3/4] Address PR review: gate StrictMode on WP 6.9+ and add query decoding tests Gate StrictMode on WP 6.9+ using version_compare so older WP versions keep existing behavior. Add PHPUnit tests covering JSON string query decoding, invalid JSON fallback, and array passthrough. Co-Authored-By: Claude Opus 4.6 (1M context) --- includes/blocks.php | 2 +- .../includes/test-blocks-query-decoding.php | 111 ++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/php/includes/test-blocks-query-decoding.php diff --git a/includes/blocks.php b/includes/blocks.php index 6f8ad035..e52c38f7 100644 --- a/includes/blocks.php +++ b/includes/blocks.php @@ -914,7 +914,7 @@ function ( $block ) { array( 'blockTypes' => array_values( $block_types ), 'postType' => get_post_type(), - 'StrictMode' => true, + 'StrictMode' => version_compare( $GLOBALS['wp_version'], '6.9', '>=' ), ) ); 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..3ce44ad3 --- /dev/null +++ b/tests/php/includes/test-blocks-query-decoding.php @@ -0,0 +1,111 @@ +assertIsArray( $query ); + $this->assertSame( 'post', $query['post_type'] ); + $this->assertSame( 3, $query['posts_per_page'] ); + } + + /** + * Test that an invalid JSON string falls back to an empty array. + */ + public function test_invalid_json_string_falls_back_to_empty_array() { + $query = 'not valid json {{{'; + + if ( is_string( $query ) ) { + $query = json_decode( wp_unslash( $query ), true ); + if ( ! is_array( $query ) ) { + $query = array(); + } + } + + $this->assertIsArray( $query ); + $this->assertEmpty( $query ); + } + + /** + * Test that an array query is left unchanged. + */ + public function test_array_query_is_unchanged() { + $query = array( + 'post_type' => 'page', + 'posts_per_page' => 5, + ); + + if ( is_string( $query ) ) { + $query = json_decode( wp_unslash( $query ), true ); + if ( ! is_array( $query ) ) { + $query = array(); + } + } + + $this->assertIsArray( $query ); + $this->assertSame( 'page', $query['post_type'] ); + $this->assertSame( 5, $query['posts_per_page'] ); + } + + /** + * Test that a JSON string encoding a non-array value falls back to empty array. + */ + public function test_json_non_array_value_falls_back_to_empty_array() { + $query = '"just a string"'; + + if ( is_string( $query ) ) { + $query = json_decode( wp_unslash( $query ), true ); + if ( ! is_array( $query ) ) { + $query = array(); + } + } + + $this->assertIsArray( $query ); + $this->assertEmpty( $query ); + } + + /** + * Test that an empty string falls back to an empty array. + */ + public function test_empty_string_falls_back_to_empty_array() { + $query = ''; + + if ( is_string( $query ) ) { + $query = json_decode( wp_unslash( $query ), true ); + if ( ! is_array( $query ) ) { + $query = array(); + } + } + + $this->assertIsArray( $query ); + $this->assertEmpty( $query ); + } +} From 6e0372446803ef2759805ab2a28396827e691f07 Mon Sep 17 00:00:00 2001 From: Adrian Morales Rubio Date: Mon, 4 May 2026 12:11:09 +0200 Subject: [PATCH 4/4] Address PR #406 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract query decoding into acf_decode_block_query_arg() so tests exercise the production code path instead of duplicating it inline. - Use `global $wp_version;` to match the codebase convention (includes/assets.php, includes/class-acf-site-health.php). - Add inline comment explaining StrictMode key is intentionally PascalCase to match acf.get('StrictMode') in the compiled acf-pro-blocks.js. - Add comment noting $query passes through acf_sanitize_request_args() (wp_kses) — safe today since callers only boolean-test keys. - Update acf_ajax_fetch_block() docblock to mention the JSON-string case. - Expand tests to cover JSON scalars (true/false/null/number/string), nested objects, slashed input, and non-string/non-array values. Co-Authored-By: Claude Opus 4.7 (1M context) --- includes/blocks.php | 52 ++++-- .../includes/test-blocks-query-decoding.php | 156 ++++++++++-------- 2 files changed, 132 insertions(+), 76 deletions(-) diff --git a/includes/blocks.php b/includes/blocks.php index e52c38f7..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,7 +918,9 @@ function ( $block ) { array( 'blockTypes' => array_values( $block_types ), 'postType' => get_post_type(), - 'StrictMode' => version_compare( $GLOBALS['wp_version'], '6.9', '>=' ), + // 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, ) ); @@ -986,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 @@ -1032,15 +1068,11 @@ function acf_ajax_fetch_block() { $raw_context = $args['context']; $post_id = $args['post_id']; - // Decode query if sent as a JSON string instead of an array. - // The block editor JS may serialize the query parameter as JSON, - // which causes a fatal TypeError on PHP 8.4 when accessing offsets. - if ( is_string( $query ) ) { - $query = json_decode( wp_unslash( $query ), true ); - if ( ! is_array( $query ) ) { - $query = array(); - } - } + // `$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 ) { diff --git a/tests/php/includes/test-blocks-query-decoding.php b/tests/php/includes/test-blocks-query-decoding.php index 3ce44ad3..515d87f4 100644 --- a/tests/php/includes/test-blocks-query-decoding.php +++ b/tests/php/includes/test-blocks-query-decoding.php @@ -1,6 +1,6 @@ assertIsArray( $query ); - $this->assertSame( 'post', $query['post_type'] ); - $this->assertSame( 3, $query['posts_per_page'] ); + 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'] ); } /** - * Test that an invalid JSON string falls back to an empty array. + * A nested JSON object matching real query keys decodes correctly. */ - public function test_invalid_json_string_falls_back_to_empty_array() { - $query = 'not valid json {{{'; - - if ( is_string( $query ) ) { - $query = json_decode( wp_unslash( $query ), true ); - if ( ! is_array( $query ) ) { - $query = array(); - } - } - - $this->assertIsArray( $query ); - $this->assertEmpty( $query ); + 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'] ); } /** - * Test that an array query is left unchanged. + * An array is returned unchanged. */ - public function test_array_query_is_unchanged() { + public function test_array_is_returned_unchanged() { $query = array( 'post_type' => 'page', 'posts_per_page' => 5, ); - if ( is_string( $query ) ) { - $query = json_decode( wp_unslash( $query ), true ); - if ( ! is_array( $query ) ) { - $query = array(); - } - } + $result = acf_decode_block_query_arg( $query ); - $this->assertIsArray( $query ); - $this->assertSame( 'page', $query['post_type'] ); - $this->assertSame( 5, $query['posts_per_page'] ); + $this->assertSame( $query, $result ); } /** - * Test that a JSON string encoding a non-array value falls back to empty array. + * Invalid JSON falls back to an empty array. */ - public function test_json_non_array_value_falls_back_to_empty_array() { - $query = '"just a string"'; - - if ( is_string( $query ) ) { - $query = json_decode( wp_unslash( $query ), true ); - if ( ! is_array( $query ) ) { - $query = array(); - } - } - - $this->assertIsArray( $query ); - $this->assertEmpty( $query ); + public function test_invalid_json_falls_back_to_empty_array() { + $this->assertSame( array(), acf_decode_block_query_arg( 'not valid json {{{' ) ); } /** - * Test that an empty string falls back to an empty array. + * An empty string falls back to an empty array. */ public function test_empty_string_falls_back_to_empty_array() { - $query = ''; + $this->assertSame( array(), acf_decode_block_query_arg( '' ) ); + } - if ( is_string( $query ) ) { - $query = json_decode( wp_unslash( $query ), true ); - if ( ! is_array( $query ) ) { - $query = array(); - } - } + /** + * 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 ) ); + } - $this->assertIsArray( $query ); - $this->assertEmpty( $query ); + /** + * 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() ), + ); } }