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 @@ -37,3 +37,4 @@
| 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/) |
| ai_provider | general | Recommends the WordPress AI Client when a plugin integrates directly with a third-party AI provider. | [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/) |
259 changes: 259 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,259 @@
<?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( $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 ) {
// Blank out comments and string literals first, so a mention in a code
// comment, changelog entry, or translation string is not reported as usage.
$scannable = $this->blank_comments_and_strings( $contents );

// These identifiers are React-specific and were removed in React 19.
$matched = '';
$position = $this->find_first_match( '/\b(?:unmountComponentAtNode|findDOMNode|ReactCurrentOwner)\b/', $scannable, $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 file references the externalized JSX runtime.
*
* A build that externalizes the runtime references the global
* `window.ReactJSXRuntime` shipped with WordPress. A `react-jsx-runtime`
* dependency declared in the sibling `*.asset.php` file is deliberately not
* treated as proof here: the `Symbol.for( 'react.element' )` marker means the
* file already inlines a pre-19 runtime, and a declared dependency does not
* rule out a stale or mixed build that still bundles its own copy. Trusting
* the asset file in that case would hide the exact breakage this check exists
* to surface.
*
* @since 2.0.0
*
* @param string $contents Contents of the JavaScript file.
* @return bool True if the runtime is externalized, false otherwise.
*/
private function is_jsx_runtime_externalized( $contents ) {
return str_contains( $contents, 'window.ReactJSXRuntime' );
}

/**
* Blanks out comments and string literals in JavaScript contents.
*
* Characters inside line comments, block comments, and single-, double-, or
* backtick-quoted strings are replaced with spaces. The length of the string
* and every newline are preserved, so match offsets still map to the correct
* line and column in the original contents.
*
* @since 2.0.0
*
* @param string $contents Contents of the JavaScript file.
* @return string The contents with comments and string literals blanked out.
*/
private function blank_comments_and_strings( $contents ) {
$pattern = '~/\*.*?\*/|//[^\r\n]*|"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|`(?:\\\\.|[^`\\\\])*`~s';

$blanked = preg_replace_callback(
$pattern,
static function ( $matches ) {
return preg_replace( '/[^\r\n]/', ' ', $matches[0] );
},
$contents
);

// On a PCRE failure (e.g. backtracking limit) fall back to the raw contents.
return is_string( $blanked ) ? $blanked : $contents;
}

/**
* 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 @@ -105,6 +105,7 @@ private function register_default_checks() {
'external_admin_menu_links' => new Checks\Plugin_Repo\External_Admin_Menu_Links_Check(),
'wp_functions_compatibility' => new Checks\Plugin_Repo\WP_Functions_Compatibility_Check(),
'ai_provider' => new Checks\General\AI_Provider_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 @@
<?php return array('dependencies' => array('react', 'react-jsx-runtime', 'wp-element'), 'version' => 'def456');
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Inlines a pre-19 runtime even though the sibling asset file declares react-jsx-runtime.
( function () {
var element = Symbol.for( "react.element" );
return element;
}() );
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,6 @@
// This build references removed React APIs only in comments and strings.
( function () {
// findDOMNode and unmountComponentAtNode were removed in React 19.
var note = 'Avoid ReactCurrentOwner; it no longer exists.';
return note;
}() );
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;
}() );
Loading
Loading