Add local eslint rule to validate markdown codeblocks with React Compiler (#7988)

In https://github.com/facebook/react/pull/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.
This commit is contained in:
lauren
2025-09-18 15:32:18 -04:00
committed by GitHub
parent bd03b86c02
commit b6a32d1e0e
21 changed files with 2542 additions and 7 deletions

View File

@@ -2,18 +2,36 @@
"root": true,
"extends": "next/core-web-vitals",
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "eslint-plugin-react-compiler"],
"plugins": ["@typescript-eslint", "eslint-plugin-react-compiler", "local-rules"],
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", {"varsIgnorePattern": "^_"}],
"react-hooks/exhaustive-deps": "error",
"react/no-unknown-property": ["error", {"ignore": ["meta"]}],
"react-compiler/react-compiler": "error"
"react-compiler/react-compiler": "error",
"local-rules/lint-markdown-code-blocks": "error"
},
"env": {
"node": true,
"commonjs": true,
"browser": true,
"es6": true
}
},
"overrides": [
{
"files": ["src/content/**/*.md"],
"parser": "./eslint-local-rules/parser",
"parserOptions": {
"sourceType": "module"
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "off",
"react-hooks/exhaustive-deps": "off",
"react/no-unknown-property": "off",
"react-compiler/react-compiler": "off",
"local-rules/lint-markdown-code-blocks": "error"
}
}
]
}

View File

@@ -32,6 +32,9 @@ jobs:
- name: Install deps
run: yarn install --frozen-lockfile
- name: Install deps (eslint-local-rules)
run: yarn install --frozen-lockfile
working-directory: eslint-local-rules
- name: Lint codebase
run: yarn ci-check

2
.gitignore vendored
View File

@@ -1,7 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
node_modules
/.pnp
.pnp.js

View File

@@ -0,0 +1,8 @@
```jsx
import {useState} from 'react';
function Counter() {
const [count, setCount] = useState(0);
setCount(count + 1);
return <div>{count}</div>;
}
```

View File

@@ -0,0 +1,8 @@
```jsx title="Counter" {expectedErrors: {'react-compiler': [99]}} {expectedErrors: {'react-compiler': [2]}}
import {useState} from 'react';
function Counter() {
const [count, setCount] = useState(0);
setCount(count + 1);
return <div>{count}</div>;
}
```

View File

@@ -0,0 +1,8 @@
```jsx {expectedErrors: {'react-compiler': 'invalid'}}
import {useState} from 'react';
function Counter() {
const [count, setCount] = useState(0);
setCount(count + 1);
return <div>{count}</div>;
}
```

View File

@@ -0,0 +1,7 @@
```bash
setCount()
```
```txt
import {useState} from 'react';
```

View File

@@ -0,0 +1,5 @@
```jsx {expectedErrors: {'react-compiler': [3]}}
function Hello() {
return <h1>Hello</h1>;
}
```

View File

@@ -0,0 +1,8 @@
```jsx {expectedErrors: {'react-compiler': [4]}}
import {useState} from 'react';
function Counter() {
const [count, setCount] = useState(0);
setCount(count + 1);
return <div>{count}</div>;
}
```

View File

