From fc3808ad8e5098fea98866979467eaa8d1447b0b Mon Sep 17 00:00:00 2001 From: Gunjan Jaswal Date: Tue, 30 Jun 2026 00:31:19 +0530 Subject: [PATCH 1/4] 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/4] 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 ) { From 2eb7396f41309e413f1cb11e58f9858bbaabcc39 Mon Sep 17 00:00:00 2001 From: Gunjan Jaswal Date: Sun, 12 Jul 2026 01:22:13 +0530 Subject: [PATCH 3/4] Harden inlined-runtime check against false negatives and positives Address the review feedback on the React 19 runtime check: - Stop treating a react-jsx-runtime dependency in the sibling .asset.php file as proof the runtime is externalized. The Symbol.for( 'react.element' ) marker means the file already inlines a pre-19 runtime, so a declared dependency can hide a stale or mixed build. Only an in-file window.ReactJSXRuntime reference now suppresses the warning. - Ignore the removed-API identifiers when they appear only in comments or string literals, so changelog notes and translation strings no longer produce false positives. Update the fixtures and tests to cover both cases. --- .../Inlined_React_Runtime_Check.php | 60 +++++++++++++------ .../asset-declared.asset.php} | 2 +- .../asset-declared.js | 5 ++ .../comment-only.js | 6 ++ .../index.js | 5 -- .../Inlined_React_Runtime_Check_Tests.php | 7 ++- 6 files changed, 59 insertions(+), 26 deletions(-) rename tests/phpunit/testdata/plugins/{test-plugin-inlined-react-runtime-without-errors/index.asset.php => test-plugin-inlined-react-runtime-with-errors/asset-declared.asset.php} (57%) create mode 100644 tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/asset-declared.js create mode 100644 tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/comment-only.js delete mode 100644 tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/index.js diff --git a/includes/Checker/Checks/Performance/Inlined_React_Runtime_Check.php b/includes/Checker/Checks/Performance/Inlined_React_Runtime_Check.php index 964c79627..37345a490 100644 --- a/includes/Checker/Checks/Performance/Inlined_React_Runtime_Check.php +++ b/includes/Checker/Checks/Performance/Inlined_React_Runtime_Check.php @@ -94,7 +94,7 @@ private function look_for_inlined_jsx_runtime( Check_Result $result, $file, $con } // Do not warn when the runtime is externalized to the copy shipped with WordPress. - if ( $this->is_jsx_runtime_externalized( $file, $contents ) ) { + if ( $this->is_jsx_runtime_externalized( $contents ) ) { return; } @@ -120,9 +120,13 @@ private function look_for_inlined_jsx_runtime( Check_Result $result, $file, $con * @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/', $contents, $matched ); + $position = $this->find_first_match( '/\b(?:unmountComponentAtNode|findDOMNode|ReactCurrentOwner)\b/', $scannable, $matched ); if ( false === $position ) { return; @@ -145,34 +149,52 @@ private function look_for_removed_react_apis( Check_Result $result, $file, $cont } /** - * Determines whether the JSX runtime is externalized rather than inlined. + * Determines whether the file references the externalized JSX runtime. * * 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. + * `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 $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; - } + private function is_jsx_runtime_externalized( $contents ) { + return str_contains( $contents, 'window.ReactJSXRuntime' ); + } - $asset_file = preg_replace( '/\.js$/', '.asset.php', $file ); + /** + * 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'; - 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; - } - } + $blanked = preg_replace_callback( + $pattern, + static function ( $matches ) { + return preg_replace( '/[^\r\n]/', ' ', $matches[0] ); + }, + $contents + ); - return false; + // On a PCRE failure (e.g. backtracking limit) fall back to the raw contents. + return is_string( $blanked ) ? $blanked : $contents; } /** diff --git a/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/index.asset.php b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/asset-declared.asset.php similarity index 57% rename from tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/index.asset.php rename to tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/asset-declared.asset.php index c04297576..605b75650 100644 --- a/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/index.asset.php +++ b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/asset-declared.asset.php @@ -1 +1 @@ - array('react', 'react-jsx-runtime', 'wp-element'), 'version' => 'abc123'); + array('react', 'react-jsx-runtime', 'wp-element'), 'version' => 'def456'); diff --git a/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/asset-declared.js b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/asset-declared.js new file mode 100644 index 000000000..017f0c3d4 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/asset-declared.js @@ -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; +}() ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/comment-only.js b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/comment-only.js new file mode 100644 index 000000000..c0a28e1c0 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/comment-only.js @@ -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; +}() ); 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 deleted file mode 100644 index 71860482c..000000000 --- a/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/index.js +++ /dev/null @@ -1,5 +0,0 @@ -// 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/tests/Checker/Checks/Inlined_React_Runtime_Check_Tests.php b/tests/phpunit/tests/Checker/Checks/Inlined_React_Runtime_Check_Tests.php index 291d59087..92416d926 100644 --- a/tests/phpunit/tests/Checker/Checks/Inlined_React_Runtime_Check_Tests.php +++ b/tests/phpunit/tests/Checker/Checks/Inlined_React_Runtime_Check_Tests.php @@ -22,13 +22,18 @@ public function test_run_with_errors() { $this->assertNotEmpty( $warnings ); $this->assertEmpty( $check_result->get_errors() ); - $this->assertSame( 2, $check_result->get_warning_count() ); + $this->assertSame( 3, $check_result->get_warning_count() ); $this->assertArrayHasKey( 'index.js', $warnings ); $this->assertArrayHasKey( 'legacy.js', $warnings ); + $this->assertArrayHasKey( 'asset-declared.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'] ) ); + + // A declared react-jsx-runtime dependency in the sibling asset file must + // not suppress the inlined pre-19 runtime marker found in the JavaScript. + $this->assertSame( 'inlined_jsx_runtime', $this->get_first_code( $warnings['asset-declared.js'] ) ); } public function test_run_without_errors() { From fc6b9a5f91714890e19847eb8c3bb1dfad732918 Mon Sep 17 00:00:00 2001 From: Jarda Snajdr Date: Mon, 21 Sep 2026 17:15:04 +0200 Subject: [PATCH 4/4] React usage check: improve bundle detection --- docs/checks.md | 2 +- .../Inlined_React_Runtime_Check.php | 259 ---------- .../Checks/Performance/React_Usage_Check.php | 457 ++++++++++++++++++ includes/Checker/Default_Check_Repository.php | 2 +- readme.txt | 2 +- .../asset-declared.js | 5 - .../index.js | 5 - .../legacy.js | 4 - .../comment-only.js | 6 - .../view.js | 6 - .../asset-declared.asset.php | 0 .../asset-declared.js | 8 + .../hydrate.js | 8 + .../jsx-runtime-dev.js | 12 + .../jsx-runtime-tree-shaken.js | 12 + .../jsx-runtime.js | 13 + .../legacy.js | 6 + .../load.php | 6 +- .../react-17-prod.js | 14 + .../react-dom.js | 15 + .../react-external-dom.js | 11 + .../react.js | 17 + .../comment-only.js | 6 + .../load.php | 6 +- .../modern.js | 10 + .../react-19.js | 8 + .../react-is.js | 20 + .../view.js | 8 + .../Inlined_React_Runtime_Check_Tests.php | 69 --- .../Checks/React_Usage_Check_Tests.php | 168 +++++++ 30 files changed, 802 insertions(+), 363 deletions(-) delete mode 100644 includes/Checker/Checks/Performance/Inlined_React_Runtime_Check.php create mode 100644 includes/Checker/Checks/Performance/React_Usage_Check.php delete mode 100644 tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/asset-declared.js delete mode 100644 tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/index.js delete mode 100644 tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/legacy.js delete mode 100644 tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/comment-only.js delete mode 100644 tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/view.js rename tests/phpunit/testdata/plugins/{test-plugin-inlined-react-runtime-with-errors => test-plugin-react-usage-with-errors}/asset-declared.asset.php (100%) create mode 100644 tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/asset-declared.js create mode 100644 tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/hydrate.js create mode 100644 tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/jsx-runtime-dev.js create mode 100644 tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/jsx-runtime-tree-shaken.js create mode 100644 tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/jsx-runtime.js create mode 100644 tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/legacy.js rename tests/phpunit/testdata/plugins/{test-plugin-inlined-react-runtime-with-errors => test-plugin-react-usage-with-errors}/load.php (67%) create mode 100644 tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/react-17-prod.js create mode 100644 tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/react-dom.js create mode 100644 tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/react-external-dom.js create mode 100644 tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/react.js create mode 100644 tests/phpunit/testdata/plugins/test-plugin-react-usage-without-errors/comment-only.js rename tests/phpunit/testdata/plugins/{test-plugin-inlined-react-runtime-without-errors => test-plugin-react-usage-without-errors}/load.php (66%) create mode 100644 tests/phpunit/testdata/plugins/test-plugin-react-usage-without-errors/modern.js create mode 100644 tests/phpunit/testdata/plugins/test-plugin-react-usage-without-errors/react-19.js create mode 100644 tests/phpunit/testdata/plugins/test-plugin-react-usage-without-errors/react-is.js create mode 100644 tests/phpunit/testdata/plugins/test-plugin-react-usage-without-errors/view.js delete mode 100644 tests/phpunit/tests/Checker/Checks/Inlined_React_Runtime_Check_Tests.php create mode 100644 tests/phpunit/tests/Checker/Checks/React_Usage_Check_Tests.php diff --git a/docs/checks.md b/docs/checks.md index a36892cda..345df7ee1 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -37,7 +37,7 @@ | 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/) | +| react_usage | performance | Detects React usage that breaks when WordPress upgrades to React 19. | [Learn more](https://react.dev/blog/2024/04/25/react-19-upgrade-guide) | ## Notes diff --git a/includes/Checker/Checks/Performance/Inlined_React_Runtime_Check.php b/includes/Checker/Checks/Performance/Inlined_React_Runtime_Check.php deleted file mode 100644 index 37345a490..000000000 --- a/includes/Checker/Checks/Performance/Inlined_React_Runtime_Check.php +++ /dev/null @@ -1,259 +0,0 @@ -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' ); - } -} diff --git a/includes/Checker/Checks/Performance/React_Usage_Check.php b/includes/Checker/Checks/Performance/React_Usage_Check.php new file mode 100644 index 000000000..740cf2b37 --- /dev/null +++ b/includes/Checker/Checks/Performance/React_Usage_Check.php @@ -0,0 +1,457 @@ +check_inlined_packages( $result, $file, $contents ) ) { + continue; + } + + $this->check_removed_apis( $result, $file, $contents ); + } + } + + /** + * Reports every pre-React 19 package inlined into a single file. + * + * Detection happens in two steps. The `react.element` symbol name establishes + * that a pre-19 build is in the file at all: React 19 renamed it to + * `react.transitional.element`, and a build that externalizes React contains + * neither. A second marker then identifies which package was inlined, because + * the three packages WordPress externalizes are fixed separately. + * + * Both steps are required. The symbol name alone proves nothing, because small + * libraries such as `react-is` list every React symbol without inlining any + * React code, and a file may well inline one package while externalizing the + * rest. + * + * @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. + * @return bool True if any inlined package was reported, false otherwise. + */ + private function check_inlined_packages( Check_Result $result, $file, $contents ) { + $position = $this->find_inlined_pre_19_react( $contents ); + + if ( false === $position ) { + return false; + } + + $reported = false; + $is_development = $this->is_development_build( $contents ); + + foreach ( $this->get_packages() as $package ) { + if ( ! preg_match( $package['pattern'], $contents ) ) { + continue; + } + + // Do not report a package that is externalized to the copy shipped + // with WordPress. A `*.asset.php` dependency is deliberately not + // accepted as proof: the element marker means a pre-19 build is + // already inlined, and a declared dependency does not rule out a + // stale or mixed build that still bundles its own copy. + if ( preg_match( $package['global'], $contents ) ) { + continue; + } + + $this->add_package_error( $result, $file, $position, $package, $is_development ); + $reported = true; + } + + return $reported; + } + + /** + * Locates the element marker emitted by React builds predating React 19. + * + * `react.element` is the name of the element type symbol used up to React + * 18. React 19 renamed it to `react.transitional.element`, and a build that + * externalizes React to the copy shipped with WordPress contains neither. + * + * Only the string literal is matched, not the surrounding + * `Symbol.for( ... )` call: the React 17 production builds hoist `Symbol.for` + * into a local variable and call it through that variable instead. + * + * @since 2.0.0 + * + * @param string $contents Contents of the JavaScript file. + * @return array|false Array with `line` and `column` keys, or false if no match was found. + */ + private function find_inlined_pre_19_react( $contents ) { + return $this->find_first_match( '/([\'"])react\.element\1/', $contents ); + } + + /** + * Adds the error for a single inlined package. + * + * @since 2.0.0 + * + * @param Check_Result $result The check result to amend. + * @param string $file Absolute path to the JavaScript file. + * @param array $position Array with `line` and `column` keys. + * @param array $package Package definition as returned by `get_packages()`. + * @param bool $is_development Whether the inlined build is a development build. + */ + private function add_package_error( Check_Result $result, $file, array $position, array $package, $is_development ) { + if ( $is_development ) { + $message = sprintf( + /* translators: %s: npm package name, e.g. "react-dom" */ + __( 'This file inlines a development build of the "%s" package instead of externalizing it. The bundled copy predates React 19 and breaks when WordPress upgrades to React 19, and development builds are far larger and slower than production builds. Use the dependency extraction webpack plugin so that the package is loaded from WordPress instead.', 'plugin-check' ), + $package['label'] + ); + } else { + $message = sprintf( + /* translators: %s: npm package name, e.g. "react-dom" */ + __( 'This file inlines the "%s" package instead of externalizing it. The bundled copy predates React 19 and breaks when WordPress upgrades to React 19. Use the dependency extraction webpack plugin so that the package is loaded from WordPress instead.', 'plugin-check' ), + $package['label'] + ); + } + + $this->add_result_error_for_file( + $result, + $message, + $package['code'], + $file, + $position['line'], + $position['column'], + self::EXTERNALIZE_DOCS_URL, + $is_development ? 7 : 6 + ); + } + + /** + * Returns the packages this check can tell apart. + * + * Each `pattern` matches code internal to the package, so that a build which + * merely calls the package does not match. `global` matches a reference to + * the browser global that the dependency extraction webpack plugin maps the + * package to. The trailing word boundary keeps `window.ReactDOM` from + * counting as a reference to `window.React`. + * + * @since 2.0.0 + * + * @return array List of package definitions. + */ + private function get_packages() { + return array( + array( + 'label' => 'react/jsx-runtime', + 'code' => 'inlined_react_jsx_runtime', + 'global' => '/\bwindow\.ReactJSXRuntime\b/', + // The runtime assigns `jsx`/`jsxs` onto its exports object. Call + // sites such as `ReactJSXRuntime.jsxs( ... )` are not matched. + // Either name alone is enough, because a bundler that sees only + // `jsx` call sites tree-shakes the `jsxs` export away. + 'pattern' => '/\bjsxs?\s*[:=][^=]/', + ), + array( + 'label' => 'react', + 'code' => 'inlined_react', + 'global' => '/\bwindow\.React\b/', + // Only the library itself assigns this export. `react-dom` also + // assigns its own, which is fine because bundling the renderer + // always bundles the library too, but `react/jsx-runtime` merely + // reads it, so the assignment is what tells the two apart. + 'pattern' => '/__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED\s*[:=][^=]/', + ), + array( + 'label' => 'react-dom', + 'code' => 'inlined_react_dom', + 'global' => '/\bwindow\.ReactDOM\b/', + // The key under which the renderer caches the fiber on every + // DOM node it owns, renamed in React 17. Nothing but the + // renderer defines it, code that merely calls the renderer does + // not, and it survives minification because it is a string + // literal. + 'pattern' => '/__reactFiber\$|__reactInternalInstance\$/', + ), + ); + } + + /** + * Determines whether the inlined build is a development build. + * + * Development builds embed documentation links in their warning messages, + * under `reactjs.org` up to React 18 and under `react.dev` from React 19. + * Production builds strip every warning, so neither link survives. + * + * @since 2.0.0 + * + * @param string $contents Contents of the JavaScript file. + * @return bool True if the file inlines a development build, false otherwise. + */ + private function is_development_build( $contents ) { + return 1 === preg_match( '#https://(?:reactjs\.org|react\.dev)/link/#', $contents ); + } + + /** + * Reports every call to a removed React API in a single file. + * + * @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 check_removed_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 ); + + foreach ( $this->get_removed_apis() as $api ) { + $position = $this->find_first_match( $api['pattern'], $scannable ); + + if ( false === $position ) { + continue; + } + + $this->add_result_warning_for_file( + $result, + sprintf( + /* translators: 1: the removed React API name, 2: the API replacing it */ + __( 'This file calls "%1$s", which was removed in React 19 and stops working once WordPress upgrades React. Use %2$s instead.', 'plugin-check' ), + $api['name'], + $api['replacement'] + ), + 'react_removed_api', + $file, + $position['line'], + $position['column'], + self::UPGRADE_DOCS_URL, + 5 + ); + } + } + + /** + * Returns the public React APIs removed in React 19. + * + * Only the documented public surface is matched. Internals such as + * `ReactCurrentOwner` are deliberately left out: they never appear in plugin + * code, only inside a React build that the plugin inlined, which the inlined + * package errors cover. + * + * `render` and `hydrate` are common words, so they are only matched when + * called on a `ReactDOM` object. The remaining names are specific enough to + * match on their own. + * + * @since 2.0.0 + * + * @return array List of removed API definitions. + */ + private function get_removed_apis() { + return array( + array( + 'name' => 'ReactDOM.render', + 'pattern' => '/\bReactDOM\s*\.\s*render\s*\(/', + 'replacement' => 'createRoot()', + ), + array( + 'name' => 'ReactDOM.hydrate', + 'pattern' => '/\bReactDOM\s*\.\s*hydrate\s*\(/', + 'replacement' => 'hydrateRoot()', + ), + array( + 'name' => 'ReactDOM.unmountComponentAtNode', + 'pattern' => '/\bunmountComponentAtNode\s*\(/', + 'replacement' => 'root.unmount()', + ), + array( + 'name' => 'ReactDOM.findDOMNode', + 'pattern' => '/\bfindDOMNode\s*\(/', + 'replacement' => 'a ref on the element', + ), + array( + 'name' => 'ReactDOM.unstable_renderSubtreeIntoContainer', + 'pattern' => '/\bunstable_renderSubtreeIntoContainer\s*\(/', + 'replacement' => 'createPortal()', + ), + array( + 'name' => 'ReactDOMServer.renderToNodeStream', + 'pattern' => '/\brenderToNodeStream\s*\(/', + 'replacement' => 'renderToPipeableStream()', + ), + array( + 'name' => 'React.createFactory', + 'pattern' => '/\bReact\s*\.\s*createFactory\s*\(/', + 'replacement' => 'JSX or createElement()', + ), + ); + } + + /** + * 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. + * @return array|false Array with `line` and `column` keys, or false if no match was found. + */ + private function find_first_match( $pattern, $contents ) { + if ( ! preg_match( $pattern, $contents, $matches, PREG_OFFSET_CAPTURE ) ) { + return false; + } + + $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 React usage that breaks when WordPress upgrades to 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 self::UPGRADE_DOCS_URL; + } +} diff --git a/includes/Checker/Default_Check_Repository.php b/includes/Checker/Default_Check_Repository.php index ab66b9946..775082860 100644 --- a/includes/Checker/Default_Check_Repository.php +++ b/includes/Checker/Default_Check_Repository.php @@ -105,7 +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(), + 'react_usage' => new Checks\Performance\React_Usage_Check(), ) ); diff --git a/readme.txt b/readme.txt index 5174d2630..e1aa6ffaa 100644 --- a/readme.txt +++ b/readme.txt @@ -88,7 +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 React Usage check to detect a bundled, outdated React runtime that breaks under React 19, distinguishing between the react, react-dom and react/jsx-runtime packages, and usage of public React APIs that were removed in 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/asset-declared.js b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/asset-declared.js deleted file mode 100644 index 017f0c3d4..000000000 --- a/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/asset-declared.js +++ /dev/null @@ -1,5 +0,0 @@ -// Inlines a pre-19 runtime even though the sibling asset file declares react-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/index.js b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/index.js deleted file mode 100644 index 9b713ae14..000000000 --- a/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/index.js +++ /dev/null @@ -1,5 +0,0 @@ -// 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 deleted file mode 100644 index 2b227c233..000000000 --- a/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/legacy.js +++ /dev/null @@ -1,4 +0,0 @@ -// 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-without-errors/comment-only.js b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/comment-only.js deleted file mode 100644 index c0a28e1c0..000000000 --- a/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/comment-only.js +++ /dev/null @@ -1,6 +0,0 @@ -// 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; -}() ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/view.js b/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/view.js deleted file mode 100644 index 3043c575e..000000000 --- a/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-without-errors/view.js +++ /dev/null @@ -1,6 +0,0 @@ -// Build output whose runtime is externalized via window.ReactJSXRuntime. -( function () { - var jsx = window.ReactJSXRuntime; - var element = Symbol.for( "react.element" ); - return jsx ? element : null; -}() ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/asset-declared.asset.php b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/asset-declared.asset.php similarity index 100% rename from tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/asset-declared.asset.php rename to tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/asset-declared.asset.php diff --git a/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/asset-declared.js b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/asset-declared.js new file mode 100644 index 000000000..e9f383a8f --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/asset-declared.js @@ -0,0 +1,8 @@ +// Inlines a pre-19 JSX runtime even though the sibling asset file declares a +// react-jsx-runtime dependency. +( function ( exports ) { + var k = Symbol.for( "react.element" ); + exports.jsxs = function ( type, props ) { + return { $$typeof: k, type: type, props: props }; + }; +}( {} ) ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/hydrate.js b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/hydrate.js new file mode 100644 index 000000000..e0653d06d --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/hydrate.js @@ -0,0 +1,8 @@ +// Server-rendered markup rehydrated through the removed legacy entry points. +( function () { + var container = document.getElementById( 'app' ); + ReactDOM.hydrate( window.createApp(), container ); + window.addEventListener( 'unload', function () { + unmountComponentAtNode( container ); + } ); +}() ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/jsx-runtime-dev.js b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/jsx-runtime-dev.js new file mode 100644 index 000000000..352ff81cc --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/jsx-runtime-dev.js @@ -0,0 +1,12 @@ +// Development build output that inlines the pre-React 19 JSX runtime. +( function ( exports ) { + var k = Symbol.for( "react.element" ); + function jsxWithValidation( type, props ) { + if ( ! type ) { + console.error( "React.jsx: type is invalid. See https://reactjs.org/link/invalid-element-type for more information." ); + } + return { $$typeof: k, type: type, props: props }; + } + exports.jsx = jsxWithValidation; + exports.jsxs = jsxWithValidation; +}( {} ) ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/jsx-runtime-tree-shaken.js b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/jsx-runtime-tree-shaken.js new file mode 100644 index 000000000..a893cc264 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/jsx-runtime-tree-shaken.js @@ -0,0 +1,12 @@ +// Build output that externalizes react and react-dom but still inlines the +// pre-19 JSX runtime. Only the jsx export is used, so the bundler tree-shook +// jsxs away and the runtime must be recognized from jsx alone. +( function ( modules ) { + var React = ( modules[ 1609 ] = window.React ); + var k = Symbol.for( "react.element" ); + var owner = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner; + modules[ 1020 ] = {}; + modules[ 1020 ].jsx = function ( type, props ) { + return { $$typeof: k, type: type, props: props, _owner: owner.current }; + }; +}( {} ) ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/jsx-runtime.js b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/jsx-runtime.js new file mode 100644 index 000000000..2d6f82009 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/jsx-runtime.js @@ -0,0 +1,13 @@ +// Production build output that inlines the pre-React 19 JSX runtime. Like the +// real runtime it reads React's internals export without assigning it, so only +// the JSX runtime must be reported for this file. +( function ( exports, React ) { + var k = Symbol.for( "react.element" ), l = Symbol.for( "react.fragment" ); + var n = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner; + function q( c, a ) { + return { $$typeof: k, type: c, key: null, ref: null, props: a, _owner: n.current }; + } + exports.Fragment = l; + exports.jsx = q; + exports.jsxs = q; +}( {}, {} ) ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/legacy.js b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/legacy.js new file mode 100644 index 000000000..965f11e6c --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/legacy.js @@ -0,0 +1,6 @@ +// Calls public React APIs that were removed in React 19. +( function () { + var container = document.getElementById( 'root' ); + ReactDOM.render( window.createApp(), container ); + return findDOMNode( container ); +}() ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/load.php similarity index 67% rename from tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/load.php rename to tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/load.php index 67b588c96..cc34e1ac1 100644 --- a/tests/phpunit/testdata/plugins/test-plugin-inlined-react-runtime-with-errors/load.php +++ b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/load.php @@ -1,6 +1,6 @@ run( $check_result ); - - $warnings = $check_result->get_warnings(); - - $this->assertNotEmpty( $warnings ); - $this->assertEmpty( $check_result->get_errors() ); - $this->assertSame( 3, $check_result->get_warning_count() ); - - $this->assertArrayHasKey( 'index.js', $warnings ); - $this->assertArrayHasKey( 'legacy.js', $warnings ); - $this->assertArrayHasKey( 'asset-declared.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'] ) ); - - // A declared react-jsx-runtime dependency in the sibling asset file must - // not suppress the inlined pre-19 runtime marker found in the JavaScript. - $this->assertSame( 'inlined_jsx_runtime', $this->get_first_code( $warnings['asset-declared.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; - } -} diff --git a/tests/phpunit/tests/Checker/Checks/React_Usage_Check_Tests.php b/tests/phpunit/tests/Checker/Checks/React_Usage_Check_Tests.php new file mode 100644 index 000000000..53f458934 --- /dev/null +++ b/tests/phpunit/tests/Checker/Checks/React_Usage_Check_Tests.php @@ -0,0 +1,168 @@ +run_check( 'test-plugin-react-usage-with-errors' ); + $errors = $check_result->get_errors(); + + $this->assertNotEmpty( $errors ); + $this->assertSame( 8, $check_result->get_error_count() ); + + // Each package is reported under its own code. + $this->assertSame( array( 'inlined_react_jsx_runtime' ), $this->get_codes( $errors, 'jsx-runtime.js' ) ); + $this->assertSame( array( 'inlined_react' ), $this->get_codes( $errors, 'react.js' ) ); + $this->assertSame( array( 'inlined_react_dom' ), $this->get_codes( $errors, 'react-dom.js' ) ); + + // The JSX runtime is recognized from the jsx export alone, and the react + // and react-dom copies the same file externalizes stay unreported. + $this->assertSame( array( 'inlined_react_jsx_runtime' ), $this->get_codes( $errors, 'jsx-runtime-tree-shaken.js' ) ); + + // Externalizing the renderer does not externalize the library: the + // window.ReactDOM reference must not suppress the inlined react copy. + $this->assertSame( array( 'inlined_react' ), $this->get_codes( $errors, 'react-external-dom.js' ) ); + + // React 17 production builds call Symbol.for through a local variable. + $this->assertSame( array( 'inlined_react' ), $this->get_codes( $errors, 'react-17-prod.js' ) ); + + // A development build is reported under the same code but with a higher severity. + $this->assertSame( array( 'inlined_react_jsx_runtime' ), $this->get_codes( $errors, 'jsx-runtime-dev.js' ) ); + $this->assertSame( 6, $this->get_first_message( $errors, 'jsx-runtime.js' )['severity'] ); + $this->assertSame( 7, $this->get_first_message( $errors, 'jsx-runtime-dev.js' )['severity'] ); + $this->assertStringContainsString( 'development build', $this->get_first_message( $errors, 'jsx-runtime-dev.js' )['message'] ); + + // A declared react-jsx-runtime dependency in the sibling asset file must + // not suppress the inlined pre-19 runtime found in the JavaScript. + $this->assertSame( array( 'inlined_react_jsx_runtime' ), $this->get_codes( $errors, 'asset-declared.js' ) ); + } + + public function test_run_with_warnings() { + $check_result = $this->run_check( 'test-plugin-react-usage-with-errors' ); + $warnings = $check_result->get_warnings(); + + $this->assertNotEmpty( $warnings ); + $this->assertSame( 4, $check_result->get_warning_count() ); + + // Every removed API used in a file is reported, not only the first one. + $this->assertSame( + array( 'ReactDOM.render', 'ReactDOM.findDOMNode' ), + $this->get_reported_apis( $warnings, 'legacy.js' ) + ); + $this->assertSame( + array( 'ReactDOM.hydrate', 'ReactDOM.unmountComponentAtNode' ), + $this->get_reported_apis( $warnings, 'hydrate.js' ) + ); + + // A file that inlines a package is reported for that alone, even though + // the inlined renderer defines the removed APIs itself. + $this->assertArrayNotHasKey( 'react-dom.js', $warnings ); + } + + public function test_run_without_errors() { + $check_result = $this->run_check( 'test-plugin-react-usage-without-errors' ); + + $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() ); + } + + /** + * Runs the check against one of the test plugins. + * + * @param string $plugin Directory name of the test plugin. + * @return Check_Result The result of the check. + */ + private function run_check( $plugin ) { + $check = new React_Usage_Check(); + $check_context = new Check_Context( UNIT_TESTS_PLUGIN_DIR . $plugin . '/load.php' ); + $check_result = new Check_Result( $check_context ); + + $check->run( $check_result ); + + return $check_result; + } + + /** + * Returns the message codes reported for a file. + * + * @param array $reported All reported messages, keyed by file. + * @param string $file File to collect the codes for. + * @return array List of message codes. + */ + private function get_codes( array $reported, $file ) { + $codes = array(); + + foreach ( $this->get_messages( $reported, $file ) as $message ) { + $codes[] = $message['code']; + } + + return $codes; + } + + /** + * Returns the removed API names reported for a file, in source order. + * + * @param array $warnings All warnings, keyed by file. + * @param string $file File to collect the API names for. + * @return array List of API names. + */ + private function get_reported_apis( array $warnings, $file ) { + $apis = array(); + + foreach ( $this->get_messages( $warnings, $file ) as $message ) { + $this->assertSame( 'react_removed_api', $message['code'] ); + + if ( preg_match( '/"([^"]+)"/', $message['message'], $matches ) ) { + $apis[] = $matches[1]; + } + } + + return $apis; + } + + /** + * Returns the first message reported for a file. + * + * @param array $reported All reported messages, keyed by file. + * @param string $file File to return the message for. + * @return array The message data. + */ + private function get_first_message( array $reported, $file ) { + $messages = $this->get_messages( $reported, $file ); + + return $messages[0]; + } + + /** + * Flattens the line and column nesting of the messages for a single file. + * + * @param array $reported All reported messages, keyed by file. + * @param string $file File to flatten the messages for. + * @return array List of message data arrays. + */ + private function get_messages( array $reported, $file ) { + $this->assertArrayHasKey( $file, $reported ); + + $flattened = array(); + + foreach ( $reported[ $file ] as $columns ) { + foreach ( $columns as $messages ) { + foreach ( $messages as $message ) { + $flattened[] = $message; + } + } + } + + return $flattened; + } +}