Skip to content

Commit 5d0d459

Browse files
authored
fix(eslint-plugin-react-dom): add React 19 precedence and blocking attributes, closes #1789 (#1790)
1 parent 595c048 commit 5d0d459

4 files changed

Lines changed: 91 additions & 5 deletions

File tree

plugins/eslint-plugin-react-dom/src/rules/no-unknown-property/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- Added React 19 `precedence` and `blocking` attributes to known properties with version-gated tag checks, preventing false positives on `<style>`, `<link>`, and `<script>` elements.
13+
1014
## [5.2.3-beta.0] - 2026-04-14
1115

1216
### Changed

plugins/eslint-plugin-react-dom/src/rules/no-unknown-property/lib.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,18 @@ export const POPOVER_API_PROPS: string[] = [
911911
"onBeforeToggle",
912912
];
913913

914+
export const REACT_19_PROPS: string[] = [
915+
...POPOVER_API_PROPS,
916+
];
917+
918+
/**
919+
* Tag-specific attributes added in React 19
920+
*/
921+
export const REACT_19_ATTRIBUTE_TAGS_MAP: TagsMap = {
922+
blocking: ["link", "script", "style"],
923+
precedence: ["link", "style"],
924+
};
925+
914926
/**
915927
* Tests React version against a comparator
916928
* @param context ESLint context
@@ -949,14 +961,26 @@ export function getDOMPropertyNames(context: RuleContext<string, unknown[]>): st
949961

950962
// Popover API props were added in React v19.0.0-rc.0
951963
if (testReactVersion(context, ">=", "19.0.0-rc.0")) {
952-
ALL_DOM_PROPERTY_NAMES.push(...POPOVER_API_PROPS);
964+
ALL_DOM_PROPERTY_NAMES.push(...REACT_19_PROPS);
953965
} else {
954-
ALL_DOM_PROPERTY_NAMES.push(...POPOVER_API_PROPS.map((prop) => prop.toLowerCase()));
966+
ALL_DOM_PROPERTY_NAMES.push(...REACT_19_PROPS.map((prop) => prop.toLowerCase()));
955967
}
956968

957969
return ALL_DOM_PROPERTY_NAMES;
958970
}
959971

972+
/**
973+
* Gets the map of attributes to their allowed tags based on React version
974+
* @param context ESLint rule context
975+
* @returns Map of attributes to allowed tags
976+
*/
977+
export function getAttributeTagsMap(context: RuleContext<string, unknown[]>): TagsMap {
978+
if (testReactVersion(context, ">=", "19.0.0-rc.0")) {
979+
return { ...ATTRIBUTE_TAGS_MAP, ...REACT_19_ATTRIBUTE_TAGS_MAP };
980+
}
981+
return ATTRIBUTE_TAGS_MAP;
982+
}
983+
960984
/**
961985
* Check if a node's parent is a JSX tag that is written with lowercase letters,
962986
* and is not a custom web component.

plugins/eslint-plugin-react-dom/src/rules/no-unknown-property/no-unknown-property.spec.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,60 @@ ruleTester.run(RULE_NAME, rule, {
470470
messageId: "invalidPropOnTag",
471471
},
472472
],
473+
}, {
474+
code: '<div precedence="medium" />',
475+
errors: [
476+
{
477+
data: {
478+
name: "precedence",
479+
allowedTags: "link, style",
480+
tagName: "div",
481+
},
482+
messageId: "invalidPropOnTag",
483+
},
484+
],
485+
}, {
486+
code: '<div blocking="render" />',
487+
errors: [
488+
{
489+
data: {
490+
name: "blocking",
491+
allowedTags: "link, script, style",
492+
tagName: "div",
493+
},
494+
messageId: "invalidPropOnTag",
495+
},
496+
],
497+
}, {
498+
code: '<style precedence="default">{`body { color: red; }`}</style>',
499+
settings: {
500+
"react-x": {
501+
version: "18.3.1",
502+
},
503+
},
504+
errors: [
505+
{
506+
data: {
507+
name: "precedence",
508+
},
509+
messageId: "unknownProp",
510+
},
511+
],
512+
}, {
513+
code: '<script blocking="render" />',
514+
settings: {
515+
"react-x": {
516+
version: "18.3.1",
517+
},
518+
},
519+
errors: [
520+
{
521+
data: {
522+
name: "blocking",
523+
},
524+
messageId: "unknownProp",
525+
},
526+
],
473527
}, {
474528
code: tsx`
475529
<div className="App" data-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash-crash:c="customValue">
@@ -596,6 +650,9 @@ ruleTester.run(RULE_NAME, rule, {
596650
},
597651
{ code: '<line fill="pink" x1="0" y1="80" x2="100" y2="20"></line>' },
598652
{ code: '<link as="audio">Audio content</link>' },
653+
{ code: '<link rel="stylesheet" href="styles.css" precedence="medium" />' },
654+
{ code: '<style href="style.css" precedence="default">{`body { color: red; }`}</style>' },
655+
{ code: '<script async src="script.js" blocking="render" />' },
599656
{
600657
code:
601658
'<video controlsList="nodownload" controls={this.controls} loop={true} muted={false} src={this.videoSrc} playsInline={true} onResize={this.onResize}></video>',

plugins/eslint-plugin-react-dom/src/rules/no-unknown-property/no-unknown-property.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { createRule } from "@/utils/create-rule";
33
import { type RuleContext, type RuleFeature, merge } from "@eslint-react/eslint";
44
import {
5-
ATTRIBUTE_TAGS_MAP,
5+
getAttributeTagsMap,
66
getStandardName,
77
getTagName,
88
getText,
@@ -163,8 +163,9 @@ export function create(context: RuleContext<MessageID, Options[]>) {
163163
if (!isValidHTMLTagInJSX(node)) return;
164164

165165
// Check if attribute is allowed only on specific tags
166-
const allowedTags = has(ATTRIBUTE_TAGS_MAP, name)
167-
? ATTRIBUTE_TAGS_MAP[name]
166+
const attributeTagsMap = getAttributeTagsMap(context);
167+
const allowedTags = has(attributeTagsMap, name)
168+
? attributeTagsMap[name]
168169
: null;
169170

170171
if (tagName != null && allowedTags != null) {

0 commit comments

Comments
 (0)