@@ -0,0 +1,131 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const {ESLint} = require('eslint');
const plugin = require('..');
const FIXTURES_DIR = path.join(
__dirname,
'fixtures',
'src',
'content'
);
const PARSER_PATH = path.join(__dirname, '..', 'parser.js');
function createESLint({fix = false} = {}) {
return new ESLint({
useEslintrc: false,
fix,
plugins: {
'local-rules': plugin,
},
overrideConfig: {
parser: PARSER_PATH,
plugins: ['local-rules'],
rules: {
'local-rules/lint-markdown-code-blocks': 'error',
},
parserOptions: {
sourceType: 'module',
},
},
});
}
function readFixture(name) {
return fs.readFileSync(path.join(FIXTURES_DIR, name), 'utf8');
}
async function lintFixture(name, {fix = false} = {}) {
const eslint = createESLint({fix});
const filePath = path.join(FIXTURES_DIR, name);
const markdown = readFixture(name);
const [result] = await eslint.lintText(markdown, {filePath});
return result;
}
async function run() {
const basicResult = await lintFixture('basic-error.md');
assert.strictEqual(
basicResult.messages.length,
1,
'expected one diagnostic'
);
assert(
basicResult.messages[0].message.includes('Calling setState during render'),
'expected message to mention setState during render'
);
const suppressedResult = await lintFixture('suppressed-error.md');
assert.strictEqual(
suppressedResult.messages.length,
0,
'expected suppression metadata to silence diagnostic'
);
const staleResult = await lintFixture('stale-expected-error.md');
assert.strictEqual(
staleResult.messages.length,
1,
'expected stale metadata error'
);
assert.strictEqual(
staleResult.messages[0].message,
'React Compiler expected error on line 3 was not triggered'
);
const duplicateResult = await lintFixture('duplicate-metadata.md');
assert.strictEqual(
duplicateResult.messages.length,
2,
'expected duplicate metadata to surface compiler diagnostic and stale metadata notice'
);
const duplicateFixed = await lintFixture('duplicate-metadata.md', {
fix: true,
});
assert(
duplicateFixed.output.includes(
"{expectedErrors: {'react-compiler': [4]}}"
),
'expected duplicates to be rewritten to a single canonical block'
);
assert(
!duplicateFixed.output.includes('[99]'),
'expected stale line numbers to be removed from metadata'
);
const mixedLanguageResult = await lintFixture('mixed-language.md');
assert.strictEqual(
mixedLanguageResult.messages.length,
0,
'expected non-js code fences to be ignored'
);
const malformedResult = await lintFixture('malformed-metadata.md');
assert.strictEqual(
malformedResult.messages.length,
1,
'expected malformed metadata to fall back to compiler diagnostics'
);
const malformedFixed = await lintFixture('malformed-metadata.md', {
fix: true,
});
assert(
malformedFixed.output.includes(
"{expectedErrors: {'react-compiler': [4]}}"
),
'expected malformed metadata to be replaced with canonical form'
);
}
run().catch(error => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,14 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
const lintMarkdownCodeBlocks = require('./rules/lint-markdown-code-blocks');
module.exports = {
rules: {
'lint-markdown-code-blocks': lintMarkdownCodeBlocks,
},
};

View File

@@ -0,0 +1,12 @@
{
"name": "eslint-plugin-local-rules",
"version": "0.0.0",
"main": "index.js",
"private": "true",
"scripts": {
"test": "node __tests__/lint-markdown-code-blocks.test.js"
},
"devDependencies": {
"eslint-mdx": "^2"
}
}

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
module.exports = require('eslint-mdx');

View File

@@ -0,0 +1,77 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
function getRelativeLine(loc) {
return loc?.start?.line ?? loc?.line ?? 1;
}
function getRelativeColumn(loc) {
return loc?.start?.column ?? loc?.column ?? 0;
}
function getRelativeEndLine(loc, fallbackLine) {
if (loc?.end?.line != null) {
return loc.end.line;
}
if (loc?.line != null) {
return loc.line;
}
return fallbackLine;
}
function getRelativeEndColumn(loc, fallbackColumn) {
if (loc?.end?.column != null) {
return loc.end.column;
}
if (loc?.column != null) {
return loc.column;
}
return fallbackColumn;
}
/**
* @param {import('./markdown').MarkdownCodeBlock} block
* @param {Array<{detail: any, loc: any, message: string}>} diagnostics
* @returns {Array<{detail: any, message: string, relativeStartLine: number, markdownLoc: {start: {line: number, column: number}, end: {line: number, column: number}}}>}
*/
function normalizeDiagnostics(block, diagnostics) {
return diagnostics.map(({detail, loc, message}) => {
const relativeStartLine = Math.max(getRelativeLine(loc), 1);
const relativeStartColumn = Math.max(getRelativeColumn(loc), 0);
const relativeEndLine = Math.max(
getRelativeEndLine(loc, relativeStartLine),
relativeStartLine
);
const relativeEndColumn = Math.max(
getRelativeEndColumn(loc, relativeStartColumn),
relativeStartColumn
);
const markdownStartLine = block.codeStartLine + relativeStartLine - 1;
const markdownEndLine = block.codeStartLine + relativeEndLine - 1;
return {
detail,
message,
relativeStartLine,
markdownLoc: {
start: {
line: markdownStartLine,
column: relativeStartColumn,
},
end: {
line: markdownEndLine,
column: relativeEndColumn,
},
},
};
});
}
module.exports = {
normalizeDiagnostics,
};

View File

@@ -0,0 +1,178 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
const {
buildFenceLine,
getCompilerExpectedLines,
getSortedUniqueNumbers,
hasCompilerEntry,
metadataEquals,
metadataHasExpectedErrorsToken,
removeCompilerExpectedLines,
setCompilerExpectedLines,
} = require('./metadata');
const {normalizeDiagnostics} = require('./diagnostics');
const {parseMarkdownFile} = require('./markdown');
const {runReactCompiler} = require('./react-compiler');
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Run React Compiler on markdown code blocks',
category: 'Possible Errors',
},
fixable: 'code',
hasSuggestions: true,
schema: [],
},
create(context) {
return {
Program(node) {
const filename = context.getFilename();
if (!filename.endsWith('.md') || !filename.includes('src/content')) {
return;
}
const sourceCode = context.getSourceCode();
const {blocks} = parseMarkdownFile(sourceCode.text, filename);
// For each supported code block, run the compiler and reconcile metadata.
for (const block of blocks) {
const compilerResult = runReactCompiler(
block.code,
`${filename}#codeblock`
);
const expectedLines = getCompilerExpectedLines(block.metadata);
const expectedLineSet = new Set(expectedLines);
const diagnostics = normalizeDiagnostics(
block,
compilerResult.diagnostics
);
const errorLines = new Set();
const unexpectedDiagnostics = [];
for (const diagnostic of diagnostics) {
const line = diagnostic.relativeStartLine;
errorLines.add(line);
if (!expectedLineSet.has(line)) {
unexpectedDiagnostics.push(diagnostic);
}
}
const normalizedErrorLines = getSortedUniqueNumbers(
Array.from(errorLines)
);
const missingExpectedLines = expectedLines.filter(
(line) => !errorLines.has(line)
);
const desiredMetadata = normalizedErrorLines.length
? setCompilerExpectedLines(block.metadata, normalizedErrorLines)
: removeCompilerExpectedLines(block.metadata);
// Compute canonical metadata and attach an autofix when it differs.
const metadataChanged = !metadataEquals(
block.metadata,
desiredMetadata
);
const replacementLine = buildFenceLine(block.lang, desiredMetadata);
const replacementDiffers = block.fence.rawText !== replacementLine;
const applyReplacementFix = replacementDiffers
? (fixer) =>
fixer.replaceTextRange(block.fence.range, replacementLine)
: null;
const hasDuplicateMetadata =
block.metadata.hadDuplicateExpectedErrors;
const hasExpectedErrorsMetadata = metadataHasExpectedErrorsToken(
block.metadata
);
const shouldFixUnexpected =
Boolean(applyReplacementFix) &&
normalizedErrorLines.length > 0 &&
(metadataChanged ||
hasDuplicateMetadata ||
!hasExpectedErrorsMetadata);
let fixAlreadyAttached = false;
for (const diagnostic of unexpectedDiagnostics) {
const reportData = {
node,
loc: diagnostic.markdownLoc,
message: diagnostic.message,
};
if (
shouldFixUnexpected &&
applyReplacementFix &&
!fixAlreadyAttached
) {
reportData.fix = applyReplacementFix;
reportData.suggest = [
{
desc: 'Add expectedErrors metadata to suppress these errors',
fix: applyReplacementFix,
},
];
fixAlreadyAttached = true;
}
context.report(reportData);
}
// Assert that expectedErrors is actually needed
if (
Boolean(applyReplacementFix) &&
missingExpectedLines.length > 0 &&
hasCompilerEntry(block.metadata)
) {
const plural = missingExpectedLines.length > 1;
const message = plural
? `React Compiler expected errors on lines ${missingExpectedLines.join(
', '
)} were not triggered`
: `React Compiler expected error on line ${missingExpectedLines[0]} was not triggered`;
const reportData = {
node,
loc: {
start: {
line: block.position.start.line,
column: 0,
},
end: {
line: block.position.start.line,
column: block.fence.rawText.length,
},
},
message,
};
if (!fixAlreadyAttached && applyReplacementFix) {
reportData.fix = applyReplacementFix;
fixAlreadyAttached = true;
} else if (applyReplacementFix) {
reportData.suggest = [
{
desc: 'Remove stale expectedErrors metadata',
fix: applyReplacementFix,
},
];
}
context.report(reportData);
}
}
},
};
},
};

