diff --git a/docs/checks.md b/docs/checks.md index 89d84fb29..203628c9a 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -38,6 +38,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/) | +| react_usage | general | Detects React usage that breaks when WordPress upgrades to React 19. | [Learn more](https://react.dev/blog/2024/04/25/react-19-upgrade-guide) | ## Results and severity diff --git a/includes/Checker/Checks/General/React_Usage_Check.php b/includes/Checker/Checks/General/React_Usage_Check.php new file mode 100644 index 000000000..92b76b090 --- /dev/null +++ b/includes/Checker/Checks/General/React_Usage_Check.php @@ -0,0 +1,808 @@ +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. Markers internal to a package then identify which one 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.2.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; + + foreach ( $this->get_packages() as $package ) { + if ( ! $this->matches_every_pattern( $package['patterns'], $contents ) ) { + continue; + } + + if ( $this->externalizes_global( $package['global'], $contents ) ) { + continue; + } + + $this->add_package_error( $result, $file, $position, $package ); + $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. + * + * A template literal counts as well as a quoted string, because some + * minifiers rewrite every string in a bundle as one. + * + * @since 2.2.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.2.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()`. + */ + private function add_package_error( Check_Result $result, $file, array $position, array $package ) { + $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 will likely break 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, + 5 + ); + } + + /** + * Reports whether a package is externalized to the copy WordPress ships. + * + * Externalizing keeps the package out of the build and reads it from a + * browser global instead, so reading that global is what proves a package + * was externalized. + * + * Writing the global proves the opposite, and one assignment anywhere in a + * file overrides every read in it. A build can only publish a copy of the + * package it already carries, and publishing it replaces the copy WordPress + * loaded, for every script that runs afterwards as well. + * + * A `*.asset.php` dependency is deliberately not accepted as proof either. + * The element marker means a pre-19 build is inlined regardless, and a + * declared dependency does not rule out a stale or mixed build that still + * bundles its own copy. + * + * @since 2.2.0 + * + * @param string $name Name of the browser global, e.g. `ReactDOM`. + * @param string $contents Contents of the JavaScript file. + * @return bool True if the package is externalized, false otherwise. + */ + private function externalizes_global( $name, $contents ) { + // The trailing word boundary keeps `window.ReactDOM` from counting as a + // reference to `window.React`. + $reference = '/\bwindow\.' . $name . '\b/'; + + // Plain assignment, along with the logical assignments a minifier may + // emit. Ruling out a second equals sign leaves the comparison operators + // out. + $assignment = '/\bwindow\.' . $name . '\b\s*(?:\|\||&&|\?\?)?=[^=]/'; + + return 1 === preg_match( $reference, $contents ) && 1 !== preg_match( $assignment, $contents ); + } + + /** + * Reports whether every one of the given patterns matches the contents. + * + * @since 2.2.0 + * + * @param string[] $patterns Regular expression patterns. + * @param string $contents Contents of the JavaScript file. + * @return bool True if all of the patterns match, false otherwise. + */ + private function matches_every_pattern( array $patterns, $contents ) { + foreach ( $patterns as $pattern ) { + if ( ! preg_match( $pattern, $contents ) ) { + return false; + } + } + + return true; + } + + /** + * Returns the packages this check can tell apart. + * + * Every one of a package's `patterns` has to match. They match code internal + * to the package, so that a build which merely calls the package does not. + * `global` names the browser global that the dependency extraction webpack + * plugin maps the package to. + * + * @since 2.2.0 + * + * @return array List of package definitions. + */ + private function get_packages() { + return array( + array( + 'label' => 'react/jsx-runtime', + 'code' => 'inlined_react_jsx_runtime', + 'global' => 'ReactJSXRuntime', + 'patterns' => array( + // 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. + '/\bjsxs?\s*[:=][^=]/', + self::ELEMENT_FACTORY_PATTERN, + ), + ), + array( + 'label' => 'react', + 'code' => 'inlined_react', + 'global' => 'React', + 'patterns' => array( + // 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. + '/__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED\s*[:=][^=]/', + self::ELEMENT_FACTORY_PATTERN, + ), + ), + array( + 'label' => 'react-dom', + 'code' => 'inlined_react_dom', + 'global' => 'ReactDOM', + 'patterns' => array( + // 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. + // + // The element factory is deliberately not required here. The + // renderer consumes elements instead of creating them, so a + // file holding nothing but a copy of `react-dom` has no + // factory in it, and this marker is specific on its own. + '/__reactFiber\$|__reactInternalInstance\$/', + ), + ), + ); + } + + /** + * Reports every call to a removed React API in a single file. + * + * @since 2.2.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( $this->get_call_pattern( $api['callee'] ), $scannable ); + + if ( false === $position ) { + continue; + } + + $this->add_result_warning_for_file( + $result, + $this->get_removed_api_message( $api ), + 'react_removed_api', + $file, + $position['line'], + $position['column'], + self::UPGRADE_DOCS_URL, + 5 + ); + } + } + + /** + * Builds the pattern matching a call to one of the removed APIs. + * + * A bundler drops the `this` an imported function would otherwise be called + * with by wrapping the reference in a sequence expression, so the call to + * `findDOMNode` written as one reads `(0,r.findDOMNode)(node)` once built. + * The closing parenthesis that lands between the name and the call is the + * reason one is allowed here. + * + * @since 2.2.0 + * + * @param string $callee Pattern matching the name the API is called by. + * @return string Pattern matching a call to it. + */ + private function get_call_pattern( $callee ) { + return '/\b' . $callee . '\s*\)?\s*\(/'; + } + + /** + * Returns the message reported for a call to a removed API. + * + * Most of the removed APIs have a drop-in replacement, and the name of that + * replacement is code that must stay untranslated, so those share a single + * sentence with the name interpolated into it. The rest have no such + * replacement and need prose, which has to be part of the translated + * sentence rather than substituted into it. + * + * @since 2.2.0 + * + * @param array $api Removed API definition as returned by `get_removed_apis()`. + * @return string The message to report. + */ + private function get_removed_api_message( array $api ) { + if ( isset( $api['message'] ) ) { + return $api['message']; + } + + return sprintf( + /* translators: 1: the removed React API name, 2: the name of 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'] + ); + } + + /** + * 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. This misses them in a bundle, where the + * object is renamed and the call comes out as something like + * `(0,r.render)(...)`, but matching either name on its own would report far + * more code that has nothing to do with React than it would find. The other + * names are specific enough to match without an object, so renaming one + * does not hide them. + * + * Every entry carries a `callee`, matching the name the API is called by, + * and either a `replacement` naming the API to migrate to or a complete + * `message` for the APIs that have no such replacement. + * + * @since 2.2.0 + * + * @return array List of removed API definitions. + */ + private function get_removed_apis() { + return array( + array( + 'name' => 'ReactDOM.render', + 'callee' => 'ReactDOM\s*\.\s*render', + 'replacement' => 'createRoot()', + ), + array( + 'name' => 'ReactDOM.hydrate', + 'callee' => 'ReactDOM\s*\.\s*hydrate', + 'replacement' => 'hydrateRoot()', + ), + array( + 'name' => 'ReactDOM.unmountComponentAtNode', + 'callee' => 'unmountComponentAtNode', + 'replacement' => 'root.unmount()', + ), + array( + 'name' => 'ReactDOM.findDOMNode', + 'callee' => 'findDOMNode', + 'message' => __( 'This file calls "ReactDOM.findDOMNode", which was removed in React 19 and stops working once WordPress upgrades React. Use a ref on the element instead.', 'plugin-check' ), + ), + array( + 'name' => 'ReactDOM.unstable_renderSubtreeIntoContainer', + 'callee' => 'unstable_renderSubtreeIntoContainer', + 'replacement' => 'createPortal()', + ), + array( + 'name' => 'ReactDOMServer.renderToNodeStream', + 'callee' => 'renderToNodeStream', + 'replacement' => 'renderToPipeableStream()', + ), + array( + 'name' => 'React.createFactory', + 'callee' => 'React\s*\.\s*createFactory', + 'message' => __( 'This file calls "React.createFactory", which was removed in React 19 and stops working once WordPress upgrades React. Use JSX or createElement() instead.', 'plugin-check' ), + ), + ); + } + + /** + * Blanks out comments and literals in JavaScript contents. + * + * Characters inside line comments, block comments, quoted strings, and + * regular expression literals are replaced with spaces. The length of the + * contents and every newline are preserved, so match offsets still map to + * the correct line and column in the original file. + * + * The contents are tokenized rather than matched with a single regular + * expression, for two reasons. A regular expression cannot tell a regex + * literal from a division operator, so the quote in `/"/` was read as the + * start of a string and swallowed the code following it. PCRE also gives up + * on the long string literals of a bundled file, which left the contents + * unblanked and reported mentions in comments as calls. + * + * @since 2.2.0 + * + * @param string $contents Contents of the JavaScript file. + * @return string The contents with comments and literals blanked out. + */ + private function blank_comments_and_strings( $contents ) { + $length = strlen( $contents ); + $blanked = ''; + $copied = 0; + $offset = 0; + $after_value = false; + + while ( $offset < $length ) { + // Whitespace does not change which token may come next. + $offset += strspn( $contents, self::WHITESPACE_CHARACTERS, $offset ); + + // Operators and punctuators all expect a value after them. + $punctuation = strcspn( $contents, self::TOKEN_START_CHARACTERS, $offset ); + + if ( $punctuation > 0 ) { + $after_value = false; + $offset += $punctuation; + continue; + } + + if ( $offset >= $length ) { + break; + } + + // Identifiers, keywords, and numbers are consumed whole, so that the + // slash in `return/^a$/.test( s )` is not taken for a division. + $word = strspn( $contents, self::WORD_CHARACTERS, $offset ); + + if ( $word > 0 ) { + $after_value = ! in_array( substr( $contents, $offset, $word ), self::VALUE_EXPECTING_KEYWORDS, true ); + $offset += $word; + continue; + } + + $char = $contents[ $offset ]; + + if ( ')' === $char || ']' === $char ) { + $after_value = true; + ++$offset; + continue; + } + + $end = $this->find_blank_end( $contents, $offset, $after_value ); + + // A division operator, which leaves nothing to blank. + if ( false === $end ) { + ++$offset; + continue; + } + + $blanked .= substr( $contents, $copied, $offset - $copied ); + $blanked .= preg_replace( '/[^\r\n]/', ' ', substr( $contents, $offset, $end - $offset ) ); + $copied = $end; + $offset = $end; + } + + return $blanked . substr( $contents, $copied ); + } + + /** + * Finds the offset just past the end of the comment or literal at an offset. + * + * A slash is the ambiguous case. It opens a comment when another slash or a + * star follows it, and otherwise divides when a value precedes it and opens + * a regular expression when one does not. + * + * @since 2.2.0 + * + * @param string $contents Contents being scanned. + * @param int $offset Offset of the opening character. + * @param bool $after_value Whether a value precedes the offset, updated to + * describe what now precedes the returned offset. + * @return int|false Offset just past the comment or literal, or false when the + * character is a division operator and blanks nothing. + */ + private function find_blank_end( $contents, $offset, &$after_value ) { + $length = strlen( $contents ); + $char = $contents[ $offset ]; + $next = $offset + 1 < $length ? $contents[ $offset + 1 ] : ''; + + if ( '/' === $char && '/' === $next ) { + return $offset + strcspn( $contents, "\r\n", $offset ); + } + + if ( '/' === $char && '*' === $next ) { + $close = strpos( $contents, '*/', $offset + 2 ); + + return false === $close ? $length : $close + 2; + } + + if ( '/' === $char && $after_value ) { + $after_value = false; + + return false; + } + + // Either a quoted string, or a regular expression opened by the slash + // that the checks above have left as the only reading. + $after_value = true; + + return $this->find_literal_end( $contents, $offset, $char ); + } + + /** + * Finds the offset just past the end of a string or regex literal. + * + * @since 2.2.0 + * + * @param string $contents Contents being scanned. + * @param int $start Offset of the opening delimiter. + * @param string $delimiter The delimiter that closes the literal. + * @return int Offset just past the literal, or where it is cut short by the end + * of the line or of the contents. + */ + private function find_literal_end( $contents, $start, $delimiter ) { + $length = strlen( $contents ); + + // Characters that interrupt the literal: an escape, its own delimiter, + // and a line break for the literals that may not span lines. In a regular + // expression a character class opens too, because the slash inside one + // does not close the literal. + if ( '/' === $delimiter ) { + $stops = "\\/[\r\n"; + } elseif ( '`' === $delimiter ) { + $stops = '\\`'; + } else { + $stops = '\\' . $delimiter . "\r\n"; + } + + $offset = $start + 1; + + while ( $offset < $length ) { + $offset += strcspn( $contents, $stops, $offset ); + + if ( $offset >= $length ) { + break; + } + + $char = $contents[ $offset ]; + + if ( '\\' === $char ) { + $offset += 2; + continue; + } + + if ( $delimiter === $char ) { + return $offset + 1; + } + + if ( '[' === $char ) { + $offset = $this->find_character_class_end( $contents, $offset ); + continue; + } + + // Cut short by the end of the line. + return $offset; + } + + return $length; + } + + /** + * Finds the offset just past the end of a regex character class. + * + * @since 2.2.0 + * + * @param string $contents Contents being scanned. + * @param int $start Offset of the opening bracket. + * @return int Offset just past the character class, or where it is cut short by + * the end of the line or of the contents. + */ + private function find_character_class_end( $contents, $start ) { + $length = strlen( $contents ); + $offset = $start + 1; + + while ( $offset < $length ) { + $offset += strcspn( $contents, "\\]\r\n", $offset ); + + if ( $offset >= $length ) { + break; + } + + if ( '\\' === $contents[ $offset ] ) { + $offset += 2; + continue; + } + + // Closed, or cut short by the end of the line. + return ']' === $contents[ $offset ] ? $offset + 1 : $offset; + } + + return $length; + } + + /** + * Finds the first occurrence of a pattern and returns its line and column. + * + * All three line endings are recognized, and a carriage return followed by a + * line feed counts once. The line ending of the file being read is what + * matters here, which is unrelated to the one native to the machine running + * the check. + * + * @since 2.2.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; + } + + $before = substr( $contents, 0, $matches[0][1] ); + $lines = preg_split( '/\r\n|\n|\r/', $before ); + + return array( + 'line' => count( $lines ), + 'column' => strlen( (string) end( $lines ) ) + 1, + ); + } + + /** + * Gets the description for the check. + * + * Every check must have a short description explaining what the check does. + * + * @since 2.2.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.2.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 48878571c..4b9a5d585 100644 --- a/includes/Checker/Default_Check_Repository.php +++ b/includes/Checker/Default_Check_Repository.php @@ -106,6 +106,7 @@ private function register_default_checks() { 'menu_image_icon' => new Checks\Plugin_Repo\Menu_Image_Icon_Check(), 'wp_functions_compatibility' => new Checks\Plugin_Repo\WP_Functions_Compatibility_Check(), 'ai_provider' => new Checks\General\AI_Provider_Check(), + 'react_usage' => new Checks\General\React_Usage_Check(), ) ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/asset-declared.asset.php b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/asset-declared.asset.php new file mode 100644 index 000000000..605b75650 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/asset-declared.asset.php @@ -0,0 +1 @@ + array('react', 'react-jsx-runtime', 'wp-element'), 'version' => 'def456'); 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..e6f45d3f5 --- /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, _owner: null }; + }; +}( {} ) ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/bundled-call.js b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/bundled-call.js new file mode 100644 index 000000000..d81bcbf0a --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/bundled-call.js @@ -0,0 +1,8 @@ +// Build output in which the imported functions are called through a sequence +// expression, which is how a bundler drops the `this` the call would otherwise +// be made with. A closing parenthesis sits between each name and its call. +( function ( r ) { + var container = document.getElementById( 'root' ); + ( 0, r.unstable_renderSubtreeIntoContainer )( window.parent, window.createApp(), container ); + return ( 0, r.findDOMNode )( container ); +}( window.ReactDOM ) ); diff --git a/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/cr-line-endings.js b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/cr-line-endings.js new file mode 100644 index 000000000..ba66da1e9 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/cr-line-endings.js @@ -0,0 +1 @@ +// Classic Mac line endings: every line break in this file is a lone carriage // return, so a position counted with line feeds collapses onto line 1. findDOMNode( document.getElementById( 'root' ) ); \ No newline at end of file diff --git a/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/global-override.js b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/global-override.js new file mode 100644 index 000000000..f9c568250 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/global-override.js @@ -0,0 +1,17 @@ +// Build output that inlines the library and the renderer and then publishes +// both under the globals WordPress uses. Writing a global is not externalizing: +// the build can only publish the copy it carries, and doing so replaces the +// copy WordPress loaded for every script that runs after it. +( function ( exports ) { + var k = Symbol.for( "react.element" ); + var internals = { ReactCurrentOwner: { current: null } }; + exports.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = internals; + exports.createElement = function ( type ) { + return { $$typeof: k, type: type, _owner: internals.ReactCurrentOwner.current }; + }; + exports.render = function ( element, container ) { + container.__reactFiber$abc = element; + }; + window.React = exports; + window.ReactDOM = exports; +}( {} ) ); 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..758cb1015 --- /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, _owner: null }; + } + 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-react-usage-with-errors/load.php b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/load.php new file mode 100644 index 000000000..cc34e1ac1 --- /dev/null +++ b/tests/phpunit/testdata/plugins/test-plugin-react-usage-with-errors/load.php @@ -0,0 +1,16 @@ +run_check( 'test-plugin-react-usage-with-errors' ); + $errors = $check_result->get_errors(); + + $this->assertNotEmpty( $errors ); + $this->assertSame( 11, $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 minifier may leave no quoted string in the file at all. + $this->assertSame( array( 'inlined_react' ), $this->get_codes( $errors, 'template-strings.js' ) ); + + // A development build keeps the same markers in a different shape, and + // is reported no differently from a production one. + $this->assertSame( array( 'inlined_react_jsx_runtime' ), $this->get_codes( $errors, 'jsx-runtime-dev.js' ) ); + $this->assertSame( 5, $this->get_first_message( $errors, 'jsx-runtime.js' )['severity'] ); + + // 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' ) ); + + // Writing the globals is not externalizing, so neither inlined package + // is suppressed by the file publishing itself under them. + $this->assertSame( + array( 'inlined_react', 'inlined_react_dom' ), + $this->get_codes( $errors, 'global-override.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( 8, $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 quote inside a regular expression literal must not be read as the + // start of a string, which would hide the call following it. + $this->assertSame( + array( 'ReactDOM.findDOMNode' ), + $this->get_reported_apis( $warnings, 'regex-literal.js' ) + ); + + // A bundler calls an imported function through a sequence expression, so + // a closing parenthesis stands between the name and the call. + $this->assertSame( + array( 'ReactDOM.findDOMNode', 'ReactDOM.unstable_renderSubtreeIntoContainer' ), + $this->get_reported_apis( $warnings, 'bundled-call.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 ); + + // Positions follow the line endings of the file. Counting the ones native + // to this machine instead would put the call on line 1 of a file written + // with carriage returns alone. + $this->assertSame( array( 3 ), array_keys( $warnings['cr-line-endings.js'] ) ); + } + + public function test_run_without_errors() { + // Of these files only fiber-inspector.js matches a package's markers. It + // is silent because it reads the renderer global, so it is what covers + // the externalization guard; the rest never match a package at all. + $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; + } +}