From fc3808ad8e5098fea98866979467eaa8d1447b0b Mon Sep 17 00:00:00 2001 From: Gunjan Jaswal Date: Tue, 30 Jun 2026 00:31:19 +0530 Subject: [PATCH 1/2] Add Inlined React Runtime check for React 19 incompatibilities Adds a static check that scans a plugin's JavaScript files for a bundled, outdated React runtime that breaks once WordPress upgrades to React 19. The primary, high-confidence signal is `Symbol.for( 'react.element' )`, which is only emitted by an inlined pre-React 19 JSX runtime (React 19 uses the `react.transitional.element` marker). The warning is suppressed when the runtime is externalized, detected via a `window.ReactJSXRuntime` reference or a `react-jsx-runtime` dependency in the sibling `*.asset.php` file. Usage of React APIs removed in React 19 (unmountComponentAtNode, findDOMNode, ReactCurrentOwner) is reported as a secondary signal. Registers the check, adds PHPUnit tests with passing/failing fixtures, and documents it in docs/checks.md and the changelog. Fixes #1356 --- docs/checks.md | 1 + .../Inlined_React_Runtime_Check.php | 236 ++++++++++++++++++ includes/Checker/Default_Check_Repository.php | 1 + readme.txt | 1 + .../index.js | 5 + .../legacy.js | 4 + .../load.php | 16 ++ .../index.asset.php | 1 + .../index.js | 5 + .../load.php | 16 ++ .../view.js | 6 + .../Inlined_React_Runtime_Check_Tests.php | 64 +++++ 12 files changed, 356 insertions(+) create mode 100644 includes/Checker/Checks/Performance/Inlined_React_Runtime_Check.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/index.js create mode 100644 tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/legacy.js create mode 100644 tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/load.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/index.asset.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/index.js create mode 100644 tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/load.php create mode 100644 tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/view.js create mode 100644 tests/phpunit/tests/Checker/Checks/Inlined_React_Runtime_Check_Tests.php diff --git a/docs/checks.md b/docs/checks.md index d52ba1f90..1f23a1447 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -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/) | diff --git a/includes/Checker/Checks/Performance/Inlined_React_Runtime_Check.php b/includes/Checker/Checks/Performance/Inlined_React_Runtime_Check.php new file mode 100644 index 000000000..57a1f0737 --- /dev/null +++ b/includes/Checker/Checks/Performance/Inlined_React_Runtime_Check.php @@ -0,0 +1,236 @@ +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. + $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' ); + } +} diff --git a/includes/Checker/Default_Check_Repository.php b/includes/Checker/Default_Check_Repository.php index d1e5740e4..1e606b3e0 100644 --- a/includes/Checker/Default_Check_Repository.php +++ b/includes/Checker/Default_Check_Repository.php @@ -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(), ) ); diff --git a/readme.txt b/readme.txt index d0fbaf0ba..5174d2630 100644 --- a/readme.txt +++ b/readme.txt @@ -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. diff --git a/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/index.js b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/index.js new file mode 100644 index 000000000..9b713ae14 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/index.js @@ -0,0 +1,5 @@ +// Simulated build output that inlines a pre-React 19 JSX runtime. +( function () { + var element = Symbol.for( "react.element" ); + return element; +}() ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/legacy.js b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/legacy.js new file mode 100644 index 000000000..2b227c233 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/legacy.js @@ -0,0 +1,4 @@ +// Simulated build output that calls a React API removed in React 19. +( function () { + ReactDOM.unmountComponentAtNode( document.getElementById( 'root' ) ); +}() ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/load.php new file mode 100644 index 000000000..67b588c96 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/load.php @@ -0,0 +1,16 @@ + array('react', 'react-jsx-runtime', 'wp-element'), 'version' => 'abc123'); diff --git a/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/index.js b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/index.js new file mode 100644 index 000000000..71860482c --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/index.js @@ -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; +}() ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/load.php new file mode 100644 index 000000000..ae37963b8 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/load.php @@ -0,0 +1,16 @@ +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; + } +} From 2940c4f3199be4a8908bb1a3a7f371c85852c2f0 Mon Sep 17 00:00:00 2001 From: Gunjan Jaswal Date: Tue, 30 Jun 2026 09:56:40 +0530 Subject: [PATCH 2/2] Initialize $matched before passing it by reference PHPMD flagged $matched as an undefined variable in look_for_removed_react_apis since it was only created via the by-reference argument. Initialize it first. --- .../Checker/Checks/Performance/Inlined_React_Runtime_Check.php | 1 + 1 file changed, 1 insertion(+) diff --git a/includes/Checker/Checks/Performance/Inlined_React_Runtime_Check.php b/includes/Checker/Checks/Performance/Inlined_React_Runtime_Check.php index 57a1f0737..964c79627 100644 --- a/includes/Checker/Checks/Performance/Inlined_React_Runtime_Check.php +++ b/includes/Checker/Checks/Performance/Inlined_React_Runtime_Check.php @@ -121,6 +121,7 @@ private function look_for_inlined_jsx_runtime( Check_Result $result, $file, $con */ 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 ) {