Skip to content

Add React usage check to detect React 19 incompatibilities - #1380

Merged
ernilambar merged 21 commits into
WordPress:trunkfrom
gunjanjaswal:add/inlined-react-runtime-check
Sep 25, 2026
Merged

ernilambar merged 21 commits into
WordPress:trunkfrom
gunjanjaswal:add/inlined-react-runtime-check

Conversation

@gunjanjaswal

@gunjanjaswal gunjanjaswal commented Jun 29, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1356.

Adds a react_usage check that flags plugin builds likely to break when WordPress upgrades to React 19.

It reports errors when a plugin inlines a pre-19 React package (react/jsx-runtime, react, or react-dom) into its bundle instead of loading the copy WordPress ships, since element objects from a pre-19 build are rejected by React 19. It reports warnings for calls to React APIs that React 19 removed, which still work today but stop working after the upgrade.

The detection heuristic is being refined as it goes, so this description stays high-level on purpose.

Open WordPress Playground Preview

AI Usage Disclosure

  • This PR includes AI-assisted code or content

AI assistance (Claude Code) was used to help draft the check and its tests. I reviewed and understand every line and take responsibility for it.

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 WordPress#1356
@github-actions

github-actions Bot commented Jun 29, 2026 •

Copy link
Copy Markdown
Contributor

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: gunjanjaswal <gunjanjaswal@git.wordpress.org>
Co-authored-by: jsnajdr <jsnajdr@git.wordpress.org>
Co-authored-by: swissspidy <swissspidy@git.wordpress.org>
Co-authored-by: ernilambar <nilambar@git.wordpress.org>
Co-authored-by: davidperezgar <davidperez@git.wordpress.org>
Co-authored-by: jonathanbossenger <psykro@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

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.
…runtime-check

# Conflicts:
#	docs/checks.md
#	includes/Checker/Default_Check_Repository.php
@davidperezgar

Copy link
Copy Markdown
Member

Hello, I've got these findings from Codex. Could you check it?

Findings
[P1] Asset dependency suppresses a real inlined-runtime signal
Inlined_React_Runtime_Check.php (line 167) suppresses Symbol.for( 'react.element' ) whenever the sibling asset file contains react-jsx-runtime. But the marker is in the JS file being scanned; an asset dependency only proves the file declares an external dependency, not that it did not also inline a pre-19 runtime. This can miss the exact problem the check is meant to catch, especially with stale/manual asset files or mixed builds. I’d only use react-jsx-runtime in the asset file as supporting context, not as a bypass once the marker is present.

[P2] Removed API regex reports comments/strings as usage
Inlined_React_Runtime_Check.php (line 125) scans raw JS for unmountComponentAtNode, findDOMNode, and ReactCurrentOwner, so a comment, changelog string, translation string, or compatibility message will produce a warning. Since this is registered as a stable default check, that could create noisy false positives. Consider limiting this to property/member access patterns or parsing tokens enough to skip comments and string literals.

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.
@gunjanjaswal

Copy link
Copy Markdown
Contributor Author

Thanks @davidperezgar, both are fair points. I pushed a fix for each.

P1 (asset dependency bypass): You're right that the .asset.php dependency only tells us the file declares react-jsx-runtime, not that it avoided inlining a pre-19 runtime. Since Symbol.for( 'react.element' ) is already a definitive inline marker, trusting the asset file there can hide the exact case the check is meant to catch. I dropped the asset file as a bypass, so now only an in-file window.ReactJSXRuntime reference suppresses the warning. I also added a fixture (asset-declared.js with its .asset.php) that inlines the runtime while declaring the dependency, and it's now flagged correctly.

P2 (comments and strings): Also right. The raw scan would flag unmountComponentAtNode, findDOMNode, or ReactCurrentOwner even inside a comment, changelog line, or translation string. It now blanks out comments and string literals before scanning, keeping the length and newlines intact so the reported line and column stay accurate, and only real usages get flagged. I added a comment-only.js fixture that mentions all three in prose and stays clean.

