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
- 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.
- 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?
- Category — Plugin_Repo, Performance, or a dedicated compatibility bucket?
- 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
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-runtimelibrary code instead of externalizing it.The JSX runtime is used by transpiled JSX. Syntax like:
is transpiled into:
and at runtime
jsx()returns an "element" object: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-runtimescript handle, and bundled plugin code should reference it through the globalwindow.ReactJSXRuntime.@wordpress/scripts/@wordpress/dependency-extraction-webpack-plugindoes 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 usesSymbol.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.jsx,jsxs,jsxDEVdefined inline alongside theSymbol.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:
*.asset.phpdependency array listsreact-jsx-runtime(and/orreact,react-dom) — the runtime is provided by core, not bundled.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:
unmountComponentAtNoderoot.unmount()fromcreateRootfindDOMNodehydrate(ReactDOM.hydrate)hydrateRootReactCurrentOwner(
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.:
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:
includes/Checker/Checks/Plugin_Repo/(orPerformance/) extendingAbstract_File_Check, using theStable_CheckandAmend_Check_Resulttraits — same shape asFile_Type_Check, which already fingerprints bundled libraries (jQuery, Backbone, Underscore) by pattern.check_files():filter_files_by_extension( $files, 'js' ), thenfiles_preg_match_all()forSymbol.for("react.element")(allowing for whitespace/quote variations). Optionally read sibling*.asset.phpfiles to apply the externalization suppression.add_result_warning_for_file()with file + line and adocslink.Default_Check_Repository.php.tests/phpunit/tests/Checker/Checks/with fixture plugins intests/phpunit/testdata/plugins/: one with an inlinedSymbol.for("react.element")runtime (should warn), and one that externalizes viareact-jsx-runtime/window.ReactJSXRuntime(should pass). MirrorFile_Type_Check_Tests.Open questions
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.react.transitional.elementanywhere, 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