| 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 | import {transformFromAstSync} from '@babel/core'; |
| 9 | import {parse as babelParse} from '@babel/parser'; |
| 10 | import {File} from '@babel/types'; |
| 11 | import BabelPluginReactCompiler, { |
| 12 | parsePluginOptions, |
| 13 | validateEnvironmentConfig, |
| 14 | type PluginOptions, |
| 15 | } from 'babel-plugin-react-compiler/src'; |
| 16 | import {Logger, LoggerEvent} from 'babel-plugin-react-compiler/src/Entrypoint'; |
| 17 | import type {SourceCode} from 'eslint'; |
| 18 | // @ts-expect-error: no types available |
| 19 | import * as HermesParser from 'hermes-parser'; |
| 20 | import {isDeepStrictEqual} from 'util'; |
| 21 | import type {ParseResult} from '@babel/parser'; |
| 22 | |
| 23 | /** |
| 24 | * Lazy-loaded Rust compiler Babel plugin. |
| 25 | * Only loaded when __unstable_useRustCompiler is enabled. |
| 26 | */ |
| 27 | let _rustPluginLoaded = false; |
| 28 | let _rustPlugin: ((babel: any) => any) | null = null; |
| 29 | |
| 30 | function getRustPlugin(): (babel: any) => any { |
| 31 | if (!_rustPluginLoaded) { |
| 32 | _rustPluginLoaded = true; |
| 33 | try { |
| 34 | _rustPlugin = |
| 35 | // eslint-disable-next-line no-restricted-syntax |
| 36 | require('babel-plugin-react-compiler-rust').default; |
| 37 | } catch { |
| 38 | _rustPlugin = null; |
| 39 | } |
| 40 | } |
| 41 | if (_rustPlugin == null) { |
| 42 | throw new Error( |
| 43 | 'eslint-plugin-react-compiler: __unstable_useRustCompiler is enabled but ' + |
| 44 | 'babel-plugin-react-compiler-rust is not available. ' + |
| 45 | 'Make sure the package is installed and its native module is built.', |
| 46 | ); |
| 47 | } |
| 48 | return _rustPlugin; |
| 49 | } |
| 50 | |
| 51 | const COMPILER_OPTIONS: PluginOptions = { |
| 52 | outputMode: 'lint', |
| 53 | panicThreshold: 'none', |
| 54 | // Don't emit errors on Flow suppressions--Flow already gave a signal |
| 55 | flowSuppressions: false, |
| 56 | environment: validateEnvironmentConfig({ |
| 57 | validateRefAccessDuringRender: true, |
| 58 | validateNoSetStateInRender: true, |
| 59 | validateNoSetStateInEffects: true, |
| 60 | validateNoJSXInTryStatements: true, |
| 61 | validateNoImpureFunctionsInRender: true, |
| 62 | validateStaticComponents: true, |
| 63 | validateNoFreezingKnownMutableFunctions: true, |
| 64 | validateNoVoidUseMemo: true, |
| 65 | // TODO: remove, this should be in the type system |
| 66 | validateNoCapitalizedCalls: [], |
| 67 | validateHooksUsage: true, |
| 68 | validateNoDerivedComputationsInEffects: true, |
| 69 | }), |
| 70 | }; |
| 71 | |
| 72 | export type RunCacheEntry = { |
| 73 | sourceCode: string; |
| 74 | filename: string; |
| 75 | userOpts: PluginOptions; |
| 76 | flowSuppressions: Array<{line: number; code: string}>; |
| 77 | events: Array<LoggerEvent>; |
| 78 | }; |
| 79 | |
| 80 | type RunParams = { |
| 81 | sourceCode: SourceCode; |
| 82 | filename: string; |
| 83 | userOpts: PluginOptions; |
| 84 | }; |
| 85 | const FLOW_SUPPRESSION_REGEX = /\$FlowFixMe\[([^\]]*)\]/g; |
| 86 | |
| 87 | function getFlowSuppressions( |
| 88 | sourceCode: SourceCode, |
| 89 | ): Array<{line: number; code: string}> { |
| 90 | const comments = sourceCode.getAllComments(); |
| 91 | const results: Array<{line: number; code: string}> = []; |
| 92 | |
| 93 | for (const commentNode of comments) { |
| 94 | const matches = commentNode.value.matchAll(FLOW_SUPPRESSION_REGEX); |
| 95 | for (const match of matches) { |
| 96 | if (match.index != null && commentNode.loc != null) { |
| 97 | const code = match[1]; |
| 98 | results.push({ |
| 99 | line: commentNode.loc!.end.line, |
| 100 | code, |
| 101 | }); |
| 102 | } |
| 103 | } |
| 104 | } |
| 105 | return results; |
| 106 | } |
| 107 | |
| 108 | function runReactCompilerImpl({ |
| 109 | sourceCode, |
| 110 | filename, |
| 111 | userOpts, |
| 112 | }: RunParams): RunCacheEntry { |
| 113 | const useRustCompiler = |
| 114 | (userOpts as Record<string, unknown>).__unstable_useRustCompiler === true; |
| 115 | |
| 116 | // Compat with older versions of eslint |
| 117 | const options: PluginOptions = parsePluginOptions({ |
| 118 | ...COMPILER_OPTIONS, |
| 119 | ...userOpts, |
| 120 | environment: { |
| 121 | ...COMPILER_OPTIONS.environment, |
| 122 | ...userOpts.environment, |
| 123 | }, |
| 124 | }); |
| 125 | const results: RunCacheEntry = { |
| 126 | sourceCode: sourceCode.text, |
| 127 | filename, |
| 128 | userOpts, |
| 129 | flowSuppressions: [], |
| 130 | events: [], |
| 131 | }; |
| 132 | const userLogger: Logger | null = options.logger; |
| 133 | options.logger = { |
| 134 | logEvent: (eventFilename, event): void => { |
| 135 | userLogger?.logEvent(eventFilename, event); |
| 136 | results.events.push(event); |
| 137 | }, |
| 138 | }; |
| 139 | |
| 140 | try { |
| 141 | options.environment = validateEnvironmentConfig(options.environment ?? {}); |
| 142 | } catch (err: unknown) { |
| 143 | options.logger?.logEvent(filename, err as LoggerEvent); |
| 144 | } |
| 145 | |
| 146 | let babelAST: ParseResult<File> | null = null; |
| 147 | if (filename.endsWith('.tsx') || filename.endsWith('.ts')) { |
| 148 | try { |
| 149 | babelAST = babelParse(sourceCode.text, { |
| 150 | sourceFilename: filename, |
| 151 | sourceType: 'unambiguous', |
| 152 | plugins: ['typescript', 'jsx'], |
| 153 | }); |
| 154 | } catch { |
| 155 | /* empty */ |
| 156 | } |
| 157 | } else { |
| 158 | try { |
| 159 | babelAST = HermesParser.parse(sourceCode.text, { |
| 160 | babel: true, |
| 161 | enableExperimentalComponentSyntax: true, |
| 162 | sourceFilename: filename, |
| 163 | sourceType: 'module', |
| 164 | }); |
| 165 | } catch { |
| 166 | /* empty */ |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | if (babelAST != null) { |
| 171 | results.flowSuppressions = getFlowSuppressions(sourceCode); |
| 172 | |
| 173 | if (useRustCompiler) { |
| 174 | // Rust compiler path: use the Rust NAPI Babel plugin instead of the |
| 175 | // TS compiler. The Rust plugin handles scope extraction, compilation, |
| 176 | // and event forwarding internally via its own resolveOptions + |
| 177 | // compileWithRust pipeline. |
| 178 | const RustPlugin = getRustPlugin(); |
| 179 | const rustOpts: Record<string, unknown> = { |
| 180 | ...COMPILER_OPTIONS, |
| 181 | ...userOpts, |
| 182 | environment: { |
| 183 | ...(COMPILER_OPTIONS.environment as Record<string, unknown>), |
| 184 | ...((userOpts.environment as Record<string, unknown>) ?? {}), |
| 185 | }, |
| 186 | logger: { |
| 187 | logEvent: ( |
| 188 | eventFilename: string | null, |
| 189 | event: LoggerEvent, |
| 190 | ): void => { |
| 191 | userLogger?.logEvent(eventFilename ?? '', event); |
| 192 | results.events.push(event); |
| 193 | }, |
| 194 | }, |
| 195 | }; |
| 196 | // Don't pass the ESLint-only flag to the compiler |
| 197 | delete rustOpts.__unstable_useRustCompiler; |
| 198 | |
| 199 | try { |
| 200 | transformFromAstSync(babelAST, sourceCode.text, { |
| 201 | filename, |
| 202 | highlightCode: false, |
| 203 | retainLines: true, |
| 204 | plugins: [[RustPlugin, rustOpts]], |
| 205 | sourceType: 'module', |
| 206 | configFile: false, |
| 207 | babelrc: false, |
| 208 | }); |
| 209 | } catch { |
| 210 | /* errors handled by injected logger */ |
| 211 | } |
| 212 | } else { |
| 213 | try { |
| 214 | transformFromAstSync(babelAST, sourceCode.text, { |
| 215 | filename, |
| 216 | highlightCode: false, |
| 217 | retainLines: true, |
| 218 | plugins: [[BabelPluginReactCompiler, options]], |
| 219 | sourceType: 'module', |
| 220 | configFile: false, |
| 221 | babelrc: false, |
| 222 | }); |
| 223 | } catch (err) { |
| 224 | /* errors handled by injected logger */ |
| 225 | } |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | return results; |
| 230 | } |
| 231 | |
| 232 | const SENTINEL = Symbol(); |
| 233 | |
| 234 | // Array backed LRU cache -- should be small < 10 elements |
| 235 | class LRUCache<K, T> { |
| 236 | // newest at headIdx, then headIdx + 1, ..., tailIdx |
| 237 | #values: Array<[K, T | Error] | [typeof SENTINEL, void]>; |
| 238 | #headIdx: number = 0; |
| 239 | |
| 240 | constructor(size: number) { |
| 241 | this.#values = new Array(size).fill(SENTINEL); |
| 242 | } |
| 243 | |
| 244 | // gets a value and sets it as "recently used" |
| 245 | get(key: K): T | null { |
| 246 | let idx = this.#values.findIndex(entry => entry[0] === key); |
| 247 | // If found, move to front |
| 248 | if (idx === this.#headIdx) { |
| 249 | return this.#values[this.#headIdx][1] as T; |
| 250 | } else if (idx < 0) { |
| 251 | return null; |
| 252 | } |
| 253 | |
| 254 | const entry: [K, T] = this.#values[idx] as [K, T]; |
| 255 | |
| 256 | const len = this.#values.length; |
| 257 | for (let i = 0; i < Math.min(idx, len - 1); i++) { |
| 258 | this.#values[(this.#headIdx + i + 1) % len] = |
| 259 | this.#values[(this.#headIdx + i) % len]; |
| 260 | } |
| 261 | this.#values[this.#headIdx] = entry; |
| 262 | return entry[1]; |
| 263 | } |
| 264 | push(key: K, value: T): void { |
| 265 | this.#headIdx = |
| 266 | (this.#headIdx - 1 + this.#values.length) % this.#values.length; |
| 267 | this.#values[this.#headIdx] = [key, value]; |
| 268 | } |
| 269 | } |
| 270 | const cache = new LRUCache<string, RunCacheEntry>(10); |
| 271 | |
| 272 | export default function runReactCompiler({ |
| 273 | sourceCode, |
| 274 | filename, |
| 275 | userOpts, |
| 276 | }: RunParams): RunCacheEntry { |
| 277 | const entry = cache.get(filename); |
| 278 | if ( |
| 279 | entry != null && |
| 280 | entry.sourceCode === sourceCode.text && |
| 281 | isDeepStrictEqual(entry.userOpts, userOpts) |
| 282 | ) { |
| 283 | return entry; |
| 284 | } else if (entry != null) { |
| 285 | if (process.env['DEBUG']) { |
| 286 | console.log( |
| 287 | `Cache hit for ${filename}, but source code or options changed, recomputing`, |
| 288 | ); |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | const runEntry = runReactCompilerImpl({ |
| 293 | sourceCode, |
| 294 | filename, |
| 295 | userOpts, |
| 296 | }); |
| 297 | // If we have a cache entry, we can update it |
| 298 | if (entry != null) { |
| 299 | Object.assign(entry, runEntry); |
| 300 | } else { |
| 301 | cache.push(filename, runEntry); |
| 302 | } |
| 303 | return {...runEntry}; |
| 304 | } |