Skip to content

Commit 01e40bb

Browse files
committed
Add local eslint rule to validate markdown codeblocks with React Compiler
In facebook/react#34462 for example, we found an issue where the compiler was incorrectly validating an example straight from the docs. In order to find more issues like this + also provide more feedback to doc authors on valid/invalid patterns, this PR adds a new local eslint rule which validates all markdown codeblocks containing components/hooks with React Compiler. An autofixer is also provided. To express that a codeblock has an expected error, we can use the following metadata: ```ts // pseudo type def type MarkdownCodeBlockMetadata = { expectedErrors?: { 'react-compiler'?: number[]; }; }; ``` and can be used like so: ```` ```js {expectedErrors: {'react-compiler': [4]}} // ❌ setState directly in render function Component({value}) { const [count, setCount] = useState(0); setCount(value); // error on L4 return <div>{count}</div>; } ``` ```` Because this is defined as a local rule, we don't have the same granular reporting that `eslint-plugin-react-hooks` yet. I can look into that later but for now this first PR just sets us up with something basic.
1 parent c9c7a50 commit 01e40bb

21 files changed

+2542
-7
lines changed

.eslintrc

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,36 @@
22
"root": true,
33
"extends": "next/core-web-vitals",
44
"parser": "@typescript-eslint/parser",
5-
"plugins": ["@typescript-eslint", "eslint-plugin-react-compiler"],
5+
"plugins": ["@typescript-eslint", "eslint-plugin-react-compiler", "local-rules"],
66
"rules": {
77
"no-unused-vars": "off",
88
"@typescript-eslint/no-unused-vars": ["error", {"varsIgnorePattern": "^_"}],
99
"react-hooks/exhaustive-deps": "error",
1010
"react/no-unknown-property": ["error", {"ignore": ["meta"]}],
11-
"react-compiler/react-compiler": "error"
11+
"react-compiler/react-compiler": "error",
12+
"local-rules/lint-markdown-code-blocks": "error"
1213
},
1314
"env": {
1415
"node": true,
1516
"commonjs": true,
1617
"browser": true,
1718
"es6": true
18-
}
19+
},
20+
"overrides": [
21+
{
22+
"files": ["src/content/**/*.md"],
23+
"parser": "./eslint-local-rules/parser",
24+
"parserOptions": {
25+
"sourceType": "module"
26+
},
27+
"rules": {
28+
"no-unused-vars": "off",
29+
"@typescript-eslint/no-unused-vars": "off",
30+
"react-hooks/exhaustive-deps": "off",
31+
"react/no-unknown-property": "off",
32+
"react-compiler/react-compiler": "off",
33+
"local-rules/lint-markdown-code-blocks": "error"
34+
}
35+
}
36+
]
1937
}

.github/workflows/site_lint.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ jobs:
3232

3333
- name: Install deps
3434
run: yarn install --frozen-lockfile
35+
- name: Install deps (eslint-local-rules)
36+
run: yarn install --frozen-lockfile
37+
working-directory: eslint-local-rules
3538

3639
- name: Lint codebase
3740
run: yarn ci-check

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
22

