@nxds/eslint-plugin 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/package.json +1 -1
- package/src/index.js +2 -0
- package/src/rules/no-react-19-only.js +280 -0
- package/src/rules/nx-class-conventions.js +2 -2
package/README.md
CHANGED
|
@@ -32,6 +32,22 @@ Enforces `.claude/rules/extract-inline-handlers.md`: inline JSX handler props (`
|
|
|
32
32
|
|
|
33
33
|
Enforces `.claude/rules/code-comments.md`: JSDoc blocks must not carry `@default` or `@example` tags. The docs read prop defaults from code and usage from `docs` stories, so a tag is a second copy that can drift. Line comments and plain block comments are not checked. Wired in the root `eslint.config.js` for `packages/react/src` and `packages/vue/src`.
|
|
34
34
|
|
|
35
|
+
### `@nxds/no-react-19-only`
|
|
36
|
+
|
|
37
|
+
Reports React 19-only APIs in `packages/react`, the source `packages/react-18` is generated from. The generator adds `forwardRef` and changes nothing else, so a React 19-only call would compile there and then break on React 18. It reports:
|
|
38
|
+
|
|
39
|
+
| Pattern | Use instead |
|
|
40
|
+
| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
|
41
|
+
| `use`, `useActionState`, `useOptimistic`, `useEffectEvent`, `Activity`, `captureOwnerStack` from `react` | An API React 18.3 has |
|
|
42
|
+
| `useFormStatus`, `requestFormReset`, `preload*`, `preinit*`, `preconnect`, `prefetchDNS` from `react-dom` | An API React 18.3 has |
|
|
43
|
+
| `<SomeContext value={…}>` | `<SomeContext.Provider value={…}>` |
|
|
44
|
+
| A ref callback that returns a cleanup | The node in state, with setup and teardown in an effect |
|
|
45
|
+
| `useDeferredValue(value, initialValue)` | One argument |
|
|
46
|
+
| A function passed to `action` or `formAction` | `onSubmit` or `onClick` |
|
|
47
|
+
| `<title>` outside an `<svg>`, `<meta>`, `<link>`, `<style precedence>` | Metadata the app's framework renders |
|
|
48
|
+
|
|
49
|
+
Wired in the root `eslint.config.js` for `packages/react/src` only. It is not in `nxdsComponentConfig`, because a React 19 app may use these APIs.
|
|
50
|
+
|
|
35
51
|
## Source of truth
|
|
36
52
|
|
|
37
53
|
The token-layer rules are self-contained: the canonical step set is `src/canonical-step-set.json`. The component rules map 1:1 to the `.claude/rules/*.md` cited above — the rule code plus those docs are the spec. Every rule has RuleTester coverage in `__tests__/`.
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createRequire } from 'node:module';
|
|
|
3
3
|
import canonicalSpacingSteps from './rules/canonical-spacing-steps.js';
|
|
4
4
|
import noJsdocDataTags from './rules/no-jsdoc-data-tags.js';
|
|
5
5
|
import noMultiStatementJsxHandler from './rules/no-multi-statement-jsx-handler.js';
|
|
6
|
+
import noReact19Only from './rules/no-react-19-only.js';
|
|
6
7
|
import noRenderPropTypes from './rules/no-render-prop-types.js';
|
|
7
8
|
import nxClassConventions from './rules/nx-class-conventions.js';
|
|
8
9
|
|
|
@@ -18,6 +19,7 @@ const plugin = {
|
|
|
18
19
|
'no-render-prop-types': noRenderPropTypes,
|
|
19
20
|
'no-multi-statement-jsx-handler': noMultiStatementJsxHandler,
|
|
20
21
|
'no-jsdoc-data-tags': noJsdocDataTags,
|
|
22
|
+
'no-react-19-only': noReact19Only,
|
|
21
23
|
},
|
|
22
24
|
};
|
|
23
25
|
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// `packages/react-18` is generated from `packages/react` by wrapping each
|
|
2
|
+
// component in `forwardRef`, and nothing else changes. So `packages/react`
|
|
3
|
+
// must not use anything React 18 lacks: the generated copy would compile and
|
|
4
|
+
// then break at runtime. This rule reports the React 19-only APIs and
|
|
5
|
+
// behaviours an nxds component could reach for.
|
|
6
|
+
|
|
7
|
+
const REACT_ONLY_19 = new Set([
|
|
8
|
+
'use',
|
|
9
|
+
'useActionState',
|
|
10
|
+
'useOptimistic',
|
|
11
|
+
'useEffectEvent',
|
|
12
|
+
'Activity',
|
|
13
|
+
'captureOwnerStack',
|
|
14
|
+
'cache',
|
|
15
|
+
'cacheSignal',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const REACT_DOM_ONLY_19 = new Set([
|
|
19
|
+
'useFormStatus',
|
|
20
|
+
'requestFormReset',
|
|
21
|
+
'preload',
|
|
22
|
+
'preloadModule',
|
|
23
|
+
'preinit',
|
|
24
|
+
'preinitModule',
|
|
25
|
+
'preconnect',
|
|
26
|
+
'prefetchDNS',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const ONLY_19_BY_SOURCE = {
|
|
30
|
+
react: REACT_ONLY_19,
|
|
31
|
+
'react-dom': REACT_DOM_ONLY_19,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const FUNCTION_TYPES = new Set([
|
|
35
|
+
'ArrowFunctionExpression',
|
|
36
|
+
'FunctionExpression',
|
|
37
|
+
'FunctionDeclaration',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
function isFunction(node) {
|
|
41
|
+
return node != null && FUNCTION_TYPES.has(node.type);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Whether `fn` returns a function from its own body, not a nested one. */
|
|
45
|
+
function returnsFunction(fn) {
|
|
46
|
+
if (
|
|
47
|
+
fn.type === 'ArrowFunctionExpression' &&
|
|
48
|
+
fn.body.type !== 'BlockStatement'
|
|
49
|
+
) {
|
|
50
|
+
return isFunction(fn.body);
|
|
51
|
+
}
|
|
52
|
+
let found = false;
|
|
53
|
+
const visit = (node) => {
|
|
54
|
+
if (found || node == null || typeof node.type !== 'string') return;
|
|
55
|
+
if (node.type === 'ReturnStatement') {
|
|
56
|
+
if (isFunction(node.argument)) found = true;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (isFunction(node)) return;
|
|
60
|
+
for (const [key, value] of Object.entries(node)) {
|
|
61
|
+
if (key === 'parent') continue;
|
|
62
|
+
if (Array.isArray(value)) value.forEach(visit);
|
|
63
|
+
else if (value && typeof value === 'object') visit(value);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
visit(fn.body);
|
|
67
|
+
return found;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The name a call's callee ends in: `useCallback` for `React.useCallback`. */
|
|
71
|
+
function calleeName(call) {
|
|
72
|
+
const { callee } = call;
|
|
73
|
+
if (callee.type === 'Identifier') return callee.name;
|
|
74
|
+
if (
|
|
75
|
+
callee.type === 'MemberExpression' &&
|
|
76
|
+
callee.property.type === 'Identifier'
|
|
77
|
+
) {
|
|
78
|
+
return callee.property.name;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The function an identifier names, when it is declared in reach. */
|
|
84
|
+
function functionNamed(context, identifier) {
|
|
85
|
+
const scope = context.sourceCode.getScope(identifier);
|
|
86
|
+
let variable = null;
|
|
87
|
+
for (let current = scope; current && !variable; current = current.upper) {
|
|
88
|
+
variable = current.set.get(identifier.name) ?? null;
|
|
89
|
+
}
|
|
90
|
+
const definition = variable?.defs[0];
|
|
91
|
+
if (!definition) return null;
|
|
92
|
+
if (definition.node.type === 'FunctionDeclaration') return definition.node;
|
|
93
|
+
if (definition.node.type !== 'VariableDeclarator') return null;
|
|
94
|
+
const init = definition.node.init;
|
|
95
|
+
if (isFunction(init)) return init;
|
|
96
|
+
if (init?.type === 'CallExpression' && calleeName(init) === 'useCallback') {
|
|
97
|
+
return isFunction(init.arguments[0]) ? init.arguments[0] : null;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function expressionFunction(context, expression) {
|
|
103
|
+
if (isFunction(expression)) return expression;
|
|
104
|
+
if (expression.type === 'Identifier')
|
|
105
|
+
return functionNamed(context, expression);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function attributeExpression(attribute) {
|
|
110
|
+
const { value } = attribute;
|
|
111
|
+
return value?.type === 'JSXExpressionContainer' ? value.expression : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hasAncestorElement(node, name) {
|
|
115
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
116
|
+
if (
|
|
117
|
+
current.type === 'JSXElement' &&
|
|
118
|
+
current.openingElement.name.type === 'JSXIdentifier' &&
|
|
119
|
+
current.openingElement.name.name === name
|
|
120
|
+
) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export default {
|
|
128
|
+
meta: {
|
|
129
|
+
type: 'problem',
|
|
130
|
+
docs: {
|
|
131
|
+
description:
|
|
132
|
+
'Disallow React 19-only APIs in source that packages/react-18 is generated from.',
|
|
133
|
+
},
|
|
134
|
+
schema: [],
|
|
135
|
+
messages: {
|
|
136
|
+
api: '`{{name}}` is React 19 only, so the generated packages/react-18 breaks. Use an API React 18.3 has.',
|
|
137
|
+
contextProvider:
|
|
138
|
+
'Rendering a context as a provider is React 19 only. Render `<{{name}}.Provider value={…}>`.',
|
|
139
|
+
refCleanup:
|
|
140
|
+
'A ref callback that returns a cleanup is React 18-incompatible: React 18 ignores the cleanup and calls the ref with `null`. Keep the node in state and set up and tear down in an effect.',
|
|
141
|
+
deferredInitialValue:
|
|
142
|
+
"`useDeferredValue`'s initial value is React 19 only. Pass one argument.",
|
|
143
|
+
formAction:
|
|
144
|
+
'A function `{{name}}` is React 19 only. Handle the form in `onSubmit` or the button in `onClick`.',
|
|
145
|
+
documentMetadata:
|
|
146
|
+
'`<{{name}}>` is hoisted into the document head by React 19 only. React 18 renders it in place.',
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
create(context) {
|
|
150
|
+
// Local names bound to the `react` / `react-dom` namespace or default import.
|
|
151
|
+
const namespaces = new Map();
|
|
152
|
+
const contexts = new Set();
|
|
153
|
+
|
|
154
|
+
function reportMember(node, source, name) {
|
|
155
|
+
if (ONLY_19_BY_SOURCE[source]?.has(name)) {
|
|
156
|
+
context.report({ node, messageId: 'api', data: { name } });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
ImportDeclaration(node) {
|
|
162
|
+
const source = node.source.value;
|
|
163
|
+
if (!(source in ONLY_19_BY_SOURCE)) return;
|
|
164
|
+
for (const specifier of node.specifiers) {
|
|
165
|
+
if (specifier.type === 'ImportSpecifier') {
|
|
166
|
+
const imported =
|
|
167
|
+
specifier.imported.name ?? specifier.imported.value;
|
|
168
|
+
reportMember(specifier, source, imported);
|
|
169
|
+
} else {
|
|
170
|
+
namespaces.set(specifier.local.name, source);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
MemberExpression(node) {
|
|
176
|
+
if (
|
|
177
|
+
node.object.type !== 'Identifier' ||
|
|
178
|
+
node.property.type !== 'Identifier'
|
|
179
|
+
) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const source = namespaces.get(node.object.name);
|
|
183
|
+
if (source) reportMember(node.property, source, node.property.name);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
JSXMemberExpression(node) {
|
|
187
|
+
if (node.object.type !== 'JSXIdentifier') return;
|
|
188
|
+
const source = namespaces.get(node.object.name);
|
|
189
|
+
if (source) reportMember(node.property, source, node.property.name);
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
VariableDeclarator(node) {
|
|
193
|
+
if (
|
|
194
|
+
node.id.type === 'Identifier' &&
|
|
195
|
+
node.init?.type === 'CallExpression' &&
|
|
196
|
+
calleeName(node.init) === 'createContext'
|
|
197
|
+
) {
|
|
198
|
+
contexts.add(node.id.name);
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
CallExpression(node) {
|
|
203
|
+
if (
|
|
204
|
+
calleeName(node) === 'useDeferredValue' &&
|
|
205
|
+
node.arguments.length > 1
|
|
206
|
+
) {
|
|
207
|
+
context.report({ node, messageId: 'deferredInitialValue' });
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
JSXOpeningElement(node) {
|
|
212
|
+
if (node.name.type !== 'JSXIdentifier') return;
|
|
213
|
+
const { name } = node.name;
|
|
214
|
+
const attributes = node.attributes.filter(
|
|
215
|
+
(attribute) => attribute.type === 'JSXAttribute'
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const isContext = contexts.has(name) || /^[A-Z]\w*Context$/.test(name);
|
|
219
|
+
if (
|
|
220
|
+
isContext &&
|
|
221
|
+
attributes.some((attribute) => attribute.name.name === 'value')
|
|
222
|
+
) {
|
|
223
|
+
context.report({
|
|
224
|
+
node,
|
|
225
|
+
messageId: 'contextProvider',
|
|
226
|
+
data: { name },
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (name === 'meta' || name === 'link') {
|
|
231
|
+
context.report({
|
|
232
|
+
node,
|
|
233
|
+
messageId: 'documentMetadata',
|
|
234
|
+
data: { name },
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
if (name === 'title' && !hasAncestorElement(node.parent, 'svg')) {
|
|
238
|
+
context.report({
|
|
239
|
+
node,
|
|
240
|
+
messageId: 'documentMetadata',
|
|
241
|
+
data: { name },
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (
|
|
245
|
+
name === 'style' &&
|
|
246
|
+
attributes.some((attribute) => attribute.name.name === 'precedence')
|
|
247
|
+
) {
|
|
248
|
+
context.report({
|
|
249
|
+
node,
|
|
250
|
+
messageId: 'documentMetadata',
|
|
251
|
+
data: { name },
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
for (const attribute of attributes) {
|
|
256
|
+
const attributeName = attribute.name.name;
|
|
257
|
+
const expression = attributeExpression(attribute);
|
|
258
|
+
if (!expression) continue;
|
|
259
|
+
|
|
260
|
+
if (attributeName === 'ref') {
|
|
261
|
+
const fn = expressionFunction(context, expression);
|
|
262
|
+
if (fn && returnsFunction(fn)) {
|
|
263
|
+
context.report({ node: attribute, messageId: 'refCleanup' });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (
|
|
267
|
+
(attributeName === 'action' || attributeName === 'formAction') &&
|
|
268
|
+
expressionFunction(context, expression)
|
|
269
|
+
) {
|
|
270
|
+
context.report({
|
|
271
|
+
node: attribute,
|
|
272
|
+
messageId: 'formAction',
|
|
273
|
+
data: { name: attributeName },
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
};
|
|
@@ -129,14 +129,14 @@ function isDocsSpecimenPage(filename) {
|
|
|
129
129
|
const RAW_TYPOGRAPHY_EXCEPTIONS = [
|
|
130
130
|
{
|
|
131
131
|
filename:
|
|
132
|
-
/(?:^|[/\\])packages[/\\]react[/\\]src[/\\]components[/\\]avatar[/\\]avatar\.tsx$/,
|
|
132
|
+
/(?:^|[/\\])packages[/\\]react(?:-18)?[/\\]src[/\\]components[/\\]avatar[/\\]avatar\.tsx$/,
|
|
133
133
|
className:
|
|
134
134
|
'nx:flex nx:size-full nx:items-center nx:justify-center nx:rounded-[inherit] nx:bg-muted nx:text-foreground nx:font-medium nx:leading-none',
|
|
135
135
|
messageIds: new Set(['rawFontWeight', 'rawLineHeight']),
|
|
136
136
|
},
|
|
137
137
|
{
|
|
138
138
|
filename:
|
|
139
|
-
/(?:^|[/\\])packages[/\\]react[/\\]src[/\\]components[/\\]chart[/\\]chart\.tsx$/,
|
|
139
|
+
/(?:^|[/\\])packages[/\\]react(?:-18)?[/\\]src[/\\]components[/\\]chart[/\\]chart\.tsx$/,
|
|
140
140
|
className: 'nx:text-foreground nx:font-mono nx:font-medium nx:tabular-nums',
|
|
141
141
|
messageIds: new Set(['rawFontWeight']),
|
|
142
142
|
},
|