diff --git a/pkg/analysis/passes/analysis.go b/pkg/analysis/passes/analysis.go
index 874354cb..117843b9 100644
--- a/pkg/analysis/passes/analysis.go
+++ b/pkg/analysis/passes/analysis.go
@@ -38,6 +38,7 @@ import (
"github.com/grafana/plugin-validator/pkg/analysis/passes/pluginname"
"github.com/grafana/plugin-validator/pkg/analysis/passes/provenance"
"github.com/grafana/plugin-validator/pkg/analysis/passes/published"
+ "github.com/grafana/plugin-validator/pkg/analysis/passes/reactcompat"
"github.com/grafana/plugin-validator/pkg/analysis/passes/readme"
"github.com/grafana/plugin-validator/pkg/analysis/passes/restrictivedep"
"github.com/grafana/plugin-validator/pkg/analysis/passes/safelinks"
@@ -90,6 +91,7 @@ var Analyzers = []*analysis.Analyzer{
pluginname.Analyzer,
provenance.Analyzer,
published.Analyzer,
+ reactcompat.Analyzer,
readme.Analyzer,
restrictivedep.Analyzer,
screenshots.Analyzer,
diff --git a/pkg/analysis/passes/reactcompat/reactcompat.go b/pkg/analysis/passes/reactcompat/reactcompat.go
new file mode 100644
index 00000000..73ae2b75
--- /dev/null
+++ b/pkg/analysis/passes/reactcompat/reactcompat.go
@@ -0,0 +1,179 @@
+package reactcompat
+
+import (
+ "bytes"
+ "fmt"
+ "regexp"
+
+ "github.com/grafana/plugin-validator/pkg/analysis"
+ "github.com/grafana/plugin-validator/pkg/analysis/passes/modulejs"
+)
+
+const react19UpgradeGuide = "https://react.dev/blog/2024/04/25/react-19-upgrade-guide"
+
+var (
+ react19PropTypes = &analysis.Rule{Name: "react-19-prop-types", Severity: analysis.Warning}
+ react19LegacyContext = &analysis.Rule{Name: "react-19-legacy-context", Severity: analysis.Warning}
+ react19StringRefs = &analysis.Rule{Name: "react-19-string-refs", Severity: analysis.Warning}
+ react19CreateFactory = &analysis.Rule{Name: "react-19-create-factory", Severity: analysis.Warning}
+ react19FindDOMNode = &analysis.Rule{Name: "react-19-find-dom-node", Severity: analysis.Warning}
+ react19LegacyRender = &analysis.Rule{Name: "react-19-legacy-render", Severity: analysis.Warning}
+ react19SecretInternals = &analysis.Rule{Name: "react-19-secret-internals", Severity: analysis.Warning}
+ react19Compatible = &analysis.Rule{Name: "react-19-compatible", Severity: analysis.OK}
+)
+
+var Analyzer = &analysis.Analyzer{
+ Name: "reactcompat",
+ Requires: []*analysis.Analyzer{modulejs.Analyzer},
+ Run: run,
+ Rules: []*analysis.Rule{
+ react19PropTypes,
+ react19LegacyContext,
+ react19StringRefs,
+ react19CreateFactory,
+ react19FindDOMNode,
+ react19LegacyRender,
+ react19SecretInternals,
+ react19Compatible,
+ },
+ ReadmeInfo: analysis.ReadmeInfo{
+ Name: "React 19 Compatibility",
+ Description: "Detects usage of React APIs removed or deprecated in React 19.",
+ },
+}
+
+// detector checks a single module.js file for a specific pattern.
+type detector interface {
+ Detect(moduleJs []byte) bool
+ Pattern() string
+}
+
+type containsBytesDetector struct {
+ pattern []byte
+}
+
+func (d *containsBytesDetector) Detect(moduleJs []byte) bool {
+ return bytes.Contains(moduleJs, d.pattern)
+}
+
+func (d *containsBytesDetector) Pattern() string {
+ return string(d.pattern)
+}
+
+type regexDetector struct {
+ regex *regexp.Regexp
+}
+
+func (d *regexDetector) Detect(moduleJs []byte) bool {
+ return d.regex.Match(moduleJs)
+}
+
+func (d *regexDetector) Pattern() string {
+ return d.regex.String()
+}
+
+// reactPattern groups a rule, a human-readable description, and the detectors that trigger it.
+type reactPattern struct {
+ rule *analysis.Rule
+ title string
+ description string
+ detectors []detector
+}
+
+var reactPatterns = []reactPattern{
+ {
+ rule: react19PropTypes,
+ title: "module.js: Uses removed React API propTypes or defaultProps",
+ description: "Detected usage of '%s'. propTypes and defaultProps on function components were removed in React 19.",
+ detectors: []detector{
+ &containsBytesDetector{pattern: []byte(".propTypes=")},
+ &containsBytesDetector{pattern: []byte(".defaultProps=")},
+ },
+ },
+ {
+ rule: react19LegacyContext,
+ title: "module.js: Uses removed React legacy context API",
+ description: "Detected usage of '%s'. contextTypes, childContextTypes, and getChildContext were removed in React 19.",
+ detectors: []detector{
+ &containsBytesDetector{pattern: []byte(".contextTypes=")},
+ &containsBytesDetector{pattern: []byte(".childContextTypes=")},
+ &containsBytesDetector{pattern: []byte("getChildContext")},
+ },
+ },
+ {
+ rule: react19StringRefs,
+ title: "module.js: Uses removed React string refs",
+ description: "Detected usage of '%s'. String refs were removed in React 19. Use callback refs or React.createRef() instead.",
+ detectors: []detector{
+ ®exDetector{regex: regexp.MustCompile(`ref:"[^"]+?"`)},
+ ®exDetector{regex: regexp.MustCompile(`ref:'[^']+'`)},
+ },
+ },
+ {
+ rule: react19CreateFactory,
+ title: "module.js: Uses removed React.createFactory",
+ description: "Detected usage of '%s'. React.createFactory was removed in React 19. Use JSX instead.",
+ detectors: []detector{
+ &containsBytesDetector{pattern: []byte("createFactory(")},
+ },
+ },
+ {
+ rule: react19FindDOMNode,
+ title: "module.js: Uses removed ReactDOM.findDOMNode",
+ description: "Detected usage of '%s'. ReactDOM.findDOMNode was removed in React 19. Use DOM refs instead.",
+ detectors: []detector{
+ &containsBytesDetector{pattern: []byte("findDOMNode(")},
+ },
+ },
+ {
+ rule: react19LegacyRender,
+ title: "module.js: Uses removed ReactDOM.render or unmountComponentAtNode",
+ description: "Detected usage of '%s'. ReactDOM.render and unmountComponentAtNode were removed in React 19. Use createRoot instead.",
+ detectors: []detector{
+ &containsBytesDetector{pattern: []byte("ReactDOM.render(")},
+ &containsBytesDetector{pattern: []byte("unmountComponentAtNode(")},
+ },
+ },
+ {
+ rule: react19SecretInternals,
+ title: "module.js: Uses React internal __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED",
+ description: "Detected usage of '%s'. This internal was removed in React 19.",
+ detectors: []detector{
+ &containsBytesDetector{pattern: []byte("__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED")},
+ },
+ },
+}
+
+func run(pass *analysis.Pass) (interface{}, error) {
+ moduleJsMap, ok := pass.ResultOf[modulejs.Analyzer].(map[string][]byte)
+ if !ok || len(moduleJsMap) == 0 {
+ return nil, nil
+ }
+
+ for _, pattern := range reactPatterns {
+ matched := false
+ matchedPattern := ""
+
+ outer:
+ for _, content := range moduleJsMap {
+ for _, d := range pattern.detectors {
+ if d.Detect(content) {
+ matched = true
+ matchedPattern = d.Pattern()
+ break outer
+ }
+ }
+ }
+
+ if matched {
+ pass.ReportResult(
+ pass.AnalyzerName,
+ pattern.rule,
+ pattern.title,
+ fmt.Sprintf(pattern.description+" See: "+react19UpgradeGuide, matchedPattern),
+ )
+ }
+ }
+
+ return nil, nil
+}
diff --git a/pkg/analysis/passes/reactcompat/reactcompat_test.go b/pkg/analysis/passes/reactcompat/reactcompat_test.go
new file mode 100644
index 00000000..9c2e4800
--- /dev/null
+++ b/pkg/analysis/passes/reactcompat/reactcompat_test.go
@@ -0,0 +1,267 @@
+package reactcompat
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/grafana/plugin-validator/pkg/analysis"
+ "github.com/grafana/plugin-validator/pkg/analysis/passes/modulejs"
+ "github.com/grafana/plugin-validator/pkg/testpassinterceptor"
+)
+
+func newPass(interceptor *testpassinterceptor.TestPassInterceptor, content map[string][]byte) *analysis.Pass {
+ return &analysis.Pass{
+ RootDir: filepath.Join("./"),
+ ResultOf: map[*analysis.Analyzer]interface{}{
+ modulejs.Analyzer: content,
+ },
+ Report: interceptor.ReportInterceptor(),
+ }
+}
+
+func TestCleanPlugin(t *testing.T) {
+ var interceptor testpassinterceptor.TestPassInterceptor
+ pass := newPass(&interceptor, map[string][]byte{
+ "module.js": []byte(`import { PanelPlugin } from '@grafana/data'`),
+ })
+
+ _, err := Analyzer.Run(pass)
+ require.NoError(t, err)
+ // No warnings; the OK rule only fires when ReportAll is set.
+ require.Len(t, interceptor.Diagnostics, 0)
+}
+
+func TestNoModuleJs(t *testing.T) {
+ var interceptor testpassinterceptor.TestPassInterceptor
+ pass := &analysis.Pass{
+ RootDir: filepath.Join("./"),
+ ResultOf: map[*analysis.Analyzer]interface{}{
+ modulejs.Analyzer: map[string][]byte{},
+ },
+ Report: interceptor.ReportInterceptor(),
+ }
+
+ _, err := Analyzer.Run(pass)
+ require.NoError(t, err)
+ require.Len(t, interceptor.Diagnostics, 0)
+}
+
+func TestPropTypes(t *testing.T) {
+ var interceptor testpassinterceptor.TestPassInterceptor
+ pass := newPass(&interceptor, map[string][]byte{
+ "module.js": []byte(`MyComponent.propTypes={name:PropTypes.string}`),
+ })
+
+ _, err := Analyzer.Run(pass)
+ require.NoError(t, err)
+ require.Len(t, interceptor.Diagnostics, 1)
+ require.Equal(t, "react-19-prop-types", interceptor.Diagnostics[0].Name)
+ require.Equal(t, analysis.Warning, interceptor.Diagnostics[0].Severity)
+}
+
+func TestDefaultProps(t *testing.T) {
+ var interceptor testpassinterceptor.TestPassInterceptor
+ pass := newPass(&interceptor, map[string][]byte{
+ "module.js": []byte(`MyComponent.defaultProps={name:"default"}`),
+ })
+
+ _, err := Analyzer.Run(pass)
+ require.NoError(t, err)
+ require.Len(t, interceptor.Diagnostics, 1)
+ require.Equal(t, "react-19-prop-types", interceptor.Diagnostics[0].Name)
+}
+
+func TestContextTypes(t *testing.T) {
+ var interceptor testpassinterceptor.TestPassInterceptor
+ pass := newPass(&interceptor, map[string][]byte{
+ "module.js": []byte(`MyComponent.contextTypes={theme:PropTypes.object}`),
+ })
+
+ _, err := Analyzer.Run(pass)
+ require.NoError(t, err)
+ require.Len(t, interceptor.Diagnostics, 1)
+ require.Equal(t, "react-19-legacy-context", interceptor.Diagnostics[0].Name)
+}
+
+func TestChildContextTypes(t *testing.T) {
+ var interceptor testpassinterceptor.TestPassInterceptor
+ pass := newPass(&interceptor, map[string][]byte{
+ "module.js": []byte(`MyComponent.childContextTypes={theme:PropTypes.object}`),
+ })
+
+ _, err := Analyzer.Run(pass)
+ require.NoError(t, err)
+ require.Len(t, interceptor.Diagnostics, 1)
+ require.Equal(t, "react-19-legacy-context", interceptor.Diagnostics[0].Name)
+}
+
+func TestGetChildContext(t *testing.T) {
+ var interceptor testpassinterceptor.TestPassInterceptor
+ pass := newPass(&interceptor, map[string][]byte{
+ "module.js": []byte(`getChildContext(){return{theme:this.state.theme}}`),
+ })
+
+ _, err := Analyzer.Run(pass)
+ require.NoError(t, err)
+ require.Len(t, interceptor.Diagnostics, 1)
+ require.Equal(t, "react-19-legacy-context", interceptor.Diagnostics[0].Name)
+}
+
+func TestStringRefsDoubleQuote(t *testing.T) {
+ var interceptor testpassinterceptor.TestPassInterceptor
+ pass := newPass(&interceptor, map[string][]byte{
+ "module.js": []byte(``),
+ })
+
+ _, err := Analyzer.Run(pass)
+ require.NoError(t, err)
+ require.Len(t, interceptor.Diagnostics, 1)
+ require.Equal(t, "react-19-string-refs", interceptor.Diagnostics[0].Name)
+}
+
+func TestStringRefsSingleQuote(t *testing.T) {
+ var interceptor testpassinterceptor.TestPassInterceptor
+ pass := newPass(&interceptor, map[string][]byte{
+ "module.js": []byte(``),
+ })
+
+ _, err := Analyzer.Run(pass)
+ require.NoError(t, err)
+ require.Len(t, interceptor.Diagnostics, 1)
+ require.Equal(t, "react-19-string-refs", interceptor.Diagnostics[0].Name)
+}
+
+func TestStringRefsNearMiss(t *testing.T) {
+ // ref: without quotes around the value should not trigger
+ var interceptor testpassinterceptor.TestPassInterceptor
+ pass := newPass(&interceptor, map[string][]byte{
+ "module.js": []byte(`ref:someVariable`),
+ })
+
+ _, err := Analyzer.Run(pass)
+ require.NoError(t, err)
+ require.Len(t, interceptor.Diagnostics, 0)
+}
+
+func TestCreateFactory(t *testing.T) {
+ var interceptor testpassinterceptor.TestPassInterceptor
+ pass := newPass(&interceptor, map[string][]byte{
+ "module.js": []byte(`var el=React.createFactory(MyComponent)`),
+ })
+
+ _, err := Analyzer.Run(pass)
+ require.NoError(t, err)
+ require.Len(t, interceptor.Diagnostics, 1)
+ require.Equal(t, "react-19-create-factory", interceptor.Diagnostics[0].Name)
+}
+
+func TestFindDOMNode(t *testing.T) {
+ var interceptor testpassinterceptor.TestPassInterceptor
+ pass := newPass(&interceptor, map[string][]byte{
+ "module.js": []byte(`var node=ReactDOM.findDOMNode(this)`),
+ })
+
+ _, err := Analyzer.Run(pass)
+ require.NoError(t, err)
+ require.Len(t, interceptor.Diagnostics, 1)
+ require.Equal(t, "react-19-find-dom-node", interceptor.Diagnostics[0].Name)
+}
+
+func TestReactDOMRender(t *testing.T) {
+ var interceptor testpassinterceptor.TestPassInterceptor
+ pass := newPass(&interceptor, map[string][]byte{
+ "module.js": []byte(`ReactDOM.render(,document.getElementById("root"))`),
+ })
+
+ _, err := Analyzer.Run(pass)
+ require.NoError(t, err)
+ require.Len(t, interceptor.Diagnostics, 1)
+ require.Equal(t, "react-19-legacy-render", interceptor.Diagnostics[0].Name)
+}
+
+func TestUnmountComponentAtNode(t *testing.T) {
+ var interceptor testpassinterceptor.TestPassInterceptor
+ pass := newPass(&interceptor, map[string][]byte{
+ "module.js": []byte(`ReactDOM.unmountComponentAtNode(container)`),
+ })
+
+ _, err := Analyzer.Run(pass)
+ require.NoError(t, err)
+ require.Len(t, interceptor.Diagnostics, 1)
+ require.Equal(t, "react-19-legacy-render", interceptor.Diagnostics[0].Name)
+}
+
+func TestLegacyRenderNearMiss(t *testing.T) {
+ // .render( without the ReactDOM. prefix should not trigger the legacy-render rule
+ var interceptor testpassinterceptor.TestPassInterceptor
+ pass := newPass(&interceptor, map[string][]byte{
+ "module.js": []byte(`component.render(props)`),
+ })
+
+ _, err := Analyzer.Run(pass)
+ require.NoError(t, err)
+ require.Len(t, interceptor.Diagnostics, 0)
+}
+
+func TestSecretInternals(t *testing.T) {
+ var interceptor testpassinterceptor.TestPassInterceptor
+ pass := newPass(&interceptor, map[string][]byte{
+ "module.js": []byte(`React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner`),
+ })
+
+ _, err := Analyzer.Run(pass)
+ require.NoError(t, err)
+ require.Len(t, interceptor.Diagnostics, 1)
+ require.Equal(t, "react-19-secret-internals", interceptor.Diagnostics[0].Name)
+}
+
+func TestMultipleIssues(t *testing.T) {
+ // A bundle that hits several distinct rules should produce one diagnostic per rule.
+ var interceptor testpassinterceptor.TestPassInterceptor
+ pass := newPass(&interceptor, map[string][]byte{
+ "module.js": []byte(
+ `MyComponent.propTypes={name:PropTypes.string}` +
+ `ReactDOM.render(,document.getElementById("root"))` +
+ `var node=ReactDOM.findDOMNode(this)`,
+ ),
+ })
+
+ _, err := Analyzer.Run(pass)
+ require.NoError(t, err)
+ require.Len(t, interceptor.Diagnostics, 3)
+
+ names := make([]string, 0, 3)
+ for _, d := range interceptor.Diagnostics {
+ names = append(names, d.Name)
+ }
+ require.Contains(t, names, "react-19-prop-types")
+ require.Contains(t, names, "react-19-legacy-render")
+ require.Contains(t, names, "react-19-find-dom-node")
+}
+
+func TestDetailContainsUpgradeGuideLink(t *testing.T) {
+ var interceptor testpassinterceptor.TestPassInterceptor
+ pass := newPass(&interceptor, map[string][]byte{
+ "module.js": []byte(`MyComponent.propTypes={}`),
+ })
+
+ _, err := Analyzer.Run(pass)
+ require.NoError(t, err)
+ require.Len(t, interceptor.Diagnostics, 1)
+ require.Contains(t, interceptor.Diagnostics[0].Detail, react19UpgradeGuide)
+}
+
+func TestEachRuleReportedOnceEvenWithMultipleMatches(t *testing.T) {
+ // Both .propTypes= and .defaultProps= match the same rule; only one diagnostic should be emitted.
+ var interceptor testpassinterceptor.TestPassInterceptor
+ pass := newPass(&interceptor, map[string][]byte{
+ "module.js": []byte(`MyComponent.propTypes={} MyComponent.defaultProps={}`),
+ })
+
+ _, err := Analyzer.Run(pass)
+ require.NoError(t, err)
+ require.Len(t, interceptor.Diagnostics, 1)
+ require.Equal(t, "react-19-prop-types", interceptor.Diagnostics[0].Name)
+}