| 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 | } |