Skip to content

Commit 8c24ec9

Browse files
mirkaciampo
andauthored
Add no-unsafe-render-order ESLint rule (#77428)
* Add lint rule for unsafe UI render order * Broaden `VisuallyHidden` rule * Update docs * Rename no-unsafe-render-order rule * Simplify no-unsafe-render-order * Move no-unsafe-render-order to custom config * Add changelog * Mark no-unsafe-render-order as recommended in README Co-authored-by: mirka <0mirka00@git.wordpress.org> Co-authored-by: ciampo <mciampini@git.wordpress.org>
1 parent 68fd59c commit 8c24ec9

9 files changed

Lines changed: 372 additions & 31 deletions

File tree

eslint.config.mjs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,17 @@ export default dedupePlugins( [
618618
},
619619
},
620620

621+
// Override: UI src — check local imports for unsafe render order rule.
622+
{
623+
files: [ 'packages/ui/src/**' ],
624+
rules: {
625+
'@wordpress/no-unsafe-render-order': [
626+
'error',
627+
{ checkLocalImports: true },
628+
],
629+
},
630+
},
631+
621632
// Override: Components src (non-test, non-stories) — DOM globals rules.
622633
{
623634
files: [ 'packages/components/src/**' ],

packages/eslint-plugin/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### New Features
6+
7+
- Added [`no-unsafe-render-order`](https://github.com/WordPress/gutenberg/blob/HEAD/packages/eslint-plugin/docs/rules/no-unsafe-render-order.md) rule to flag unsafe render-prop composition with `VisuallyHidden` and `Link`/`Text` ([#77428](https://github.com/WordPress/gutenberg/pull/77428)).
8+
59
## 25.0.0 (2026-04-15)
610

711
### Breaking Changes

packages/eslint-plugin/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ The granular rulesets will not define any environment globals. As such, if they
127127
| [no-dom-globals-in-react-fc](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-dom-globals-in-react-fc.md) | Disallow use of DOM globals in the render cycle of a React function component. | |
128128
| [components-no-missing-40px-size-prop](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/components-no-missing-40px-size-prop.md) | Disallow missing `__next40pxDefaultSize` prop on `@wordpress/components` components. ||
129129
| [components-no-unsafe-button-disabled](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/components-no-unsafe-button-disabled.md) | Disallow using `disabled` on Button without `accessibleWhenDisabled`. ||
130+
| [no-unsafe-render-order](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unsafe-render-order.md) | Prevent unsafe `render` composition orders that silently remove semantics. ||
130131
| [no-i18n-in-save](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-i18n-in-save.md) | Disallow translation functions in block save methods. | |
131132
| [no-unmerged-classname](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unmerged-classname.md) | Disallow unmerged `className` in components that spread rest props. | |
132133
| [no-unguarded-get-range-at](https://github.com/WordPress/gutenberg/tree/HEAD/packages/eslint-plugin/docs/rules/no-unguarded-get-range-at.md) | Disallow the usage of unguarded `getRangeAt` calls. ||

packages/eslint-plugin/configs/custom.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ module.exports = [
1616
'@wordpress/no-unguarded-get-range-at': 'error',
1717
'@wordpress/no-global-active-element': 'error',
1818
'@wordpress/no-global-get-selection': 'error',
19+
'@wordpress/no-unsafe-render-order': 'error',
1920
'@wordpress/no-setting-ds-tokens': 'error',
2021
'@wordpress/no-unknown-ds-tokens': 'error',
2122
'@wordpress/no-unsafe-wp-apis': 'error',
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Prevent Unsafe `render` Composition Order (`no-unsafe-render-order`)
2+
3+
Prevents `render` prop composition patterns that silently change the final DOM element and strip useful semantics.
4+
5+
This rule currently covers two patterns:
6+
7+
- No component should host `render={ <VisuallyHidden /> }`, because that replaces the host element with `VisuallyHidden`'s default `<div>`.
8+
- `Link` should not host `render={ <Text /> }`, because that replaces the anchor with `Text`'s default `<span>`.
9+
10+
## Rule details
11+
12+
Examples of **incorrect** code for this rule:
13+
14+
```jsx
15+
import { Dialog, Link, Text, VisuallyHidden } from '@wordpress/ui';
16+
17+
<Dialog.Title render={ <VisuallyHidden /> }>Title</Dialog.Title>;
18+
<CustomThing render={ <VisuallyHidden /> }>Hidden content</CustomThing>;
19+
<Link href="#" render={ <Text /> }>
20+
Read more
21+
</Link>;
22+
```
23+
24+
Examples of **correct** code for this rule:
25+
26+
```jsx
27+
import { Dialog, Link, Text, VisuallyHidden } from '@wordpress/ui';
28+
29+
<VisuallyHidden render={ <Dialog.Title /> }>Title</VisuallyHidden>;
30+
<Text render={ <Link href="#" /> }>Read more</Text>;
31+
```
32+
33+
## Options
34+
35+
### `checkLocalImports`
36+
37+
When set to `true`, the rule also checks tracked components imported from relative paths. This is useful inside packages where the components are imported locally instead of from a package entrypoint.
38+
39+
```json
40+
{
41+
"@wordpress/no-unsafe-render-order": [
42+
"error",
43+
{ "checkLocalImports": true }
44+
]
45+
}
46+
```
47+
48+
## Important notes
49+
50+
- By default, the rule checks `VisuallyHidden`, `Text`, and `Link` when they are imported from `@wordpress/ui`.
51+
- Named import aliases such as `import { Link as UILink }` are tracked.
52+
- When `checkLocalImports` is enabled, the rule also tracks local named imports for the covered patterns.
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { RuleTester } from 'eslint';
2+
import rule from '../no-unsafe-render-order';
3+
4+
const ruleTester = new RuleTester( {
5+
languageOptions: {
6+
sourceType: 'module',
7+
ecmaVersion: 6,
8+
parserOptions: {
9+
ecmaFeatures: {
10+
jsx: true,
11+
},
12+
},
13+
},
14+
} );
15+
16+
ruleTester.run( 'no-unsafe-render-order', rule, {
17+
valid: [
18+
{
19+
code: `
20+
import { Dialog, VisuallyHidden } from '@wordpress/ui';
21+
22+
<VisuallyHidden render={ <Dialog.Title /> }>
23+
Title
24+
</VisuallyHidden>;
25+
`,
26+
},
27+
{
28+
code: `
29+
import { Text, Link } from '@wordpress/ui';
30+
31+
<Text render={ <Link href="#" /> }>Read more</Text>;
32+
`,
33+
},
34+
{
35+
code: `
36+
import { Popover } from '@wordpress/ui';
37+
import { VisuallyHidden } from 'some-other-package';
38+
39+
<Popover.Title render={ <VisuallyHidden /> }>
40+
Title
41+
</Popover.Title>;
42+
`,
43+
},
44+
{
45+
code: `
46+
import { Link } from '@wordpress/ui';
47+
48+
<Link href="#">Read more</Link>;
49+
`,
50+
},
51+
{
52+
code: `
53+
import { Link } from '@wordpress/ui';
54+
import { VisuallyHidden } from 'some-other-package';
55+
56+
<Link href="#" render={ <VisuallyHidden /> }>
57+
Read more
58+
</Link>;
59+
`,
60+
},
61+
],
62+
invalid: [
63+
{
64+
code: `
65+
import { Dialog, VisuallyHidden } from '@wordpress/ui';
66+
67+
<Dialog.Title render={ <VisuallyHidden /> }>
68+
Title
69+
</Dialog.Title>;
70+
`,
71+
errors: [ { messageId: 'visuallyHiddenOrder' } ],
72+
},
73+
{
74+
code: `
75+
import { Dialog as UIDialog, VisuallyHidden as Hidden } from '@wordpress/ui';
76+
77+
<UIDialog.Title render={ <Hidden /> }>
78+
Title
79+
</UIDialog.Title>;
80+
`,
81+
errors: [ { messageId: 'visuallyHiddenOrder' } ],
82+
},
83+
{
84+
code: `
85+
import { VisuallyHidden } from '@wordpress/ui';
86+
87+
<CustomThing render={ <VisuallyHidden /> }>
88+
Hidden content
89+
</CustomThing>;
90+
`,
91+
errors: [ { messageId: 'visuallyHiddenOrder' } ],
92+
},
93+
{
94+
code: `
95+
import { Link, Text } from '@wordpress/ui';
96+
97+
<Link href="#" render={ <Text /> }>
98+
Read more
99+
</Link>;
100+
`,
101+
errors: [ { messageId: 'linkTextOrder' } ],
102+
},
103+
{
104+
code: `
105+
import { Link as UILink, Text as UIText } from '@wordpress/ui';
106+
107+
<UILink href="#" render={ <UIText /> }>
108+
Read more
109+
</UILink>;
110+
`,
111+
errors: [ { messageId: 'linkTextOrder' } ],
112+
},
113+
{
114+
code: `
115+
import * as Field from '../index';
116+
import { VisuallyHidden } from '../../../visually-hidden';
117+
118+
<Field.Label render={ <VisuallyHidden /> }>Name</Field.Label>;
119+
`,
120+
options: [ { checkLocalImports: true } ],
121+
errors: [ { messageId: 'visuallyHiddenOrder' } ],
122+
},
123+
{
124+
code: `
125+
import { Link } from '../index';
126+
import { Text } from '../../text';
127+
128+
<Link href="#" render={ <Text /> }>
129+
Read more
130+
</Link>;
131+
`,
132+
options: [ { checkLocalImports: true } ],
133+
errors: [ { messageId: 'linkTextOrder' } ],
134+
},
135+
],
136+
} );
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/**
2+
* Components tracked by this rule.
3+
*
4+
* @type {Set<string>}
5+
*/
6+
const TRACKED_COMPONENTS = new Set( [ 'Link', 'Text', 'VisuallyHidden' ] );
7+
8+
/**
9+
* @type {import('eslint').Rule.RuleModule}
10+
*/
11+
module.exports = {
12+
meta: {
13+
type: 'problem',
14+
docs: {
15+
description:
16+
'Prevent render-prop composition orders that silently remove semantics.',
17+
url: 'https://github.com/WordPress/gutenberg/blob/HEAD/packages/eslint-plugin/docs/rules/no-unsafe-render-order.md',
18+
},
19+
schema: [
20+
{
21+
type: 'object',
22+
properties: {
23+
checkLocalImports: {
24+
type: 'boolean',
25+
description:
26+
'When true, also checks tracked components imported from relative paths.',
27+
},
28+
},
29+
additionalProperties: false,
30+
},
31+
],
32+
messages: {
33+
visuallyHiddenOrder:
34+
'Do not pass `VisuallyHidden` via `render`. Make `VisuallyHidden` the outer component instead.',
35+
linkTextOrder:
36+
'Use `Text` as the outer component and pass `Link` via `render` so the resulting element stays an `<a>`.',
37+
},
38+
},
39+
40+
create( context ) {
41+
const checkLocalImports =
42+
context.options[ 0 ]?.checkLocalImports ?? false;
43+
const trackedImports = new Map();
44+
45+
/**
46+
* @param {string} source
47+
* @return {boolean} Whether the import should be tracked.
48+
*/
49+
function shouldTrackImportSource( source ) {
50+
if ( source === '@wordpress/ui' ) {
51+
return true;
52+
}
53+
54+
if ( checkLocalImports ) {
55+
return source.startsWith( '.' ) || source.startsWith( '/' );
56+
}
57+
58+
return false;
59+
}
60+
61+
/**
62+
* @param {import('estree-jsx').JSXIdentifier|import('estree-jsx').JSXMemberExpression} node
63+
* @return {string|null} Tracked component name or null.
64+
*/
65+
function resolveTrackedJsxName( node ) {
66+
if ( node.type !== 'JSXIdentifier' ) {
67+
return null;
68+
}
69+
70+
return trackedImports.get( node.name ) ?? null;
71+
}
72+
73+
/**
74+
* @param {Array} attributes JSX attributes to inspect.
75+
* @param {string} attributeName Attribute name to match.
76+
* @return {import('estree-jsx').JSXAttribute|undefined} Matching attribute.
77+
*/
78+
function getJsxAttribute( attributes, attributeName ) {
79+
return attributes.find(
80+
( attribute ) =>
81+
attribute.type === 'JSXAttribute' &&
82+
attribute.name?.name === attributeName
83+
);
84+
}
85+
86+
/**
87+
* @param {Array} attributes
88+
* @return {string|null} Resolved JSX name inside `render={ <... /> }`.
89+
*/
90+
function getRenderedComponentName( attributes ) {
91+
const renderAttribute = getJsxAttribute( attributes, 'render' );
92+
93+
if (
94+
! renderAttribute?.value ||
95+
renderAttribute.value.type !== 'JSXExpressionContainer' ||
96+
renderAttribute.value.expression.type !== 'JSXElement'
97+
) {
98+
return null;
99+
}
100+
101+
return resolveTrackedJsxName(
102+
renderAttribute.value.expression.openingElement.name
103+
);
104+
}
105+
106+
return {
107+
ImportDeclaration( node ) {
108+
const source = node.source.value;
109+
110+
if (
111+
typeof source !== 'string' ||
112+
! shouldTrackImportSource( source )
113+
) {
114+
return;
115+
}
116+
117+
node.specifiers.forEach( ( specifier ) => {
118+
if ( specifier.type === 'ImportSpecifier' ) {
119+
const importedName = specifier.imported.name;
120+
121+
if ( TRACKED_COMPONENTS.has( importedName ) ) {
122+
trackedImports.set(
123+
specifier.local.name,
124+
importedName
125+
);
126+
}
127+
}
128+
} );
129+
},
130+
131+
JSXOpeningElement( node ) {
132+
const renderedComponentName = getRenderedComponentName(
133+
node.attributes
134+
);
135+
136+
if ( renderedComponentName === 'VisuallyHidden' ) {
137+
context.report( {
138+
node,
139+
messageId: 'visuallyHiddenOrder',
140+
} );
141+
return;
142+
}
143+
144+
const elementName = resolveTrackedJsxName( node.name );
145+
146+
if (
147+
elementName === 'Link' &&
148+
renderedComponentName === 'Text'
149+
) {
150+
context.report( {
151+
node,
152+
messageId: 'linkTextOrder',
153+
} );
154+
}
155+
},
156+
};
157+
},
158+
};

0 commit comments

Comments
 (0)