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
1 change: 1 addition & 0 deletions docs/checks.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@
| enqueued_styles_scope | performance | Checks whether any stylesheets are loaded on all pages, which is usually not desirable and can lead to performance issues. | [Learn more](https://developer.wordpress.org/plugins/) |
| enqueued_scripts_scope | performance | Checks whether any scripts are loaded on all pages, which is usually not desirable and can lead to performance issues. | [Learn more](https://developer.wordpress.org/plugins/) |
| non_blocking_scripts | performance | Checks whether scripts and styles are enqueued using a recommended loading strategy. | [Learn more](https://developer.wordpress.org/plugins/) |
| inlined_react_runtime | performance | Detects a bundled, outdated React runtime that is incompatible with React 19. | [Learn more](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dependency-extraction-webpack-plugin/) |
237 changes: 237 additions & 0 deletions includes/Checker/Checks/Performance/Inlined_React_Runtime_Check.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
<?php
/**
* Class Inlined_React_Runtime_Check.
*
* @package plugin-check
*/

namespace WordPress\Plugin_Check\Checker\Checks\Performance;

use Exception;
use WordPress\Plugin_Check\Checker\Check_Categories;
use WordPress\Plugin_Check\Checker\Check_Result;
use WordPress\Plugin_Check\Checker\Checks\Abstract_File_Check;
use WordPress\Plugin_Check\Traits\Amend_Check_Result;
use WordPress\Plugin_Check\Traits\Stable_Check;

/**
* Check to detect a bundled, outdated React runtime that breaks under React 19.
*
* WordPress is moving from React 18 to React 19. Testing has shown that the vast
* majority of plugin breakages come from a single cause: the plugin inlines the
* `react/jsx-runtime` library into its build output instead of externalizing it
* (i.e. relying on the copy shipped with WordPress). The element object shape
* changed between React 18 and 19, so elements produced by an inlined pre-19
* runtime are rejected by the React 19 bundled with WordPress.
*
* Detection is based on the high confidence signal `Symbol.for( 'react.element' )`,
* which only appears when a pre-19 JSX runtime is inlined. React 19 uses a
* different marker (`react.transitional.element`), so this does not match builds
* that already externalize the runtime. As an additional signal, usages of React
* APIs that were removed in React 19 are reported as well.
*
* @since 2.0.0
*/
class Inlined_React_Runtime_Check extends Abstract_File_Check {

use Amend_Check_Result;
use Stable_Check;

/**
* Gets the categories for the check.
*
* Every check must have at least one category.
*
* @since 2.0.0
*
* @return array The categories for the check.
*/
public function get_categories() {
return array( Check_Categories::CATEGORY_PERFORMANCE );
}

/**
* Amends the given result by running the check on the given list of files.
*
* @since 2.0.0
*
* @param Check_Result $result The check result to amend, including the plugin context to check.
* @param array $files List of absolute file paths.
*
* @throws Exception Thrown when the check fails with a critical error (unrelated to any errors detected as part of
* the check).
*/
protected function check_files( Check_Result $result, array $files ) {
$js_files = self::filter_files_by_extension( $files, 'js' );

foreach ( $js_files as $file ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$contents = file_get_contents( $file );
if ( false === $contents ) {
continue;
}

$this->look_for_inlined_jsx_runtime( $result, $file, $contents );
$this->look_for_removed_react_apis( $result, $file, $contents );
}
}

/**
* Reports an inlined pre-19 JSX runtime, unless the runtime is externalized.
*
* @since 2.0.0
*
* @param Check_Result $result The check result to amend.
* @param string $file Absolute path to the JavaScript file.
* @param string $contents Contents of the JavaScript file.
*/
private function look_for_inlined_jsx_runtime( Check_Result $result, $file, $contents ) {
// `Symbol.for( 'react.element' )` is only emitted by an inlined pre-19 JSX runtime.
$position = $this->find_first_match( '/Symbol\.for\(\s*[\'"]react\.element[\'"]\s*\)/', $contents );

if ( false === $position ) {
return;
}

// Do not warn when the runtime is externalized to the copy shipped with WordPress.
if ( $this->is_jsx_runtime_externalized( $file, $contents ) ) {
return;
}

$this->add_result_warning_for_file(
$result,
__( 'This file appears to inline the React JSX runtime instead of externalizing it. Bundled pre-React 19 runtimes break when WordPress upgrades to React 19. Use the dependency extraction webpack plugin so that "react-jsx-runtime" is loaded from WordPress instead.', 'plugin-check' ),
'inlined_jsx_runtime',
$file,
$position['line'],
$position['column'],
'https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dependency-extraction-webpack-plugin/',
6
);
}

/**
* Reports usage of React APIs that were removed in React 19.
*
* @since 2.0.0
*
* @param Check_Result $result The check result to amend.
* @param string $file Absolute path to the JavaScript file.
* @param string $contents Contents of the JavaScript file.
*/
private function look_for_removed_react_apis( Check_Result $result, $file, $contents ) {
// These identifiers are React-specific and were removed in React 19.
$matched = '';
$position = $this->find_first_match( '/\b(?:unmountComponentAtNode|findDOMNode|ReactCurrentOwner)\b/', $contents, $matched );

if ( false === $position ) {
return;
}

$this->add_result_warning_for_file(
$result,
sprintf(
/* translators: %s: the removed React API name */
__( 'This file references "%s", a React API that was removed in React 19 and will stop working once WordPress upgrades React. Update the bundled code to a React 19 compatible version.', 'plugin-check' ),
$matched
),
'react_removed_api',
$file,
$position['line'],
$position['column'],
'https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dependency-extraction-webpack-plugin/',
5
);
}

/**
* Determines whether the JSX runtime is externalized rather than inlined.
*
* A build that externalizes the runtime references the global
* `window.ReactJSXRuntime`, or declares `react-jsx-runtime` as a dependency in
* its sibling `*.asset.php` file generated by the dependency extraction plugin.
*
* @since 2.0.0
*
* @param string $file Absolute path to the JavaScript file.
* @param string $contents Contents of the JavaScript file.
* @return bool True if the runtime is externalized, false otherwise.
*/
private function is_jsx_runtime_externalized( $file, $contents ) {
if ( str_contains( $contents, 'window.ReactJSXRuntime' ) ) {
return true;
}

$asset_file = preg_replace( '/\.js$/', '.asset.php', $file );

if ( is_string( $asset_file ) && $asset_file !== $file && file_exists( $asset_file ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$asset_contents = file_get_contents( $asset_file );
if ( false !== $asset_contents && str_contains( $asset_contents, 'react-jsx-runtime' ) ) {
return true;
}
}

return false;
}

/**
* Finds the first occurrence of a pattern and returns its line and column.
*
* @since 2.0.0
*
* @param string $pattern The regular expression pattern to search for.
* @param string $contents The contents to search.
* @param string|null $matched Optional. Populated with the matched text, passed by reference.
* @return array|false Array with `line` and `column` keys, or false if no match was found.
*/
private function find_first_match( $pattern, $contents, &$matched = null ) {
if ( ! preg_match( $pattern, $contents, $matches, PREG_OFFSET_CAPTURE ) ) {
return false;
}

$matched = $matches[0][0];
$offset = $matches[0][1];

if ( 0 === $offset ) {
return array(
'line' => 1,
'column' => 1,
);
}

$before = substr( $contents, 0, $offset );
$exploded = explode( PHP_EOL, $before );

return array(
'line' => count( $exploded ),
'column' => strlen( (string) end( $exploded ) ) + 1,
);
}

/**
* Gets the description for the check.
*
* Every check must have a short description explaining what the check does.
*
* @since 2.0.0
*
* @return string Description.
*/
public function get_description(): string {
return __( 'Detects a bundled, outdated React runtime that is incompatible with React 19.', 'plugin-check' );
}

/**
* Gets the documentation URL for the check.
*
* Every check must have a URL with further information about the check.
*
* @since 2.0.0
*
* @return string The documentation URL.
*/
public function get_documentation_url(): string {
return __( 'https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dependency-extraction-webpack-plugin/', 'plugin-check' );
}
}
1 change: 1 addition & 0 deletions includes/Checker/Default_Check_Repository.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ private function register_default_checks() {
'direct_file_access' => new Checks\Plugin_Repo\Direct_File_Access_Check(),
'external_admin_menu_links' => new Checks\Plugin_Repo\External_Admin_Menu_Links_Check(),
'wp_functions_compatibility' => new Checks\Plugin_Repo\WP_Functions_Compatibility_Check(),
'inlined_react_runtime' => new Checks\Performance\Inlined_React_Runtime_Check(),
)
);

Expand Down
1 change: 1 addition & 0 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ In any case, passing the checks in this tool likely helps to achieve a smooth pl
= 2.0.0 =

* Enhancement - Add WordPress functions compatibility check to flag usage of functions unavailable in a plugin's declared minimum WordPress version.
* Enhancement - Add Inlined React Runtime check to detect a bundled, outdated React runtime that breaks under React 19.
* Enhancement - Add Write File check to detect plugins saving data in the plugin folder instead of the uploads directory or database.
* Enhancement - Add batched AI false positive detection with check-specific prompts and AI model selection for WP-CLI.
* Enhancement - Add CTRF export support for check results.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Simulated build output that inlines a pre-React 19 JSX runtime.
( function () {
var element = Symbol.for( "react.element" );
return element;
}() );
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Simulated build output that calls a React API removed in React 19.
( function () {
ReactDOM.unmountComponentAtNode( document.getElementById( 'root' ) );
}() );
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php
/**
* Plugin Name: Test Plugin Inlined React Runtime With Errors
* Plugin URI: https://github.com/WordPress/plugin-check
* Description: Some plugin description.
* Requires at least: 6.0
* Requires PHP: 7.4
* Version: 1.0.0
* Author: WordPress Performance Team
* Author URI: https://make.wordpress.org/performance/
* License: GPLv2 or later
* License URI: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
* Text Domain: test-plugin-inlined-react-runtime-with-errors
*
* @package test-plugin-inlined-react-runtime-with-errors
*/
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<?php return array('dependencies' => array('react', 'react-jsx-runtime', 'wp-element'), 'version' => 'abc123');
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Build output whose runtime is externalized via the sibling index.asset.php.
( function () {
var element = Symbol.for( "react.element" );
return element;
}() );
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php
/**
* Plugin Name: Test Plugin Inlined React Runtime Without Errors
* Plugin URI: https://github.com/WordPress/plugin-check
* Description: Some plugin description.
* Requires at least: 6.0
* Requires PHP: 7.4
* Version: 1.0.0
* Author: WordPress Performance Team
* Author URI: https://make.wordpress.org/performance/
* License: GPLv2 or later
* License URI: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
* Text Domain: test-plugin-inlined-react-runtime-without-errors
*
* @package test-plugin-inlined-react-runtime-without-errors
*/
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Build output whose runtime is externalized via window.ReactJSXRuntime.
( function () {
var jsx = window.ReactJSXRuntime;
var element = Symbol.for( "react.element" );
return jsx ? element : null;
}() );
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php
/**
* Tests for the Inlined_React_Runtime_Check class.
*
* @package plugin-check
*/

use WordPress\Plugin_Check\Checker\Check_Context;
use WordPress\Plugin_Check\Checker\Check_Result;
use WordPress\Plugin_Check\Checker\Checks\Performance\Inlined_React_Runtime_Check;

class Inlined_React_Runtime_Check_Tests extends WP_UnitTestCase {

public function test_run_with_errors() {
$check = new Inlined_React_Runtime_Check();
$check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-inlined-react-runtime-with-errors/load.php' );
$check_result = new Check_Result( $check_context );

$check->run( $check_result );

$warnings = $check_result->get_warnings();

$this->assertNotEmpty( $warnings );
$this->assertEmpty( $check_result->get_errors() );
$this->assertSame( 2, $check_result->get_warning_count() );

$this->assertArrayHasKey( 'index.js', $warnings );
$this->assertArrayHasKey( 'legacy.js', $warnings );

$this->assertSame( 'inlined_jsx_runtime', $this->get_first_code( $warnings['index.js'] ) );
$this->assertSame( 'react_removed_api', $this->get_first_code( $warnings['legacy.js'] ) );
}

public function test_run_without_errors() {
$check = new Inlined_React_Runtime_Check();
$check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . 'test-plugin-inlined-react-runtime-without-errors/load.php' );
$check_result = new Check_Result( $check_context );

$check->run( $check_result );

$this->assertEmpty( $check_result->get_errors() );
$this->assertEmpty( $check_result->get_warnings() );
$this->assertSame( 0, $check_result->get_error_count() );
$this->assertSame( 0, $check_result->get_warning_count() );
}

/**
* Returns the message code of the first warning reported for a file.
*
* @param array $file_warnings Warnings for a single file, keyed by line and column.
* @return string|null The message code, or null if none was found.
*/
private function get_first_code( array $file_warnings ) {
foreach ( $file_warnings as $columns ) {
foreach ( $columns as $messages ) {
if ( isset( $messages[0]['code'] ) ) {
return $messages[0]['code'];
}
}
}

return null;
}
}
Loading