33
# dependencies
4-
/node_modules
4+
node_modules
55
/.pnp
66
.pnp.js
77

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
```jsx
2+
import {useState} from 'react';
3+
function Counter() {
4+
const [count, setCount] = useState(0);
5+
setCount(count + 1);
6+
return <div>{count}</div>;
7+
}
8+
```
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
```jsx title="Counter" {expectedErrors: {'react-compiler': [99]}} {expectedErrors: {'react-compiler': [2]}}
2+
import {useState} from 'react';
3+
function Counter() {
4+
const [count, setCount] = useState(0);
5+
setCount(count + 1);
6+
return <div>{count}</div>;
7+
}
8+
```
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
```jsx {expectedErrors: {'react-compiler': 'invalid'}}
2+
import {useState} from 'react';
3+
function Counter() {
4+
const [count, setCount] = useState(0);
5+
setCount(count + 1);
6+
return <div>{count}</div>;
7+
}
8+
```
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
```bash
2+
setCount()
3+
```
4+
5+
```txt
6+
import {useState} from 'react';
7+
```
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
```jsx {expectedErrors: {'react-compiler': [3]}}
2+
function Hello() {
3+
return <h1>Hello</h1>;
4+
}
5+
```
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
```jsx {expectedErrors: {'react-compiler': [4]}}
2+
import {useState} from 'react';
3+
function Counter() {
4+
const [count, setCount] = useState(0);
5+
setCount(count + 1);
6+
return <div>{count}</div>;
7+
}
8+
```
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
const assert = require('assert');
9+
const fs = require('fs');
10+
const path = require('path');
11+
const {ESLint} = require('eslint');
12+
const plugin = require('..');
13+
14+
const FIXTURES_DIR = path.join(
15+
__dirname,
16+
'fixtures',
17+
'src',
18+
'content'
19+
);
20+
const PARSER_PATH = path.join(__dirname, '..', 'parser.js');
21+
22+
function createESLint({fix = false} = {}) {
23+
return new ESLint({
24+
useEslintrc: false,
25+
fix,
26+
plugins: {
27+
'local-rules': plugin,
28+
},
29+
overrideConfig: {
30+
parser: PARSER_PATH,
31+
plugins: ['local-rules'],
32+
rules: {
33+
'local-rules/lint-markdown-code-blocks': 'error',
34+
},
35+
parserOptions: {
36+
sourceType: 'module',
37+
},
38+
},
39+
});
40+
}
41+
42+
function readFixture(name) {
43+
return fs.readFileSync(path.join(FIXTURES_DIR, name), 'utf8');
44+
}
45+
46+
async function lintFixture(name, {fix = false} = {}) {
47+
const eslint = createESLint({fix});
48+
const filePath = path.join(FIXTURES_DIR, name);
49+
const markdown = readFixture(name);
50+
const [result] = await eslint.lintText(markdown, {filePath});
51+
return result;
52+
}
53+
54+
async function run() {
55+
const basicResult = await lintFixture('basic-error.md');
56+
assert.strictEqual(
57+
basicResult.messages.length,
58+
1,
59+
'expected one diagnostic'
60+
);
61+
assert(
62+
basicResult.messages[0].message.includes('Calling setState during render'),
63+
'expected message to mention setState during render'
64+
);
65+
66+
const suppressedResult = await lintFixture('suppressed-error.md');
67+
assert.strictEqual(
68+
suppressedResult.messages.length,
69+
0,
70+
'expected suppression metadata to silence diagnostic'
71+
);
72+
73+
const staleResult = await lintFixture('stale-expected-error.md');
74+
assert.strictEqual(
75+
staleResult.messages.length,
76+
1,
77+
'expected stale metadata error'
78+
);
79+
assert.strictEqual(
80+
staleResult.messages[0].message,
81+
'React Compiler expected error on line 3 was not triggered'
82+
);
83+
84+
const duplicateResult = await lintFixture('duplicate-metadata.md');
85+
assert.strictEqual(
86+
duplicateResult.messages.length,
87+
2,
88+
'expected duplicate metadata to surface compiler diagnostic and stale metadata notice'
89+
);
90+
const duplicateFixed = await lintFixture('duplicate-metadata.md', {
91+
fix: true,
92+
});
93+
assert(
94+
duplicateFixed.output.includes(
95+
"{expectedErrors: {'react-compiler': [4]}}"
96+
),
97+
'expected duplicates to be rewritten to a single canonical block'
98+
);
99+
assert(
100+
!duplicateFixed.output.includes('[99]'),
101+
'expected stale line numbers to be removed from metadata'
102+
);
103+
104+
const mixedLanguageResult = await lintFixture('mixed-language.md');
105+
assert.strictEqual(
106+
mixedLanguageResult.messages.length,
107+
0,
108+
'expected non-js code fences to be ignored'
109+
);
110+
111+
const malformedResult = await lintFixture('malformed-metadata.md');
112+
assert.strictEqual(
113+
malformedResult.messages.length,
114+
1,
115+
'expected malformed metadata to fall back to compiler diagnostics'
116+
);
117+
const malformedFixed = await lintFixture('malformed-metadata.md', {
118+
fix: true,
119+
});
120+
assert(
121+
malformedFixed.output.includes(
122+
"{expectedErrors: {'react-compiler': [4]}}"
123+
),
124+
'expected malformed metadata to be replaced with canonical form'
125+
);
126+
}
127+
128+
run().catch(error => {
129+
console.error(error);
130+
process.exitCode = 1;
131+
});

0 commit comments

Comments
 (0)