Happy to adjust either if you'd prefer a different approach.

@gunjanjaswal

Copy link
Copy Markdown
Contributor Author

Bumping this gently. The Inlined React Runtime check has been green for a few weeks now and still mergeable. If anything on trunk has moved under it I'm glad to rebase; otherwise it's ready for a review pass whenever the team has bandwidth.

@jsnajdr jsnajdr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is ready to ship, it will be very useful for the React 19 rollout we are planning for Gutenberg in the WP 7.2 cycle.

@gunjanjaswal

Copy link
Copy Markdown
Contributor Author

Thanks @jsnajdr, and thanks again for strengthening the detection before this landed. Glad it lines up with the WP 7.2 React 19 rollout, that's exactly the kind of breakage this is meant to catch early. Happy to help if anything else comes up as you start testing plugins against it.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved moderate findings affect detection coverage, suppression behavior, result severity, API coverage, and line reporting.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 5 Medium severity

Open (5)
What changed in this PR

Adds a React compatibility check for bundled React runtimes and React 19-incompatible APIs, with registration, documentation, changelog updates, and PHPUnit fixtures.

Changes:

  • Implements React runtime and removed-API detection.
  • Registers and documents the new check.
  • Adds passing and failing fixtures with test coverage.
File Reviewed change
tests/​phpunit/​tests/​Checker/​Checks/​React_Usage_Check_Tests.php Tests React usage detection and suppression behavior.
tests/​phpunit/​testdata/​plugins/​test-plugin-react-usage-without-errors/​view.js Passing React usage fixture.
tests/​phpunit/​testdata/​plugins/​test-plugin-react-usage-without-errors/​react-is.js Passing React detection fixture.
tests/​phpunit/​testdata/​plugins/​test-plugin-react-usage-without-errors/​react-19.js React 19 compatibility fixture.
tests/​phpunit/​testdata/​plugins/​test-plugin-react-usage-without-errors/​modern.js Modern React usage fixture.
tests/​phpunit/​testdata/​plugins/​test-plugin-react-usage-without-errors/​load.php Passing fixture plugin bootstrap.
tests/​phpunit/​testdata/​plugins/​test-plugin-react-usage-without-errors/​comment-only.js Comment-only detection fixture.
tests/​phpunit/​testdata/​plugins/​test-plugin-react-usage-with-errors/​react.js Bundled React incompatibility fixture.
tests/​phpunit/​testdata/​plugins/​test-plugin-react-usage-with-errors/​react-external-dom.js React DOM usage fixture.
tests/​phpunit/​testdata/​plugins/​test-plugin-react-usage-with-errors/​react-dom.js Removed React DOM API fixture.
tests/​phpunit/​testdata/​plugins/​test-plugin-react-usage-with-errors/​react-17-prod.js React 17 runtime fixture.
tests/​phpunit/​testdata/​plugins/​test-plugin-react-usage-with-errors/​load.php Failing fixture plugin bootstrap.
tests/​phpunit/​testdata/​plugins/​test-plugin-react-usage-with-errors/​legacy.js Legacy React API fixture.
tests/​phpunit/​testdata/​plugins/​test-plugin-react-usage-with-errors/​jsx-runtime.js Inlined JSX runtime fixture.
tests/​phpunit/​testdata/​plugins/​test-plugin-react-usage-with-errors/​jsx-runtime-tree-shaken.js Tree-shaken JSX runtime fixture.
tests/​phpunit/​testdata/​plugins/​test-plugin-react-usage-with-errors/​jsx-runtime-dev.js Development JSX runtime fixture.
tests/​phpunit/​testdata/​plugins/​test-plugin-react-usage-with-errors/​hydrate.js Hydration API fixture.
tests/​phpunit/​testdata/​plugins/​test-plugin-react-usage-with-errors/​asset-declared.js Asset-declared runtime fixture.
tests/​phpunit/​testdata/​plugins/​test-plugin-react-usage-with-errors/​asset-declared.asset.php Asset dependency metadata fixture.
readme.txt Adds the changelog entry.
includes/​Checker/​Default_Check_Repository.php Registers the React usage check.
includes/​Checker/​Checks/​Performance/​React_Usage_Check.php Implements React compatibility scanning.
docs/​checks.md Documents the check.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +93 to +95
if ( $this->check_inlined_packages( $result, $file, $contents ) ) {
continue;
}
Comment on lines +138 to +141
// 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.
);
}

