main
ts 304 lines 8.44 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
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 }