View File

@@ -0,0 +1,124 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
const remark = require('remark');
const {parseFenceMetadata} = require('./metadata');
/**
* @typedef {Object} MarkdownCodeBlock
* @property {string} code
* @property {number} codeStartLine
* @property {{start: {line: number, column: number}, end: {line: number, column: number}}} position
* @property {{lineIndex: number, rawText: string, metaText: string, range: [number, number]}} fence
* @property {string} filePath
* @property {string} lang
* @property {import('./metadata').FenceMetadata} metadata
*/
const SUPPORTED_LANGUAGES = new Set([
'js',
'jsx',
'javascript',
'ts',
'tsx',
'typescript',
]);
function computeLineOffsets(lines) {
const offsets = [];
let currentOffset = 0;
for (const line of lines) {
offsets.push(currentOffset);
currentOffset += line.length + 1;
}
return offsets;
}
function parseMarkdownFile(content, filePath) {
const tree = remark().parse(content);
const lines = content.split('\n');
const lineOffsets = computeLineOffsets(lines);
const blocks = [];
function traverse(node) {
if (!node || typeof node !== 'object') {
return;
}
if (node.type === 'code') {
const rawLang = node.lang || '';
const normalizedLang = rawLang.toLowerCase();
if (!normalizedLang || !SUPPORTED_LANGUAGES.has(normalizedLang)) {
return;
}
const fenceLineIndex = (node.position?.start?.line ?? 1) - 1;
const fenceStartOffset = node.position?.start?.offset ?? 0;
const fenceLine = lines[fenceLineIndex] ?? '';
const fenceEndOffset = fenceStartOffset + fenceLine.length;
let metaText = '';
if (fenceLine) {
const prefixMatch = fenceLine.match(/^`{3,}\s*/);
const prefixLength = prefixMatch ? prefixMatch[0].length : 3;
metaText = fenceLine.slice(prefixLength + rawLang.length);
} else if (node.meta) {
metaText = ` ${node.meta}`;
}
const metadata = parseFenceMetadata(metaText);
blocks.push({
lang: rawLang || normalizedLang,
metadata,
filePath,
code: node.value || '',
codeStartLine: (node.position?.start?.line ?? 1) + 1,
position: {
start: {
line: fenceLineIndex + 1,
column: (node.position?.start?.column ?? 1) - 1,
},
end: {
line: fenceLineIndex + 1,
column: (node.position?.start?.column ?? 1) - 1 + fenceLine.length,
},
},
fence: {
lineIndex: fenceLineIndex,
rawText: fenceLine,
metaText,
range: [fenceStartOffset, fenceEndOffset],
},
});
return;
}
if ('children' in node && Array.isArray(node.children)) {
for (const child of node.children) {
traverse(child);
}
}
}
traverse(tree);
return {
content,
blocks,
lines,
lineOffsets,
};
}
module.exports = {
SUPPORTED_LANGUAGES,
computeLineOffsets,
parseMarkdownFile,
};

View File

@@ -0,0 +1,377 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @typedef {{type: 'text', raw: string}} TextToken
* @typedef {{
* type: 'expectedErrors',
* entries: Record<string, Array<number>>,
* raw?: string,
* }} ExpectedErrorsToken
* @typedef {TextToken | ExpectedErrorsToken} MetadataToken
*
* @typedef {{
* leading: string,
* trailing: string,
* tokens: Array<MetadataToken>,
* parseError: boolean,
* hadDuplicateExpectedErrors: boolean,
* }} FenceMetadata
*/
const EXPECTED_ERRORS_BLOCK_REGEX = /\{\s*expectedErrors\s*:/;
const REACT_COMPILER_KEY = 'react-compiler';
function getSortedUniqueNumbers(values) {
return Array.from(new Set(values))
.filter((value) => typeof value === 'number' && !Number.isNaN(value))
.sort((a, b) => a - b);
}
function tokenizeMeta(body) {
if (!body) {
return [];
}
const tokens = [];
let current = '';
let depth = 0;
for (let i = 0; i < body.length; i++) {
const char = body[i];
if (char === '{') {
depth++;
} else if (char === '}') {
depth = Math.max(depth - 1, 0);
}
if (char === ' ' && depth === 0) {
if (current) {
tokens.push(current);
current = '';
}
continue;
}
current += char;
}
if (current) {
tokens.push(current);
}
return tokens;
}
function normalizeEntryValues(values) {
if (!Array.isArray(values)) {
return [];
}
return getSortedUniqueNumbers(values);
}
function parseExpectedErrorsEntries(rawEntries) {
const normalized = rawEntries
.replace(/([{,]\s*)([a-zA-Z_$][\w$]*)\s*:/g, '$1"$2":')
.replace(/'([^']*)'/g, '"$1"');
const parsed = JSON.parse(normalized);
const entries = {};
if (parsed && typeof parsed === 'object') {
for (const [key, value] of Object.entries(parsed)) {
entries[key] = normalizeEntryValues(Array.isArray(value) ? value.flat() : value);
}
}
return entries;
}
function parseExpectedErrorsToken(tokenText) {
const match = tokenText.match(/^\{\s*expectedErrors\s*:\s*(\{[\s\S]*\})\s*\}$/);
if (!match) {
return null;
}
const entriesSource = match[1];
let parseError = false;
let entries;
try {
entries = parseExpectedErrorsEntries(entriesSource);
} catch (error) {
parseError = true;
entries = {};
}
return {
token: {
type: 'expectedErrors',
entries,
raw: tokenText,
},
parseError,
};
}
function parseFenceMetadata(metaText) {
if (!metaText) {
return {
leading: '',
trailing: '',
tokens: [],
parseError: false,
hadDuplicateExpectedErrors: false,
};
}
const leading = metaText.match(/^\s*/)?.[0] ?? '';
const trailing = metaText.match(/\s*$/)?.[0] ?? '';
const bodyStart = leading.length;
const bodyEnd = metaText.length - trailing.length;
const body = metaText.slice(bodyStart, bodyEnd).trim();
if (!body) {
return {
leading,
trailing,
tokens: [],
parseError: false,
hadDuplicateExpectedErrors: false,
};
}
const tokens = [];
let parseError = false;
let sawExpectedErrors = false;
let hadDuplicateExpectedErrors = false;
for (const rawToken of tokenizeMeta(body)) {
const normalizedToken = rawToken.trim();
if (!normalizedToken) {
continue;
}
if (EXPECTED_ERRORS_BLOCK_REGEX.test(normalizedToken)) {
const parsed = parseExpectedErrorsToken(normalizedToken);
if (parsed) {
if (sawExpectedErrors) {
hadDuplicateExpectedErrors = true;
// Drop duplicates. We'll rebuild the canonical block on write.
continue;
}
tokens.push(parsed.token);
parseError = parseError || parsed.parseError;
sawExpectedErrors = true;
continue;
}
}
tokens.push({type: 'text', raw: normalizedToken});
}
return {
leading,
trailing,
tokens,
parseError,
hadDuplicateExpectedErrors,
};
}
function cloneMetadata(metadata) {
return {
leading: metadata.leading,
trailing: metadata.trailing,
parseError: metadata.parseError,
hadDuplicateExpectedErrors: metadata.hadDuplicateExpectedErrors,
tokens: metadata.tokens.map((token) => {
if (token.type === 'expectedErrors') {
const clonedEntries = {};
for (const [key, value] of Object.entries(token.entries)) {
clonedEntries[key] = [...value];
}
return {type: 'expectedErrors', entries: clonedEntries};
}
return {type: 'text', raw: token.raw};
}),
};
}
function findExpectedErrorsToken(metadata) {
return metadata.tokens.find((token) => token.type === 'expectedErrors') || null;
}
function getCompilerExpectedLines(metadata) {
const token = findExpectedErrorsToken(metadata);
if (!token) {
return [];
}
return getSortedUniqueNumbers(token.entries[REACT_COMPILER_KEY] || []);
}
function hasCompilerEntry(metadata) {
const token = findExpectedErrorsToken(metadata);
return Boolean(token && token.entries[REACT_COMPILER_KEY]?.length);
}
function metadataHasExpectedErrorsToken(metadata) {
return Boolean(findExpectedErrorsToken(metadata));
}
function stringifyExpectedErrorsToken(token) {
const entries = token.entries || {};
const keys = Object.keys(entries).filter((key) => entries[key].length > 0);
if (keys.length === 0) {
return '';
}
keys.sort();
const segments = keys.map((key) => {
const values = entries[key];
return `'${key}': [${values.join(', ')}]`;
});
return `{expectedErrors: {${segments.join(', ')}}}`;
}
function stringifyFenceMetadata(metadata) {
if (!metadata.tokens.length) {
return '';
}
const parts = metadata.tokens
.map((token) => {
if (token.type === 'expectedErrors') {
return stringifyExpectedErrorsToken(token);
}
return token.raw;
})
.filter(Boolean);
if (!parts.length) {
return '';
}
const leading = metadata.leading || ' ';
const trailing = metadata.trailing ? metadata.trailing.trimEnd() : '';
const body = parts.join(' ');
return `${leading}${body}${trailing}`;
}
function buildFenceLine(lang, metadata) {
const meta = stringifyFenceMetadata(metadata);
return meta ? `\`\`\`${lang}${meta}` : `\`\`\`${lang}`;
}
function metadataEquals(a, b) {
if (a.leading !== b.leading || a.trailing !== b.trailing) {
return false;
}
if (a.tokens.length !== b.tokens.length) {
return false;
}
for (let i = 0; i < a.tokens.length; i++) {
const left = a.tokens[i];
const right = b.tokens[i];
if (left.type !== right.type) {
return false;
}
if (left.type === 'text') {
if (left.raw !== right.raw) {
return false;
}
} else {
const leftKeys = Object.keys(left.entries).sort();
const rightKeys = Object.keys(right.entries).sort();
if (leftKeys.length !== rightKeys.length) {
return false;
}
for (let j = 0; j < leftKeys.length; j++) {
if (leftKeys[j] !== rightKeys[j]) {
return false;
}
const lValues = getSortedUniqueNumbers(left.entries[leftKeys[j]]);
const rValues = getSortedUniqueNumbers(right.entries[rightKeys[j]]);
if (lValues.length !== rValues.length) {
return false;
}
for (let k = 0; k < lValues.length; k++) {
if (lValues[k] !== rValues[k]) {
return false;
}
}
}
}
}
return true;
}
function normalizeMetadata(metadata) {
const normalized = cloneMetadata(metadata);
normalized.hadDuplicateExpectedErrors = false;
normalized.parseError = false;
if (!normalized.tokens.length) {
normalized.leading = '';
normalized.trailing = '';
}
return normalized;
}
function setCompilerExpectedLines(metadata, lines) {
const normalizedLines = getSortedUniqueNumbers(lines);
if (normalizedLines.length === 0) {
return removeCompilerExpectedLines(metadata);
}
const next = cloneMetadata(metadata);
let token = findExpectedErrorsToken(next);
if (!token) {
token = {type: 'expectedErrors', entries: {}};
next.tokens = [token, ...next.tokens];
}
token.entries[REACT_COMPILER_KEY] = normalizedLines;
return normalizeMetadata(next);
}
function removeCompilerExpectedLines(metadata) {
const next = cloneMetadata(metadata);
const token = findExpectedErrorsToken(next);
if (!token) {
return normalizeMetadata(next);
}
delete token.entries[REACT_COMPILER_KEY];
const hasEntries = Object.values(token.entries).some(
(value) => Array.isArray(value) && value.length > 0
);
if (!hasEntries) {
next.tokens = next.tokens.filter((item) => item !== token);
}
return normalizeMetadata(next);
}
module.exports = {
buildFenceLine,
getCompilerExpectedLines,
getSortedUniqueNumbers,
hasCompilerEntry,
metadataEquals,
metadataHasExpectedErrorsToken,
parseFenceMetadata,
removeCompilerExpectedLines,
setCompilerExpectedLines,
stringifyFenceMetadata,
};

View File

@@ -0,0 +1,122 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
const {transformFromAstSync} = require('@babel/core');
const {parse: babelParse} = require('@babel/parser');
const BabelPluginReactCompiler = require('babel-plugin-react-compiler').default;
const {
parsePluginOptions,
validateEnvironmentConfig,
} = require('babel-plugin-react-compiler');
const COMPILER_OPTIONS = {
noEmit: true,
panicThreshold: 'none',
environment: validateEnvironmentConfig({
validateRefAccessDuringRender: true,
validateNoSetStateInRender: true,
validateNoSetStateInEffects: true,
validateNoJSXInTryStatements: true,
validateNoImpureFunctionsInRender: true,
validateStaticComponents: true,
validateNoFreezingKnownMutableFunctions: true,
validateNoVoidUseMemo: true,
validateNoCapitalizedCalls: [],
validateHooksUsage: true,
validateNoDerivedComputationsInEffects: true,
}),
};
function hasRelevantCode(code) {
const functionPattern = /^(export\s+)?(default\s+)?function\s+\w+/m;
const arrowPattern =
/^(export\s+)?(const|let|var)\s+\w+\s*=\s*(\([^)]*\)|\w+)\s*=>/m;
const hasImports = /^import\s+/m.test(code);
return functionPattern.test(code) || arrowPattern.test(code) || hasImports;
}
function runReactCompiler(code, filename) {
const result = {
sourceCode: code,
events: [],
};
if (!hasRelevantCode(code)) {
return {...result, diagnostics: []};
}
const options = parsePluginOptions({
...COMPILER_OPTIONS,
});
options.logger = {
logEvent: (_, event) => {
if (event.kind === 'CompileError') {
const category = event.detail?.category;
if (category === 'Todo' || category === 'Invariant') {
return;
}
result.events.push(event);
}
},
};
try {
const ast = babelParse(code, {
sourceFilename: filename,
sourceType: 'module',
plugins: ['jsx', 'typescript'],
});
transformFromAstSync(ast, code, {
filename,
highlightCode: false,
retainLines: true,
plugins: [[BabelPluginReactCompiler, options]],
sourceType: 'module',
configFile: false,
babelrc: false,
});
} catch (error) {
return {...result, diagnostics: []};
}
const diagnostics = [];
for (const event of result.events) {
if (event.kind !== 'CompileError') {
continue;
}
const detail = event.detail;
if (!detail) {
continue;
}
const loc = typeof detail.primaryLocation === 'function'
? detail.primaryLocation()
: null;
if (loc == null || typeof loc === 'symbol') {
continue;
}
const message = typeof detail.printErrorMessage === 'function'
? detail.printErrorMessage(result.sourceCode, {eslint: true})
: detail.description || 'Unknown React Compiler error';
diagnostics.push({detail, loc, message});
}
return {...result, diagnostics};
}
module.exports = {
hasRelevantCode,
runReactCompiler,
};

1421
eslint-local-rules/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,8 @@
"analyze": "ANALYZE=true next build",
"dev": "next-remote-watch ./src/content",
"build": "next build && node --experimental-modules ./scripts/downloadFonts.mjs",
"lint": "next lint",
"lint:fix": "next lint --fix",
"lint": "next lint && eslint \"src/content/**/*.md\"",
"lint:fix": "next lint --fix && eslint \"src/content/**/*.md\" --fix",
"format:source": "prettier --config .prettierrc --write \"{plugins,src}/**/*.{js,ts,jsx,tsx,css}\"",
"nit:source": "prettier --config .prettierrc --list-different \"{plugins,src}/**/*.{js,ts,jsx,tsx,css}\"",
"prettier": "yarn format:source",
@@ -22,7 +22,8 @@
"check-all": "npm-run-all prettier lint:fix tsc rss",
"rss": "node scripts/generateRss.js",
"deadlinks": "node scripts/deadLinkChecker.js",
"copyright": "node scripts/copyright.js"
"copyright": "node scripts/copyright.js",
"test:eslint-local-rules": "yarn --cwd eslint-local-rules test"
},
"dependencies": {
"@codesandbox/sandpack-react": "2.13.5",
@@ -70,6 +71,7 @@
"eslint-plugin-flowtype": "4.x",
"eslint-plugin-import": "2.x",
"eslint-plugin-jsx-a11y": "6.x",
"eslint-plugin-local-rules": "link:eslint-local-rules",
"eslint-plugin-react": "7.x",
"eslint-plugin-react-compiler": "^19.0.0-beta-e552027-20250112",
"eslint-plugin-react-hooks": "^0.0.0-experimental-fabef7a6b-20221215",

View File

@@ -3405,6 +3405,10 @@ eslint-plugin-jsx-a11y@^6.4.1:
safe-regex-test "^1.0.3"
string.prototype.includes "^2.0.0"
"eslint-plugin-local-rules@link:eslint-local-rules":
version "0.0.0"
uid ""
eslint-plugin-react-compiler@^19.0.0-beta-e552027-20250112:
version "19.0.0-beta-e552027-20250112"
resolved "https://registry.yarnpkg.com/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-19.0.0-beta-e552027-20250112.tgz#f4ad9cebe47615ebf6097a8084a30d761ee164f4"