$this->add_result_error_for_file(
Comment on lines +319 to +322
* 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.
}

$before = substr( $contents, 0, $offset );
$exploded = explode( PHP_EOL, $before );
@ernilambar

Copy link
Copy Markdown
Member

Reviewed by AI: Opus 5.5

Summary

This PR adds a react_usage static check. It reports errors when a JS file inlines a pre-19 react/jsx-runtime, react or react-dom, and warnings when it calls React APIs that React 19 removed. The primary signal ("react.element" plus a per-package fingerprint) covers the issue's headline case well. However, the package fingerprints are loose enough to produce false errors in a stable, default-on check, and some messages overstate what actually breaks on WordPress.

✅ What's good

  • Two-step detection (element marker + package-specific fingerprint) is a real improvement over the issue's bare Symbol.for("react.element") grep. react-is no longer triggers it (react-is.js fixture).
  • Matching the string literal rather than Symbol.for(...) catches React 17 production builds that hoist Symbol.for into a local (react-17-prod.js).
  • The *.asset.php bypass was dropped as requested in the P1 feedback. asset-declared.js locks that in.
  • The per-package codes (inlined_react_jsx_runtime, inlined_react, inlined_react_dom) give actionable output. Detecting development builds is a nice touch.
  • Blanking comments and strings before the removed-API scan preserves offsets, so line and column stay correct.
  • The fixtures cover mixed builds well: tree-shaken jsxs, and window.ReactDOM not suppressing an inlined react.
  • PHPCS passes on the new files. All CI jobs are green (PHP 7.4–8.4, WP 6.3/latest/trunk, PHPStan, sniffs).

