Skip to content

Enhancement: Add a check to warn about React 19 incompatibilities / bundled outdated React #1356

Description

@jonathanbossenger

WordPress is planning to upgrade its bundled React from 18 to 19.

Real-world compatibility testing in the Gutenberg React 19 polyfill PR (WordPress/gutenberg#78899) showed that large, widely-installed plugins (Yoast SEO, Elementor, Fluent Forms, Sure Cart, Sure Forms, etc.) break under React 19.

~99% of these failures share a single root cause: plugins inline the react/jsx-runtime library code instead of externalizing it.

The JSX runtime is used by transpiled JSX. Syntax like:

<Foo className="foo">hello</Foo>

is transpiled into:

import { jsx } from 'react/jsx-runtime';
jsx( Foo, { className: 'foo' }, 'hello' );

and at runtime jsx() returns an "element" object:

{
  $$typeof: Symbol.for( 'react.element' ),
  type: Foo,
  props: { className: 'foo', children: 'hello' },
}

These objects are then handed to React to render. Between React 18 and 19 the element object shape changed — only slightly, but enough that React 19 rejects objects produced by the React 18 runtime. So if a plugin inlines the React 18 jsx-runtime, it produces incompatible objects and feeds them to WordPress's React 19.

The fix is to always externalize the JSX runtime. WordPress provides the react-jsx-runtime script handle, and bundled plugin code should reference it through the global window.ReactJSXRuntime. @wordpress/scripts / @wordpress/dependency-extraction-webpack-plugin does this automatically; the problem occurs when a plugin's build inlines the runtime instead.

Plugin Check is well placed to catch this before a plugin ships.

Proposal

Add a static-analysis check that scans a plugin's built JavaScript for an inlined JSX runtime, and passes plugins that correctly externalize it.

Primary signal — inlined JSX runtime (high confidence)

The inlined runtime bakes the element tag into the bundle as a literal Symbol.for() call. The string argument survives minification (it is a runtime value, not a renameable identifier), so it is a reliable fingerprint:

  • Symbol.for("react.element") — present when a pre-19 JSX runtime is inlined. React 19's runtime uses Symbol.for("react.transitional.element"), so finding the non-transitional string is strong evidence of an inlined React 18-era runtime being fed to React 19. This is the headline detector.
  • Supporting fingerprints (optional, to raise confidence): the runtime's exported helpers jsx, jsxs, jsxDEV defined inline alongside the Symbol.for("react.element") tag.

"All clear" / suppression — correctly externalized

A properly externalized plugin will not contain the inlined runtime. Use these as suppression signals to keep false positives low:

  • The generated *.asset.php dependency array lists react-jsx-runtime (and/or react, react-dom) — the runtime is provided by core, not bundled.
  • The built JS references window.ReactJSXRuntime (the external global the runtime is mapped to).

If either is present and no inlined-runtime fingerprint is found, the plugin is doing it right.

Secondary signal — removed React APIs (lower volume)

A minority of failures came from removed/changed APIs rather than the runtime. These are distinctive tokens that also survive minification and can be reported with their own message:

Token Issue Guidance
unmountComponentAtNode Removed in React 19 Use root.unmount() from createRoot
findDOMNode Removed in React 19 Use refs
hydrate (ReactDOM.hydrate) Removed in React 19 Use hydrateRoot
ReactCurrentOwner Internal, restructured in React 19 Don't access React internals

(ReactDOM.render / bare .render( should be excluded — too noisy in minified bundles.)

Message / guidance

When the inlined runtime is detected, the message should point at the real fix, e.g.:

This file appears to inline the React JSX runtime (react/jsx-runtime), which is incompatible with WordPress's React 19. Externalize it instead and reference WordPress's react-jsx-runtime script (window.ReactJSXRuntime). When building with @wordpress/scripts, this happens automatically.

Link to the React 19 upgrade post.

Severity

Warning, not error — detection is heuristic (minification, bundler differences, custom builds), and while inlining the runtime is the wrong choice for WordPress, it is not strictly forbidden. (Open to making it an error given how high-confidence the primary signal is — see open questions.)

Implementation notes

Fits the existing check architecture with no new infrastructure:

  • New class under includes/Checker/Checks/Plugin_Repo/ (or Performance/) extending Abstract_File_Check, using the Stable_Check and Amend_Check_Result traits — same shape as File_Type_Check, which already fingerprints bundled libraries (jQuery, Backbone, Underscore) by pattern.
  • Logic in check_files(): filter_files_by_extension( $files, 'js' ), then files_preg_match_all() for Symbol.for("react.element") (allowing for whitespace/quote variations). Optionally read sibling *.asset.php files to apply the externalization suppression.
  • Report via add_result_warning_for_file() with file + line and a docs link.
  • Register the slug in Default_Check_Repository.php.
  • Add a test under tests/phpunit/tests/Checker/Checks/ with fixture plugins in tests/phpunit/testdata/plugins/: one with an inlined Symbol.for("react.element") runtime (should warn), and one that externalizes via react-jsx-runtime / window.ReactJSXRuntime (should pass). Mirror File_Type_Check_Tests.

Open questions

  1. Regex robustness — confirm Symbol.for("react.element") survives across the common build pipelines (@wordpress/scripts, CRA, Vite, esbuild) and quote/whitespace variants. Worth grepping a couple of the known-broken plugin builds (Yoast, Elementor) to validate before finalizing the pattern.
  2. Error vs. warning — given the primary signal is high-confidence and the impact is "breaks on current WordPress," should this be an error rather than a warning?
  3. Category — Plugin_Repo, Performance, or a dedicated compatibility bucket?
  4. Transitional tag — should we also flag react.transitional.element anywhere, or is its presence harmless (it indicates a React 19 runtime, which is fine, though inlining even the v19 runtime is still suboptimal vs. externalizing)?

References

Metadata

Metadata

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions