main
ts 354 lines 10.1 KB
Raw
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 /* eslint-disable no-for-of-loops/no-for-of-loops */
8
9 import {transformFromAstSync} from '@babel/core';
10 import {parse as babelParse} from '@babel/parser';
11 import {File} from '@babel/types';
12 import BabelPluginReactCompiler, {
13 parsePluginOptions,
14 validateEnvironmentConfig,
15 type PluginOptions,
16 Logger,
17 LoggerEvent,
18 } from 'babel-plugin-react-compiler';
19 import type {SourceCode} from 'eslint';
20 import type * as ESTree from 'estree';
21 import * as HermesParser from 'hermes-parser';
22 import {isDeepStrictEqual} from 'util';
23 import type {ParseResult} from '@babel/parser';
24 import {
25 eprh_enableUseKeyedStateCompilerLint,
26 eprh_enableVerboseNoSetStateInEffectCompilerLint,
27 eprh_enableExhaustiveEffectDependenciesCompilerLint,
28 } from 'shared/ReactFeatureFlags';
29
30 // Pattern for component names: starts with uppercase letter
31 const COMPONENT_NAME_PATTERN = /^[A-Z]/;
32 // Pattern for hook names: starts with 'use' followed by uppercase letter or digit
33 const HOOK_NAME_PATTERN = /^use[A-Z0-9]/;
34
35 /**
36 * Quick heuristic using ESLint's already-parsed AST to detect if the file
37 * may contain React components or hooks based on function naming patterns.
38 * Only checks top-level declarations since components/hooks are declared at module scope.
39 * Returns true if compilation should proceed, false to skip.
40 */
41 function mayContainReactCode(sourceCode: SourceCode): boolean {
42 const ast = sourceCode.ast;
43
44 // Only check top-level statements - components/hooks are declared at module scope
45 for (const node of ast.body) {
46 if (checkTopLevelNode(node)) {
47 return true;
48 }
49 }
50
51 return false;
52 }
53
54 function checkTopLevelNode(node: ESTree.Node): boolean {
55 // Handle Flow component/hook declarations (hermes-eslint produces these node types)
56 // @ts-expect-error not part of ESTree spec
57 if (node.type === 'ComponentDeclaration' || node.type === 'HookDeclaration') {
58 return true;
59 }
60
61 // Handle: export function MyComponent() {} or export const useHook = () => {}
62 if (node.type === 'ExportNamedDeclaration') {
63 const decl = (node as ESTree.ExportNamedDeclaration).declaration;
64 if (decl != null) {
65 return checkTopLevelNode(decl);
66 }
67 return false;
68 }
69
70 // Handle: export default function MyComponent() {} or export default () => {}
71 if (node.type === 'ExportDefaultDeclaration') {
72 const decl = (node as ESTree.ExportDefaultDeclaration).declaration;
73 // Anonymous default function export - compile conservatively
74 if (
75 decl.type === 'FunctionExpression' ||
76 decl.type === 'ArrowFunctionExpression' ||
77 (decl.type === 'FunctionDeclaration' &&
78 (decl as ESTree.FunctionDeclaration).id == null)
79 ) {
80 return true;
81 }
82 return checkTopLevelNode(decl as ESTree.Node);
83 }
84
85 // Handle: function MyComponent() {}
86 // Also handles Flow component/hook syntax transformed to FunctionDeclaration with flags
87 if (node.type === 'FunctionDeclaration') {
88 // Check for Hermes-added flags indicating Flow component/hook syntax
89 if ('__componentDeclaration' in node || '__hookDeclaration' in node) {
90 return true;
91 }
92 const id = (node as ESTree.FunctionDeclaration).id;
93 if (id != null) {
94 const name = id.name;
95 if (COMPONENT_NAME_PATTERN.test(name) || HOOK_NAME_PATTERN.test(name)) {
96 return true;
97 }
98 }
99 }
100
101 // Handle: const MyComponent = () => {} or const useHook = function() {}
102 if (node.type === 'VariableDeclaration') {
103 for (const decl of (node as ESTree.VariableDeclaration).declarations) {
104 if (decl.id.type === 'Identifier') {
105 const init = decl.init;
106 if (
107 init != null &&
108 (init.type === 'ArrowFunctionExpression' ||
109 init.type === 'FunctionExpression')
110 ) {
111 const name = decl.id.name;
112 if (
113 COMPONENT_NAME_PATTERN.test(name) ||
114 HOOK_NAME_PATTERN.test(name)
115 ) {
116 return true;
117 }
118 }
119 }
120 }
121 }
122
123 return false;
124 }
125
126 const COMPILER_OPTIONS: PluginOptions = {
127 outputMode: 'lint',
128 panicThreshold: 'none',
129 // Don't emit errors on Flow suppressions--Flow already gave a signal
130 flowSuppressions: false,
131 environment: {
132 validateRefAccessDuringRender: true,
133 validateNoSetStateInRender: true,
134 validateNoSetStateInEffects: true,
135 validateNoJSXInTryStatements: true,
136 validateNoImpureFunctionsInRender: true,
137 validateStaticComponents: true,
138 validateNoFreezingKnownMutableFunctions: true,
139 validateNoVoidUseMemo: true,
140 // TODO: remove, this should be in the type system
141 validateNoCapitalizedCalls: [],
142 validateHooksUsage: true,
143 validateNoDerivedComputationsInEffects: true,
144
145 // Experimental options controlled by ReactFeatureFlags
146 enableUseKeyedState: eprh_enableUseKeyedStateCompilerLint,
147 enableVerboseNoSetStateInEffect:
148 eprh_enableVerboseNoSetStateInEffectCompilerLint,
149 validateExhaustiveEffectDependencies:
150 eprh_enableExhaustiveEffectDependenciesCompilerLint,
151 },
152 };
153
154 export type RunCacheEntry = {
155 sourceCode: string;
156 filename: string;
157 userOpts: PluginOptions;
158 flowSuppressions: Array<{line: number; code: string}>;
159 events: Array<LoggerEvent>;
160 };
161
162 type RunParams = {
163 sourceCode: SourceCode;
164 filename: string;
165 userOpts: PluginOptions;
166 };
167 const FLOW_SUPPRESSION_REGEX = /\$FlowFixMe\[([^\]]*)\]/g;
168
169 function getFlowSuppressions(
170 sourceCode: SourceCode,
171 ): Array<{line: number; code: string}> {
172 const comments = sourceCode.getAllComments();
173 const results: Array<{line: number; code: string}> = [];
174
175 for (const commentNode of comments) {
176 const matches = commentNode.value.matchAll(FLOW_SUPPRESSION_REGEX);
177 for (const match of matches) {
178 if (match.index != null && commentNode.loc != null) {
179 const code = match[1];
180 results.push({
181 line: commentNode.loc!.end.line,
182 code,
183 });
184 }
185 }
186 }
187 return results;
188 }
189
190 function runReactCompilerImpl({
191 sourceCode,
192 filename,
193 userOpts,
194 }: RunParams): RunCacheEntry {
195 // Compat with older versions of eslint
196 const options = parsePluginOptions({
197 ...COMPILER_OPTIONS,
198 ...userOpts,
199 environment: {
200 ...COMPILER_OPTIONS.environment,
201 ...userOpts.environment,
202 },
203 });
204 const results: RunCacheEntry = {
205 sourceCode: sourceCode.text,
206 filename,
207 userOpts,
208 flowSuppressions: [],
209 events: [],
210 };
211 const userLogger: Logger | null = options.logger;
212 options.logger = {
213 logEvent: (eventFilename, event): void => {
214 userLogger?.logEvent(eventFilename, event);
215 results.events.push(event);
216 },
217 };
218
219 try {
220 options.environment = validateEnvironmentConfig(options.environment ?? {});
221 } catch (err: unknown) {
222 options.logger?.logEvent(filename, err as LoggerEvent);
223 }
224
225 let babelAST: ParseResult<File> | null = null;
226
227 if (filename.endsWith('.tsx') || filename.endsWith('.ts')) {
228 try {
229 babelAST = babelParse(sourceCode.text, {
230 sourceFilename: filename,
231 sourceType: 'unambiguous',
232 plugins: ['typescript', 'jsx'],
233 });
234 } catch {
235 /* empty */
236 }
237 } else {
238 try {
239 babelAST = HermesParser.parse(sourceCode.text, {
240 babel: true,
241 enableExperimentalComponentSyntax: true,
242 sourceFilename: filename,
243 sourceType: 'module',
244 });
245 } catch {
246 /* empty */
247 }
248 }
249
250 if (babelAST != null) {
251 results.flowSuppressions = getFlowSuppressions(sourceCode);
252 try {
253 transformFromAstSync(babelAST, sourceCode.text, {
254 filename,
255 highlightCode: false,
256 retainLines: true,
257 plugins: [[BabelPluginReactCompiler, options]],
258 sourceType: 'module',
259 configFile: false,
260 babelrc: false,
261 });
262 } catch (err) {
263 /* errors handled by injected logger */
264 }
265 }
266
267 return results;
268 }
269
270 const SENTINEL = Symbol();
271
272 // Array backed LRU cache -- should be small < 10 elements
273 class LRUCache<K, T> {
274 // newest at headIdx, then headIdx + 1, ..., tailIdx
275 #values: Array<[K, T | Error] | [typeof SENTINEL, void]>;
276 #headIdx: number = 0;
277
278 constructor(size: number) {
279 this.#values = new Array(size).fill(SENTINEL);
280 }
281
282 // gets a value and sets it as "recently used"
283 get(key: K): T | null {
284 const idx = this.#values.findIndex(entry => entry[0] === key);
285 // If found, move to front
286 if (idx === this.#headIdx) {
287 return this.#values[this.#headIdx][1] as T;
288 } else if (idx < 0) {
289 return null;
290 }
291
292 const entry: [K, T] = this.#values[idx] as [K, T];
293
294 const len = this.#values.length;
295 for (let i = 0; i < Math.min(idx, len - 1); i++) {
296 this.#values[(this.#headIdx + i + 1) % len] =
297 this.#values[(this.#headIdx + i) % len];
298 }
299 this.#values[this.#headIdx] = entry;
300 return entry[1];
301 }
302 push(key: K, value: T): void {
303 this.#headIdx =
304 (this.#headIdx - 1 + this.#values.length) % this.#values.length;
305 this.#values[this.#headIdx] = [key, value];
306 }
307 }
308 const cache = new LRUCache<string, RunCacheEntry>(10);
309
310 export default function runReactCompiler({
311 sourceCode,
312 filename,
313 userOpts,
314 }: RunParams): RunCacheEntry {
315 const entry = cache.get(filename);
316 if (
317 entry != null &&
318 entry.sourceCode === sourceCode.text &&
319 isDeepStrictEqual(entry.userOpts, userOpts)
320 ) {
321 return entry;
322 }
323
324 // Quick heuristic: skip files that don't appear to contain React code.
325 // We still cache the empty result so subsequent rules don't re-run the check.
326 if (!mayContainReactCode(sourceCode)) {
327 const emptyResult: RunCacheEntry = {
328 sourceCode: sourceCode.text,
329 filename,
330 userOpts,
331 flowSuppressions: [],
332 events: [],
333 };
334 if (entry != null) {
335 Object.assign(entry, emptyResult);
336 } else {
337 cache.push(filename, emptyResult);
338 }
339 return {...emptyResult};
340 }
341
342 const runEntry = runReactCompilerImpl({
343 sourceCode,
344 filename,
345 userOpts,
346 });
347 // If we have a cache entry, we can update it
348 if (entry != null) {
349 Object.assign(entry, runEntry);
350 } else {
351 cache.push(filename, runEntry);
352 }
353 return {...runEntry};
354 }