⚠️ Must-fix before merge

  • includes/Checker/Checks/Performance/React_Usage_Check.php:234 — The react/jsx-runtime fingerprint /\bjsxs?\s*[:=][^=]/ matches any jsx key or assignment in the file. Any bundle that contains "react.element" for an unrelated reason (e.g. react-is pulled in via prop-types / hoist-non-react-statics, which is very common) gets an error if it also has:

    • Prism.languages.jsx = …
    • { ecmaFeatures: { jsx: true } }
    • a highlight.js or CodeMirror jsx: alias
    • any local const jsx = … that isn't literally window.ReactJSXRuntime

    I confirmed this with synthetic inputs: Symbol.for("react.element") together with Prism.languages.jsx = … or {jsx:true} is reported as inlined_react_jsx_runtime. The fingerprint needs to be tied to the runtime itself. For example, require the jsx assignment on an exports-like object, or co-occurrence with the runtime's ref/key/_owner element literal or its "react.fragment" export.

  • includes/Checker/Checks/Performance/React_Usage_Check.php:244 — The react fingerprint also matches preact/compat, which defines both Symbol.for('react.element') and __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = {…}. A Preact plugin never touches WordPress's React, yet it would get an error saying it "breaks when WordPress upgrades to React 19".

  • includes/Checker/Checks/Performance/React_Usage_Check.php:188-208 — The errors claim every inlined package "breaks when WordPress upgrades to React 19". That is only true when elements cross the boundary: an inlined runtime or react feeding WordPress's react-dom / @wordpress/components, or the reverse. A fully self-contained React 18 bundle (runtime + react + react-dom all inlined, rendering its own root) gets three errors but keeps working. It's bloat, not breakage. Also, the issue proposed warning severity ("detection is heuristic"), and the PR description still says "Everything's a warning, nothing errors out". The PR now emits errors at severity 6/7. Pick one, and make it explicit:

    • (a) Error only for mixed builds, where at least one of the three packages is inlined and another is externalized; warning otherwise.
    • (b) Warning across the board.

    Either way, the message text must match what actually happens.

  • includes/Checker/Checks/Performance/React_Usage_Check.php:302 — The Gutenberg React 19 work (WordPress/gutenberg#78899, merged) polyfills render, hydrate and unmountComponentAtNode in @wordpress/element and in the react-dom script. A plugin that calls ReactDOM.render/hydrate/unmountComponentAtNode on WordPress's externalized react-dom does not "stop working once WordPress upgrades React". The warning is false for those three. Either reword it for them (deprecated, polyfilled, migrate to createRoot/hydrateRoot/root.unmount()) or give them a separate, softer message. findDOMNode, unstable_renderSubtreeIntoContainer, renderToNodeStream and createFactory are not polyfilled and can keep the current wording.

  • includes/Checker/Checks/Performance/React_Usage_Check.php:352, :367 — The replacement values 'a ref on the element' and 'JSX or createElement()' are English prose substituted into a translated sentence via %2$s, so they can never be translated. Pass only code identifiers through the placeholder (createRoot(), hydrateRoot(), …). For the two prose cases, use a dedicated translatable string, or wrap the prose in __().

  • readme.txt:109 — The changelog entry is added under = 2.0.0 =, but 2.0.0 and 2.1.0 are already tagged and released (plugin.php is at 2.1.0). Move it to the next release section, or leave it for the release PR.

  • includes/Checker/Checks/Performance/React_Usage_Check.php:33 (and every @since 2.0.0 in the file) — Same problem. This code ships in the next release, not 2.0.0. Update @since to the next version.

💡 Suggestions (optional)

  • includes/Checker/Checks/Performance/React_Usage_Check.php:239 — Global suppression treats any window.React reference as proof of externalization. A bundle that inlines React and exposes it with window.React = exports (a common pattern) is therefore silently cleared. I confirmed this false negative. Consider matching only read positions, e.g. rejecting window.React\s*=(?!=).

  • includes/Checker/Checks/Performance/React_Usage_Check.php:229 — Unminified webpack output emits externals as window["ReactJSXRuntime"] / window["React"]. Terser only rewrites them to dot access when minifying. Accepting window\[\s*(['"])ReactJSXRuntime\1\s*\] makes suppression work for development builds too.

  • includes/Checker/Checks/Performance/React_Usage_Check.php:386 — The comment/string blanker has two weaknesses:

    • It does not handle regex literals. var r=/"/g;findDOMNode(e);var s="x"; swallows the findDOMNode call, a false negative I confirmed.
    • On large vendor bundles with long string literals, PCRE hits its recursion limit. On a 3 MB literal it returned null and fell back to the raw contents, which silently brings back the comment/string false positives that P2 fixed.

    Either document this as an accepted limitation, or skip blanking above a size threshold and report nothing for the removed APIs in that file.

  • includes/Checker/Checks/Performance/React_Usage_Check.php:66 — CATEGORY_PERFORMANCE is an odd home for what is mainly a compatibility check. Only the development-build note is performance-related. CATEGORY_GENERAL fits better, unless the team wants a compatibility bucket (issue open question 3).

  • includes/Checker/Checks/Performance/React_Usage_Check.php:46 — The issue asked for a link to the React 19 upgrade post on make.wordpress.org. That is more relevant to WordPress plugin authors than react.dev's generic guide, especially for the inlined_* errors.

  • includes/Checker/Checks/Performance/React_Usage_Check.php:93 — Skipping the removed-API scan for any file with an inlined package also hides the plugin's own findDOMNode calls when only the JSX runtime was inlined. That's acceptable, but worth a line in the docblock since the comment only justifies the react-dom case.

  • tests/phpunit/tests/Checker/Checks/React_Usage_Check_Tests.php — Add negative fixtures for the false-positive shapes above: react-is + jsx: key, preact/compat, and a window["ReactJSXRuntime"] dev build. There is also no fixture proving that window.React / window.ReactDOM suppress their own packages.

  • PR description — It is stale: it still describes inlined_react_runtime, Inlined_React_Runtime_Check_Tests, inlined_jsx_runtime, ReactCurrentOwner detection, *.asset.php suppression and warnings only. Update it so the merge commit and changelog reflect what ships.

Verdict

Request Changes: the detection idea is sound, but a stable, default-on check emitting errors needs tighter fingerprints (JSX-key and preact/compat false positives) and messages that match actual React 19 behavior on WordPress (self-contained bundles, polyfilled render/hydrate/unmountComponentAtNode), plus the version and i18n fixes.

@jsnajdr

jsnajdr commented Sep 23, 2026

Copy link
Copy Markdown
Member

Hi @gunjanjaswal 👋 Could you please change the PR title to:

Add React usage check to detect React 19 incompatibilities

and edit the PR description so that it's more up to date after recent changes. I can be much shorter, it doesn't need to describe any implementation details. They change quickly, as the detection heuristic is improved.

And this is no longer true:

Everything's a warning, nothing errors out

Detected React 19 incompatibilities are marked as errors, as they really have the potential to break the site. plugin-check has many more benign issues marked as errors.

I'll soon propose some code changes as another stacked PR, but I can't modify the PR GitHub info itself.

@gunjanjaswal gunjanjaswal changed the title Add Inlined React Runtime check for React 19 incompatibilities Add React usage check to detect React 19 incompatibilities Sep 23, 2026
@gunjanjaswal

Copy link
Copy Markdown
Contributor Author

Done — retitled to "Add React usage check to detect React 19 incompatibilities" and trimmed the description down. Dropped the implementation walkthrough (it moves too fast to keep in sync) and corrected the severity note: incompatibilities are reported as errors, with warnings only for the removed-API calls. Looking forward to your stacked changes.

@jsnajdr

jsnajdr commented Sep 24, 2026

Copy link
Copy Markdown
Member

Reviewed by AI: Opus 5.5

I'm addressing most of the feedback in the stacked gunjanjaswal#2 PR.

Must fix

  • react/jsx-runtime detection now also requires an _owner: element factory, so a jsx: key from Prism or other libraries is not falsely detected as JSX runtime.
  • preact/compat false positive: react detection now requires the same _owner: marker, which preact/compat does not produce because it builds Preact vnodes.
  • will WordPress break or not? Changing the messages to "will likely break" and keeping them as errors. Bundling React is very risky, the page itself can be fine, but then WordPress can load another React UI on the same admin page, like core-commands. The two Reacts won't like each other. We don't need to run a 100% reliable static analysis to flag something as error. High risk is enough.
  • untranslatable messages: fixed by using custom messages for the two APIs that were using the bad interpolation.
  • fixed the 2.0.0 version mismatches. Removed the changelog entry (release commit will add it), updated the @since comments.

Suggestions

  • more precise detection of window.React = exports: distinguish between reading (counts as externalizing) and assignment (clear signal that bundling happens).
  • blanker weaknesses: regexp was replaced with a token scanner that detects regex literals, and doesn't have the PCRE recursion limit.
  • changed the category from Performance to General. Keeping detected bundling as errors.
  • documented why we skip check for removed APIs after detecting a bundled package. It makes the detection less reliable, and it's an irrelevant issue for a script that bundles React.
  • added fixtures for prism-jsx.js, preact-compat.js and fiber-inspector.js.

Other fixes

  • correctly detect line endings and file positions. Use the file's own line endings rather than PHP_EOL.
  • detect react.element as a template literal, in backtick quotes. Some minifiers do that.
  • detect bundled react-is with a new stale_react_is warning, another upgrade risk.

Not done

  • not implementing the window["React"] detection because the pattern is not used in practice. Bundlers and minifiers minify this to window.React.
  • still reporting the detected bundled React as an error. We have plenty of other errors where the plugin continues to work "just fine". One example is WordPress.WP.EnqueuedResources.NonEnqueuedScript that errors on a literal <script> tag in the HTML output. Such a script works, of course, but it's a risky anti-pattern worth flagging.
  • not linking to a make.wordpress.org post because we don't yet have one that would be 100% relevant and precise about the current situation. Can update in near future.

The check is about compatibility, not performance: all but one of its
results are about code that stops working when WordPress upgrades React.
Only the development-build note is about size and speed.

Follows the directory convention of the other checks, where the directory
and namespace mirror the primary category.
The replacement was substituted into the translated sentence through a
placeholder, which works for the APIs whose replacement is a code
identifier but not for the two that need prose: "a ref on the element"
and "JSX or createElement()" reached the user in English whatever the
locale.

Give those two the complete sentence as their own translatable string and
keep the shared one for the replacements that are code. The reported text
is unchanged.
2.0.0 and 2.1.0 are both released, so @SInCE 2.0.0 claimed the check had
been available for two releases that never contained it. The next release
is 2.2.0.

Drop the changelog entry as well. It was inserted into the released 2.0.0
section, and per docs/releasing.md the changelog is written when the
release is cut, which is why the other checks added since 2.1.0 do not
carry one.
Comments and string literals were blanked with a single regular
expression, which failed in two ways.

It could not tell a regex literal from a division, so the quote in
`var re = /"/g;` opened what looked like a string and blanked the code
after it. Any removed API called later in such a file went unreported.

PCRE also exhausted its recursion limit on the long string literals of a
bundled file. preg_replace_callback then returned null and the fallback
handed back the raw contents, so nothing was blanked at all and a mention
in a comment was reported as a call, undoing what blanking is for.

Walk the contents token by token instead. A slash opens a regular
expression where no value precedes it and divides otherwise, which also
covers `return/^a$/` and a slash inside a character class. There is no
backtracking, so file size no longer matters: a 3 MB literal that used to
defeat PCRE now scans in well under a second.
The fingerprints for `react/jsx-runtime` and `react` matched too readily
once the file mentioned the element symbol for an unrelated reason, which
`react-is` does and which reaches a great many bundles through
`prop-types`. `jsxs?\s*[:=]` then matched a syntax highlighter defining
`Prism.languages.jsx`, or a parser option object holding `jsx: true`, and
reported an error against a plugin with no React problem at all.

The same applies to `preact/compat`, which names the element symbol and
exports React's internals sentinel while creating Preact vnodes. It never
touches the React WordPress ships, yet it was reported as inlining react.

Require `_owner`, a field React 19 dropped from the element object, so a
package is only implicated when the file also builds pre-19 elements. The
renderer is exempt: it consumes elements rather than creating them, so a
file holding only a copy of `react-dom` has no factory, and `__reactFiber$`
is specific enough by itself. That exemption also stops such a file from
being reported as inlining react as well as react-dom.

Verified against react, react-dom and react/jsx-runtime for 16, 17 and 18
in both development and production, and against the bundles of astra-sites
and wp-table-builder, all of which are still detected.
Positions were counted by splitting on PHP_EOL, the line ending native to
the machine running the check. A plugin's line endings have nothing to do
with that, so a file written with line feeds collapsed onto line 1 when
checked on Windows, and a file written with carriage returns alone
collapsed onto line 1 everywhere.

Split on all three line endings instead, counting a carriage return
followed by a line feed once. The regression fixture uses carriage
returns alone, the case that was wrong on every platform and the one
line ending git will not rewrite on checkout.
The guard accepted any mention of window.React as proof that the package
was externalized, reads and writes alike. A build that publishes itself
under the global therefore silenced the very error it should raise: it
can only publish a copy it carries, and it replaces the copy WordPress
loaded for every script that runs after it.

Take a read of the global as proof of externalizing, and let an
assignment anywhere in the file override every read in it.

The packages now carry the name of their global rather than a pattern,
since two patterns are built from it. fiber-inspector.js restores
coverage of the read: it is now the only file among those expected to
stay silent whose silence depends on the guard.
The marker that establishes a pre-19 build is the name of the element
symbol, which was matched only between quotes. Some minifiers rewrite
every string in a bundle as a template literal, leaving a build with no
quoted string in it at all, and such a file passed as having no React
inlined.

Accept the backtick alongside the two quotes. Only this marker read a
delimiter; the rest match bare substrings.
The bundles WordPress ships patch React 19 into accepting the pre-19
element shape and warning about it, so whether an inlined copy fails
outright depends on where its elements end up.

Hedge the claim to match, and record in the class docblock why the
severity is still an error: the patch is there to carry plugins through
the upgrade, not to make the older shape supported.

The removed-API warnings keep saying such a call stops working, because
those APIs are gone from React 19 with nothing standing in for them.
The comment justified the skip for an inlined renderer, where a removed
API found in the file is React's own code, but not for the other two
packages, where the call may well be the plugin's own.

Record the rest of the reasoning: a deprecated call is a small thing to
raise beside a bundled copy of React, and it is not lost, because the
marker goes when the inlined copy does and the next run reports it.
The check graded a development build above a production one, but nothing
recorded why one should outrank the other, and the numbers were close
enough to a submission threshold to matter. Report both at the default
severity and let the message carry the difference.
A development build was detected by the documentation links React embeds
in its warnings, and reported with its own wording. Inlining the package
is the problem either way, so the second message and the sniffing behind
it bought a branch through the reporting path and little else.
@gunjanjaswal

Copy link
Copy Markdown
Contributor Author

Merged your stacked #2, so everything above is now in this PR — the _owner: element-factory gating for react/jsx-runtime, the token scanner, the react-is handling, line-ending fixes, the move to General, and the "will likely break" wording kept as errors. Thanks for driving all of it.

@jsnajdr

jsnajdr commented Sep 24, 2026

Copy link
Copy Markdown
Member

Thanks @gunjanjaswal, there is one more little fix that addresses the "PHP Code Linting" CI failure: #3. Fixes a too high complexity of the blank_comments_and_strings.

@ernilambar

Copy link
Copy Markdown
Member

Can you please check if following issues are valid?

  • includes/Checker/Checks/General/React_Usage_Check.php:492, :497, :502, :507 — Every removed-API pattern requires the name to be followed directly by (. Webpack 5 compiles a named import from an external as (0,r.findDOMNode)(this), with a ) between the name and the call. That covers import { findDOMNode } from 'react-dom' and the same import from @wordpress/element, which is exactly what @wordpress/scripts builds produce. I verified that (0,r.unmountComponentAtNode)(e), (0,r.findDOMNode)(this) and the unminified (0,react_dom__WEBPACK_IMPORTED_MODULE_0__.findDOMNode)(this) all produce no warning. As written, the scan only catches hand-written, unbundled code like the legacy.js / hydrate.js fixtures. Fix: allow an optional closing paren for the four distinctive names, e.g. /\bfindDOMNode\s*\)?\s*\(/. Add a fixture with the (0,x.name)(…) shape. ReactDOM.render/hydrate can't safely take the same treatment ((0,r.render) is far too generic), so say in the docblock that they only match the literal ReactDOM. form.
  • includes/Checker/Checks/General/React_Usage_Check.php:453 — This was raised in the previous round and has had no reply. For ReactDOM.render, ReactDOM.hydrate and unmountComponentAtNode, the shared message says the call "stops working once WordPress upgrades React". Element: add polyfills for render, hydrate, unmountComponentAtNode gutenberg#78899 polyfills exactly these three in @wordpress/element and in the react-dom script, so for a plugin using WordPress's externalized React DOM, that sentence is false. The class docblock (lines 29–34) already takes this nuance into account for element shapes. The removed-API messages should do the same. Either give these three a "deprecated, polyfilled by WordPress, migrate to createRoot()/hydrateRoot()/root.unmount()" message, or reply on the thread explaining why the current wording should stand.

@gunjanjaswal

Copy link
Copy Markdown
Contributor Author

Both are valid, thanks for the careful testing — and sorry the line-453 point sat from last round without a reply.

On the removed-API patterns: you're right that requiring the name immediately before ( misses the @wordpress/scripts external-interop form. (0,r.findDOMNode)(this) and the unminified (0,react_dom__WEBPACK_IMPORTED_MODULE_0__.findDOMNode)(this) slip straight through, so in practice the scan only catches hand-written unbundled code. Allowing an optional closing paren for the four distinctive names, plus a (0,x.name)(…) fixture, is the right fix, and agreed that ReactDOM.render/hydrate can't take the same widening since (0,r.render) is far too generic — that stays literal, documented in the docblock.

On line 453: also agreed. With WordPress/gutenberg#78899 polyfilling render/hydrate/unmountComponentAtNode in @wordpress/element and the react-dom script, "stops working once WordPress upgrades React" is wrong for a plugin on WordPress's externalized React. Those three should get the deprecated-but-polyfilled wording pointing at createRoot()/hydrateRoot()/root.unmount(), matching the nuance the class docblock already carries for element shapes.

@jsnajdr has been authoring the detection through the stacked PRs and has more queued — these two fit naturally there, so we'll fold both into the next one. I'll make sure they land.

@jsnajdr

jsnajdr commented Sep 24, 2026

Copy link
Copy Markdown
Member

Webpack 5 compiles a named import from an external as (0,r.findDOMNode)(this)

This one is real, quite a glaring omission 😨. Fixing in gunjanjaswal#4. It was missed because the corpus of some 10+ plugins that I'm locally testing on always contained a "normal" call to findDOMNode, inside a bundled library.

polyfills exactly these three in @wordpress/element and in the react-dom script, so for a plugin using WordPress's externalized React DOM, that sentence is false

I don't think this is valid. Yes, in a literal sense the sentence is false, because in Gutenberg we patch react-dom to continue supporting the removed APIs. But that's an emergency measure so that we don't break sites, not a signal that everything continues to be OK. The Plugin Check should flag this with a strong language.

@jsnajdr

jsnajdr commented Sep 24, 2026

Copy link
Copy Markdown
Member

OK, we have all the required approvals, CI is green... Anything else needed to merge?

@ernilambar ernilambar added this to the 2.2.0 milestone Sep 25, 2026
@ernilambar
ernilambar merged commit 2f273b7 into WordPress:trunk Sep 25, 2026
28 checks passed
@gunjanjaswal

Copy link
Copy Markdown
Contributor Author

Thanks @ernilambar for merging, and @jsnajdr for all the detection work along the way — the per-package _owner: gating, the bundler-wrapped call detection, the token scanner. Glad it's in ahead of the React 19 rollout.

@gunjanjaswal
gunjanjaswal deleted the add/inlined-react-runtime-check branch September 25, 2026 05:10
@gunjanjaswal

Copy link
Copy Markdown
Contributor Author

Now that this has landed, I'd like to keep helping in this area. @jsnajdr you mentioned a make.wordpress.org post and that the detection heuristic will keep evolving — happy to take on follow-ups there (the polyfilled-API wording, more bundler shapes, fixtures from real plugins), or anything else on the plugin-check side you or @ernilambar would value a hand with. Just point me at it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

6 participants