main
ts 1,357 lines 38.2 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 {NodePath} from '@babel/core';
9 import * as t from '@babel/types';
10 import {
11 CompilerDiagnostic,
12 CompilerError,
13 CompilerErrorDetail,
14 ErrorCategory,
15 } from '../CompilerError';
16 import {CompileErrorDetail} from './Options';
17 import {ExternalFunction, ReactFunctionType} from '../HIR/Environment';
18 import {CodegenFunction} from '../ReactiveScopes';
19 import {isComponentDeclaration} from '../Utils/ComponentDeclaration';
20 import {isHookDeclaration} from '../Utils/HookDeclaration';
21 import {assertExhaustive} from '../Utils/utils';
22 import {insertGatedFunctionDeclaration} from './Gating';
23 import {
24 addImportsToProgram,
25 ProgramContext,
26 validateRestrictedImports,
27 } from './Imports';
28 import {
29 CompilerOutputMode,
30 CompilerReactTarget,
31 ParsedPluginOptions,
32 PluginOptions,
33 } from './Options';
34 import {compileFn} from './Pipeline';
35 import {
36 filterSuppressionsThatAffectFunction,
37 findProgramSuppressions,
38 suppressionsToCompilerError,
39 } from './Suppression';
40 import {GeneratedSource} from '../HIR';
41 import {Err, Ok, Result} from '../Utils/Result';
42
43 export type CompilerPass = {
44 opts: ParsedPluginOptions;
45 filename: string | null;
46 comments: Array<t.CommentBlock | t.CommentLine>;
47 code: string | null;
48 };
49 export const OPT_IN_DIRECTIVES = new Set(['use forget', 'use memo']);
50 export const OPT_OUT_DIRECTIVES = new Set(['use no forget', 'use no memo']);
51 const DYNAMIC_GATING_DIRECTIVE = new RegExp('^use memo if\\(([^\\)]*)\\)$');
52
53 export function tryFindDirectiveEnablingMemoization(
54 directives: Array<t.Directive>,
55 opts: ParsedPluginOptions,
56 ): Result<t.Directive | null, CompilerError> {
57 const optIn = directives.find(directive =>
58 OPT_IN_DIRECTIVES.has(directive.value.value),
59 );
60 if (optIn != null) {
61 return Ok(optIn);
62 }
63 const dynamicGating = findDirectivesDynamicGating(directives, opts);
64 if (dynamicGating.isOk()) {
65 return Ok(dynamicGating.unwrap()?.directive ?? null);
66 } else {
67 return Err(dynamicGating.unwrapErr());
68 }
69 }
70
71 export function findDirectiveDisablingMemoization(
72 directives: Array<t.Directive>,
73 {customOptOutDirectives}: PluginOptions,
74 ): t.Directive | null {
75 if (customOptOutDirectives != null) {
76 return (
77 directives.find(
78 directive =>
79 customOptOutDirectives.indexOf(directive.value.value) !== -1,
80 ) ?? null
81 );
82 }
83 return (
84 directives.find(directive =>
85 OPT_OUT_DIRECTIVES.has(directive.value.value),
86 ) ?? null
87 );
88 }
89 function findDirectivesDynamicGating(
90 directives: Array<t.Directive>,
91 opts: ParsedPluginOptions,
92 ): Result<
93 {
94 gating: ExternalFunction;
95 directive: t.Directive;
96 } | null,
97 CompilerError
98 > {
99 if (opts.dynamicGating === null) {
100 return Ok(null);
101 }
102 const errors = new CompilerError();
103 const result: Array<{directive: t.Directive; match: string}> = [];
104
105 for (const directive of directives) {
106 const maybeMatch = DYNAMIC_GATING_DIRECTIVE.exec(directive.value.value);
107 if (maybeMatch != null && maybeMatch[1] != null) {
108 if (t.isValidIdentifier(maybeMatch[1])) {
109 result.push({directive, match: maybeMatch[1]});
110 } else {
111 errors.push({
112 reason: `Dynamic gating directive is not a valid JavaScript identifier`,
113 description: `Found '${directive.value.value}'`,
114 category: ErrorCategory.Gating,
115 loc: directive.loc ?? null,
116 suggestions: null,
117 });
118 }
119 }
120 }
121 if (errors.hasAnyErrors()) {
122 return Err(errors);
123 } else if (result.length > 1) {
124 const error = new CompilerError();
125 error.push({
126 reason: `Multiple dynamic gating directives found`,
127 description: `Expected a single directive but found [${result
128 .map(r => r.directive.value.value)
129 .join(', ')}]`,
130 category: ErrorCategory.Gating,
131 loc: result[0].directive.loc ?? null,
132 suggestions: null,
133 });
134 return Err(error);
135 } else if (result.length === 1) {
136 return Ok({
137 gating: {
138 source: opts.dynamicGating.source,
139 importSpecifierName: result[0].match,
140 },
141 directive: result[0].directive,
142 });
143 } else {
144 return Ok(null);
145 }
146 }
147
148 function isError(err: unknown): boolean {
149 return !(err instanceof CompilerError) || err.hasErrors();
150 }
151
152 function isConfigError(err: unknown): boolean {
153 if (err instanceof CompilerError) {
154 return err.details.some(detail => detail.category === ErrorCategory.Config);
155 }
156 return false;
157 }
158
159 export type BabelFn =
160 | NodePath<t.FunctionDeclaration>
161 | NodePath<t.FunctionExpression>
162 | NodePath<t.ArrowFunctionExpression>;
163
164 export type CompileResult = {
165 /**
166 * Distinguishes existing functions that were compiled ('original') from
167 * functions which were outlined. Only original functions need to be gated
168 * if gating mode is enabled.
169 */
170 kind: 'original' | 'outlined';
171 originalFn: BabelFn;
172 compiledFn: CodegenFunction;
173 };
174
175 /**
176 * Format a CompilerDiagnostic or CompilerErrorDetail class instance
177 * into a plain object for logEvent(). This ensures the logged value
178 * has all fields as direct properties (no getters, no nested `options`).
179 */
180 export function formatDetailForLogging(
181 detail: CompilerDiagnostic | CompilerErrorDetail,
182 ): CompileErrorDetail {
183 if (detail instanceof CompilerDiagnostic) {
184 return {
185 category: detail.category,
186 reason: detail.reason,
187 description: detail.description ?? null,
188 severity: detail.severity,
189 suggestions: detail.suggestions ?? null,
190 details: detail.options.details.map(d => {
191 if (d.kind === 'error') {
192 const loc = d.loc != null && typeof d.loc !== 'symbol' ? d.loc : null;
193 return {kind: d.kind, loc, message: d.message};
194 } else {
195 return {kind: d.kind, loc: null, message: d.message};
196 }
197 }),
198 };
199 } else {
200 const loc =
201 detail.loc != null && typeof detail.loc !== 'symbol' ? detail.loc : null;
202 return {
203 category: detail.category,
204 reason: detail.reason,
205 description: detail.description ?? null,
206 severity: detail.severity,
207 suggestions: detail.suggestions ?? null,
208 loc,
209 };
210 }
211 }
212
213 function logError(
214 err: unknown,
215 context: {
216 opts: PluginOptions;
217 filename: string | null;
218 },
219 fnLoc: t.SourceLocation | null,
220 ): void {
221 if (context.opts.logger) {
222 if (err instanceof CompilerError) {
223 for (const detail of err.details) {
224 context.opts.logger.logEvent(context.filename, {
225 kind: 'CompileError',
226 fnLoc,
227 detail: formatDetailForLogging(detail),
228 });
229 }
230 } else {
231 let stringifiedError;
232 if (err instanceof Error) {
233 stringifiedError = err.stack ?? err.message;
234 } else {
235 stringifiedError = err?.toString() ?? '[ null ]';
236 }
237
238 context.opts.logger.logEvent(context.filename, {
239 kind: 'PipelineError',
240 fnLoc,
241 data: stringifiedError,
242 });
243 }
244 }
245 }
246 function handleError(
247 err: unknown,
248 context: {
249 opts: PluginOptions;
250 filename: string | null;
251 },
252 fnLoc: t.SourceLocation | null,
253 ): void {
254 logError(err, context, fnLoc);
255 if (
256 context.opts.panicThreshold === 'all_errors' ||
257 (context.opts.panicThreshold === 'critical_errors' && isError(err)) ||
258 isConfigError(err) // Always throws regardless of panic threshold
259 ) {
260 throw err;
261 }
262 }
263
264 export function createNewFunctionNode(
265 originalFn: BabelFn,
266 compiledFn: CodegenFunction,
267 ): t.FunctionDeclaration | t.ArrowFunctionExpression | t.FunctionExpression {
268 let transformedFn:
269 | t.FunctionDeclaration
270 | t.ArrowFunctionExpression
271 | t.FunctionExpression;
272 switch (originalFn.node.type) {
273 case 'FunctionDeclaration': {
274 const fn: t.FunctionDeclaration = {
275 type: 'FunctionDeclaration',
276 id: compiledFn.id,
277 loc: originalFn.node.loc ?? null,
278 async: compiledFn.async,
279 generator: compiledFn.generator,
280 params: compiledFn.params,
281 body: compiledFn.body,
282 };
283 transformedFn = fn;
284 break;
285 }
286 case 'ArrowFunctionExpression': {
287 const fn: t.ArrowFunctionExpression = {
288 type: 'ArrowFunctionExpression',
289 loc: originalFn.node.loc ?? null,
290 async: compiledFn.async,
291 generator: compiledFn.generator,
292 params: compiledFn.params,
293 expression: originalFn.node.expression,
294 body: compiledFn.body,
295 };
296 transformedFn = fn;
297 break;
298 }
299 case 'FunctionExpression': {
300 const fn: t.FunctionExpression = {
301 type: 'FunctionExpression',
302 id: compiledFn.id,
303 loc: originalFn.node.loc ?? null,
304 async: compiledFn.async,
305 generator: compiledFn.generator,
306 params: compiledFn.params,
307 body: compiledFn.body,
308 };
309 transformedFn = fn;
310 break;
311 }
312 default: {
313 assertExhaustive(
314 originalFn.node,
315 `Creating unhandled function: ${originalFn.node}`,
316 );
317 }
318 }
319 // Avoid visiting the new transformed version
320 return transformedFn;
321 }
322
323 function insertNewOutlinedFunctionNode(
324 program: NodePath<t.Program>,
325 originalFn: BabelFn,
326 compiledFn: CodegenFunction,
327 ): BabelFn {
328 switch (originalFn.type) {
329 case 'FunctionDeclaration': {
330 return originalFn.insertAfter(
331 createNewFunctionNode(originalFn, compiledFn),
332 )[0]!;
333 }
334 /**
335 * We can't just append the outlined function as a sibling of the original function if it is an
336 * (Arrow)FunctionExpression parented by a VariableDeclaration, as this would cause its parent
337 * to become a SequenceExpression instead which breaks a bunch of assumptions elsewhere in the
338 * plugin.
339 *
340 * To get around this, we always synthesize a new FunctionDeclaration for the outlined function
341 * and insert it as a true sibling to the original function.
342 */
343 case 'ArrowFunctionExpression':
344 case 'FunctionExpression': {
345 const fn: t.FunctionDeclaration = {
346 type: 'FunctionDeclaration',
347 id: compiledFn.id,
348 loc: originalFn.node.loc ?? null,
349 async: compiledFn.async,
350 generator: compiledFn.generator,
351 params: compiledFn.params,
352 body: compiledFn.body,
353 };
354 const insertedFuncDecl = program.pushContainer('body', [fn])[0]!;
355 CompilerError.invariant(insertedFuncDecl.isFunctionDeclaration(), {
356 reason: 'Expected inserted function declaration',
357 description: `Got: ${insertedFuncDecl}`,
358 loc: insertedFuncDecl.node?.loc ?? GeneratedSource,
359 });
360 return insertedFuncDecl;
361 }
362 default: {
363 assertExhaustive(
364 originalFn,
365 `Inserting unhandled function: ${originalFn}`,
366 );
367 }
368 }
369 }
370
371 const DEFAULT_ESLINT_SUPPRESSIONS = [
372 'react-hooks/exhaustive-deps',
373 'react-hooks/rules-of-hooks',
374 ];
375
376 function isFilePartOfSources(
377 sources: Array<string> | ((filename: string) => boolean),
378 filename: string,
379 ): boolean {
380 if (typeof sources === 'function') {
381 return sources(filename);
382 }
383
384 for (const prefix of sources) {
385 if (filename.indexOf(prefix) !== -1) {
386 return true;
387 }
388 }
389
390 return false;
391 }
392
393 /**
394 * Main entrypoint for React Compiler.
395 *
396 * @param program The Babel program node to compile
397 * @param pass Compiler configuration and context
398 * @returns Compilation results or null if compilation was skipped
399 */
400 export function compileProgram(
401 program: NodePath<t.Program>,
402 pass: CompilerPass,
403 ): void {
404 /**
405 * This is directly invoked by the react-compiler babel plugin, so exceptions
406 * thrown by this function will fail the babel build.
407 * - call `handleError` if your error is recoverable.
408 * Unless the error is a warning / info diagnostic, compilation of a function
409 * / entire file should also be skipped.
410 * - throw an exception if the error is fatal / not recoverable.
411 * Examples of this are invalid compiler configs or failure to codegen outlined
412 * functions *after* already emitting optimized components / hooks that invoke
413 * the outlined functions.
414 */
415 if (shouldSkipCompilation(program, pass)) {
416 return;
417 }
418 const restrictedImportsErr = validateRestrictedImports(
419 program,
420 pass.opts.environment,
421 );
422 if (restrictedImportsErr) {
423 handleError(restrictedImportsErr, pass, null);
424 return;
425 }
426 /*
427 * Record lint errors and critical errors as depending on Forget's config,
428 * we may still need to run Forget's analysis on every function (even if we
429 * have already encountered errors) for reporting.
430 */
431 const suppressions = findProgramSuppressions(
432 pass.comments,
433 /*
434 * If the compiler is validating hooks rules and exhaustive memo dependencies, we don't need to check
435 * for React ESLint suppressions
436 */
437 pass.opts.environment.validateExhaustiveMemoizationDependencies &&
438 pass.opts.environment.validateHooksUsage
439 ? null
440 : (pass.opts.eslintSuppressionRules ?? DEFAULT_ESLINT_SUPPRESSIONS),
441 // Always bail on Flow suppressions
442 pass.opts.flowSuppressions,
443 );
444
445 const programContext = new ProgramContext({
446 program: program,
447 opts: pass.opts,
448 filename: pass.filename,
449 code: pass.code,
450 suppressions,
451 hasModuleScopeOptOut:
452 findDirectiveDisablingMemoization(program.node.directives, pass.opts) !=
453 null,
454 });
455
456 const queue: Array<CompileSource> = findFunctionsToCompile(
457 program,
458 pass,
459 programContext,
460 );
461 const compiledFns: Array<CompileResult> = [];
462
463 // outputMode takes precedence if specified
464 const outputMode: CompilerOutputMode =
465 pass.opts.outputMode ?? (pass.opts.noEmit ? 'lint' : 'client');
466 while (queue.length !== 0) {
467 const current = queue.shift()!;
468 const compiled = processFn(
469 current.fn,
470 current.fnType,
471 programContext,
472 outputMode,
473 );
474
475 if (compiled != null) {
476 for (const outlined of compiled.outlined) {
477 CompilerError.invariant(outlined.fn.outlined.length === 0, {
478 reason: 'Unexpected nested outlined functions',
479 loc: outlined.fn.loc,
480 });
481 const fn = insertNewOutlinedFunctionNode(
482 program,
483 current.fn,
484 outlined.fn,
485 );
486 fn.skip();
487 programContext.alreadyCompiled.add(fn.node);
488 if (outlined.type !== null) {
489 queue.push({
490 kind: 'outlined',
491 fn,
492 fnType: outlined.type,
493 });
494 }
495 }
496 compiledFns.push({
497 kind: current.kind,
498 originalFn: current.fn,
499 compiledFn: compiled,
500 });
501 }
502 }
503
504 // Avoid modifying the program if we find a program level opt-out
505 if (programContext.hasModuleScopeOptOut) {
506 if (compiledFns.length > 0) {
507 const error = new CompilerError();
508 error.pushErrorDetail(
509 new CompilerErrorDetail({
510 reason:
511 'Unexpected compiled functions when module scope opt-out is present',
512 category: ErrorCategory.Invariant,
513 loc: null,
514 }),
515 );
516 handleError(error, programContext, null);
517 }
518 return;
519 }
520
521 // Insert React Compiler generated functions into the Babel AST
522 applyCompiledFunctions(program, compiledFns, pass, programContext);
523 }
524
525 type CompileSource = {
526 kind: 'original' | 'outlined';
527 fn: BabelFn;
528 fnType: ReactFunctionType;
529 };
530 /**
531 * Find all React components and hooks that need to be compiled
532 *
533 * @returns An array of React functions from @param program to transform
534 */
535 function findFunctionsToCompile(
536 program: NodePath<t.Program>,
537 pass: CompilerPass,
538 programContext: ProgramContext,
539 ): Array<CompileSource> {
540 const queue: Array<CompileSource> = [];
541 const traverseFunction = (fn: BabelFn, pass: CompilerPass): void => {
542 // In 'all' mode, compile only top level functions
543 if (
544 pass.opts.compilationMode === 'all' &&
545 fn.scope.getProgramParent() !== fn.scope.parent
546 ) {
547 return;
548 }
549
550 const fnType = getReactFunctionType(fn, pass);
551
552 if (fnType === null || programContext.alreadyCompiled.has(fn.node)) {
553 return;
554 }
555
556 /*
557 * We may be generating a new FunctionDeclaration node, so we must skip over it or this
558 * traversal will loop infinitely.
559 * Ensure we avoid visiting the original function again.
560 */
561 programContext.alreadyCompiled.add(fn.node);
562 fn.skip();
563
564 queue.push({kind: 'original', fn, fnType});
565 };
566
567 // Main traversal to compile with Forget
568 program.traverse(
569 {
570 ClassDeclaration(node: NodePath<t.ClassDeclaration>) {
571 /*
572 * Don't visit functions defined inside classes, because they
573 * can reference `this` which is unsafe for compilation
574 */
575 node.skip();
576 },
577
578 ClassExpression(node: NodePath<t.ClassExpression>) {
579 /*
580 * Don't visit functions defined inside classes, because they
581 * can reference `this` which is unsafe for compilation
582 */
583 node.skip();
584 },
585
586 FunctionDeclaration: traverseFunction,
587
588 FunctionExpression: traverseFunction,
589
590 ArrowFunctionExpression: traverseFunction,
591 },
592 {
593 ...pass,
594 opts: {...pass.opts, ...pass.opts},
595 filename: pass.filename ?? null,
596 },
597 );
598 return queue;
599 }
600
601 /**
602 * Try to compile a source function, taking into account all local suppressions,
603 * opt-ins, and opt-outs.
604 *
605 * Errors encountered during compilation are either logged (if recoverable) or
606 * thrown (if non-recoverable).
607 *
608 * @returns the compiled function or null if the function was skipped (due to
609 * config settings and/or outputs)
610 */
611 function processFn(
612 fn: BabelFn,
613 fnType: ReactFunctionType,
614 programContext: ProgramContext,
615 outputMode: CompilerOutputMode,
616 ): null | CodegenFunction {
617 let directives: {
618 optIn: t.Directive | null;
619 optOut: t.Directive | null;
620 };
621 if (fn.node.body.type !== 'BlockStatement') {
622 directives = {
623 optIn: null,
624 optOut: null,
625 };
626 } else {
627 const optIn = tryFindDirectiveEnablingMemoization(
628 fn.node.body.directives,
629 programContext.opts,
630 );
631 if (optIn.isErr()) {
632 /**
633 * If parsing opt-in directive fails, it's most likely that React Compiler
634 * was not tested or rolled out on this function. In that case, we handle
635 * the error and fall back to the safest option which is to not optimize
636 * the function.
637 */
638 handleError(optIn.unwrapErr(), programContext, fn.node.loc ?? null);
639 return null;
640 }
641 directives = {
642 optIn: optIn.unwrapOr(null),
643 optOut: findDirectiveDisablingMemoization(
644 fn.node.body.directives,
645 programContext.opts,
646 ),
647 };
648 }
649
650 let compiledFn: CodegenFunction;
651 const compileResult = tryCompileFunction(
652 fn,
653 fnType,
654 programContext,
655 outputMode,
656 );
657 if (compileResult.kind === 'error') {
658 if (directives.optOut != null) {
659 logError(compileResult.error, programContext, fn.node.loc ?? null);
660 } else {
661 handleError(compileResult.error, programContext, fn.node.loc ?? null);
662 }
663 return null;
664 } else {
665 compiledFn = compileResult.compiledFn;
666 }
667
668 /**
669 * If 'use no forget/memo' is present and we still ran the code through the
670 * compiler for validation, log a skip event and don't mutate the babel AST.
671 * This allows us to flag if there is an unused 'use no forget/memo'
672 * directive.
673 */
674 if (
675 programContext.opts.ignoreUseNoForget === false &&
676 directives.optOut != null
677 ) {
678 programContext.logEvent({
679 kind: 'CompileSkip',
680 fnLoc: fn.node.body.loc ?? null,
681 reason: `Skipped due to '${directives.optOut.value.value}' directive.`,
682 loc: directives.optOut.loc ?? null,
683 });
684 return null;
685 }
686 programContext.logEvent({
687 kind: 'CompileSuccess',
688 fnLoc: fn.node.loc ?? null,
689 fnName: compiledFn.id?.name ?? null,
690 memoSlots: compiledFn.memoSlotsUsed,
691 memoBlocks: compiledFn.memoBlocks,
692 memoValues: compiledFn.memoValues,
693 prunedMemoBlocks: compiledFn.prunedMemoBlocks,
694 prunedMemoValues: compiledFn.prunedMemoValues,
695 });
696
697 if (programContext.hasModuleScopeOptOut) {
698 return null;
699 } else if (programContext.opts.outputMode === 'lint') {
700 return null;
701 } else if (
702 programContext.opts.compilationMode === 'annotation' &&
703 directives.optIn == null
704 ) {
705 /**
706 * If no opt-in directive is found and the compiler is configured in
707 * annotation mode, don't insert the compiled function.
708 */
709 return null;
710 } else {
711 return compiledFn;
712 }
713 }
714
715 function tryCompileFunction(
716 fn: BabelFn,
717 fnType: ReactFunctionType,
718 programContext: ProgramContext,
719 outputMode: CompilerOutputMode,
720 ):
721 | {kind: 'compile'; compiledFn: CodegenFunction}
722 | {kind: 'error'; error: unknown} {
723 /**
724 * Note that Babel does not attach comment nodes to nodes; they are dangling off of the
725 * Program node itself. We need to figure out whether an eslint suppression range
726 * applies to this function first.
727 */
728 const suppressionsInFunction = filterSuppressionsThatAffectFunction(
729 programContext.suppressions,
730 fn,
731 );
732 if (suppressionsInFunction.length > 0) {
733 return {
734 kind: 'error',
735 error: suppressionsToCompilerError(suppressionsInFunction),
736 };
737 }
738
739 try {
740 const result = compileFn(
741 fn,
742 programContext.opts.environment,
743 fnType,
744 outputMode,
745 programContext,
746 programContext.opts.logger,
747 programContext.filename,
748 programContext.code,
749 );
750 if (result.isOk()) {
751 return {kind: 'compile', compiledFn: result.unwrap()};
752 } else {
753 return {kind: 'error', error: result.unwrapErr()};
754 }
755 } catch (err) {
756 /**
757 * A pass incorrectly threw instead of recording the error.
758 * Log for detection in development.
759 */
760 if (
761 err instanceof CompilerError &&
762 err.details.every(detail => detail.category !== ErrorCategory.Invariant)
763 ) {
764 programContext.logEvent({
765 kind: 'CompileUnexpectedThrow',
766 fnLoc: fn.node.loc ?? null,
767 data: err.toString(),
768 });
769 }
770 return {kind: 'error', error: err};
771 }
772 }
773
774 /**
775 * Applies React Compiler generated functions to the babel AST by replacing
776 * existing functions in place or inserting new declarations.
777 */
778 function applyCompiledFunctions(
779 program: NodePath<t.Program>,
780 compiledFns: Array<CompileResult>,
781 pass: CompilerPass,
782 programContext: ProgramContext,
783 ): void {
784 let referencedBeforeDeclared = null;
785 for (const result of compiledFns) {
786 const {kind, originalFn, compiledFn} = result;
787 const transformedFn = createNewFunctionNode(originalFn, compiledFn);
788 programContext.alreadyCompiled.add(transformedFn);
789
790 let dynamicGating: ExternalFunction | null = null;
791 if (originalFn.node.body.type === 'BlockStatement') {
792 const result = findDirectivesDynamicGating(
793 originalFn.node.body.directives,
794 pass.opts,
795 );
796 if (result.isOk()) {
797 dynamicGating = result.unwrap()?.gating ?? null;
798 }
799 }
800 const functionGating = dynamicGating ?? pass.opts.gating;
801 if (kind === 'original' && functionGating != null) {
802 referencedBeforeDeclared ??=
803 getFunctionReferencedBeforeDeclarationAtTopLevel(program, compiledFns);
804 insertGatedFunctionDeclaration(
805 originalFn,
806 transformedFn,
807 programContext,
808 functionGating,
809 referencedBeforeDeclared.has(result),
810 );
811 } else {
812 originalFn.replaceWith(transformedFn);
813 }
814 }
815
816 // Forget compiled the component, we need to update existing imports of useMemoCache
817 if (compiledFns.length > 0) {
818 // Codegen may have registered `_c` for a function that was later discarded.
819 const anyAppliedUsesMemo = compiledFns.some(
820 result => result.compiledFn.memoSlotsUsed > 0,
821 );
822 if (!anyAppliedUsesMemo) {
823 programContext.removeMemoCacheImport();
824 }
825 addImportsToProgram(program, programContext);
826 }
827 }
828
829 function shouldSkipCompilation(
830 program: NodePath<t.Program>,
831 pass: CompilerPass,
832 ): boolean {
833 if (pass.opts.sources) {
834 if (pass.filename === null) {
835 const error = new CompilerError();
836 error.pushErrorDetail(
837 new CompilerErrorDetail({
838 reason: `Expected a filename but found none.`,
839 description:
840 "When the 'sources' config options is specified, the React compiler will only compile files with a name",
841 category: ErrorCategory.Config,
842 loc: null,
843 }),
844 );
845 handleError(error, pass, null);
846 return true;
847 }
848
849 if (!isFilePartOfSources(pass.opts.sources, pass.filename)) {
850 return true;
851 }
852 }
853
854 if (
855 hasMemoCacheFunctionImport(
856 program,
857 getReactCompilerRuntimeModule(pass.opts.target),
858 )
859 ) {
860 return true;
861 }
862 return false;
863 }
864
865 function getReactFunctionType(
866 fn: BabelFn,
867 pass: CompilerPass,
868 ): ReactFunctionType | null {
869 if (fn.node.body.type === 'BlockStatement') {
870 const optInDirectives = tryFindDirectiveEnablingMemoization(
871 fn.node.body.directives,
872 pass.opts,
873 );
874 if (optInDirectives.unwrapOr(null) != null) {
875 return getComponentOrHookLike(fn) ?? 'Other';
876 }
877 }
878
879 // Component and hook declarations are known components/hooks
880 let componentSyntaxType: ReactFunctionType | null = null;
881 if (fn.isFunctionDeclaration()) {
882 if (isComponentDeclaration(fn.node)) {
883 componentSyntaxType = 'Component';
884 } else if (isHookDeclaration(fn.node)) {
885 componentSyntaxType = 'Hook';
886 }
887 }
888
889 switch (pass.opts.compilationMode) {
890 case 'annotation': {
891 // opt-ins are checked above
892 return null;
893 }
894 case 'infer': {
895 // Check if this is a component or hook-like function
896 return componentSyntaxType ?? getComponentOrHookLike(fn);
897 }
898 case 'syntax': {
899 return componentSyntaxType;
900 }
901 case 'all': {
902 return getComponentOrHookLike(fn) ?? 'Other';
903 }
904 default: {
905 assertExhaustive(
906 pass.opts.compilationMode,
907 `Unexpected compilationMode \`${pass.opts.compilationMode}\``,
908 );
909 }
910 }
911 }
912
913 /**
914 * Returns true if the program contains an `import {c} from "<moduleName>"` declaration,
915 * regardless of the local name of the 'c' specifier and the presence of other specifiers
916 * in the same declaration.
917 */
918 function hasMemoCacheFunctionImport(
919 program: NodePath<t.Program>,
920 moduleName: string,
921 ): boolean {
922 let hasUseMemoCache = false;
923 program.traverse({
924 ImportSpecifier(path) {
925 const imported = path.get('imported');
926 let importedName: string | null = null;
927 if (imported.isIdentifier()) {
928 importedName = imported.node.name;
929 } else if (imported.isStringLiteral()) {
930 importedName = imported.node.value;
931 }
932 if (
933 importedName === 'c' &&
934 path.parentPath.isImportDeclaration() &&
935 path.parentPath.get('source').node.value === moduleName
936 ) {
937 hasUseMemoCache = true;
938 }
939 },
940 });
941 return hasUseMemoCache;
942 }
943
944 function isHookName(s: string): boolean {
945 return /^use[A-Z0-9]/.test(s);
946 }
947
948 /*
949 * We consider hooks to be a hook name identifier or a member expression
950 * containing a hook name.
951 */
952
953 function isHook(path: NodePath<t.Expression | t.PrivateName>): boolean {
954 if (path.isIdentifier()) {
955 return isHookName(path.node.name);
956 } else if (
957 path.isMemberExpression() &&
958 !path.node.computed &&
959 isHook(path.get('property'))
960 ) {
961 const obj = path.get('object').node;
962 const isPascalCaseNameSpace = /^[A-Z].*/;
963 return obj.type === 'Identifier' && isPascalCaseNameSpace.test(obj.name);
964 } else {
965 return false;
966 }
967 }
968
969 /*
970 * Checks if the node is a React component name. React component names must
971 * always start with an uppercase letter.
972 */
973
974 function isComponentName(path: NodePath<t.Expression>): boolean {
975 return path.isIdentifier() && /^[A-Z]/.test(path.node.name);
976 }
977
978 function isReactAPI(
979 path: NodePath<t.Expression | t.PrivateName | t.V8IntrinsicIdentifier>,
980 functionName: string,
981 ): boolean {
982 const node = path.node;
983 return (
984 (node.type === 'Identifier' && node.name === functionName) ||
985 (node.type === 'MemberExpression' &&
986 node.object.type === 'Identifier' &&
987 node.object.name === 'React' &&
988 node.property.type === 'Identifier' &&
989 node.property.name === functionName)
990 );
991 }
992
993 /*
994 * Checks if the node is a callback argument of forwardRef. This render function
995 * should follow the rules of hooks.
996 */
997
998 function isForwardRefCallback(path: NodePath<t.Expression>): boolean {
999 return !!(
1000 path.parentPath.isCallExpression() &&
1001 path.parentPath.get('callee').isExpression() &&
1002 isReactAPI(path.parentPath.get('callee'), 'forwardRef')
1003 );
1004 }
1005
1006 /*
1007 * Checks if the node is a callback argument of React.memo. This anonymous
1008 * functional component should follow the rules of hooks.
1009 */
1010
1011 function isMemoCallback(path: NodePath<t.Expression>): boolean {
1012 return (
1013 path.parentPath.isCallExpression() &&
1014 path.parentPath.get('callee').isExpression() &&
1015 isReactAPI(path.parentPath.get('callee'), 'memo')
1016 );
1017 }
1018
1019 function isValidPropsAnnotation(
1020 annot: t.TypeAnnotation | t.TSTypeAnnotation | t.Noop | null | undefined,
1021 ): boolean {
1022 if (annot == null) {
1023 return true;
1024 } else if (annot.type === 'TSTypeAnnotation') {
1025 switch (annot.typeAnnotation.type) {
1026 case 'TSArrayType':
1027 case 'TSBigIntKeyword':
1028 case 'TSBooleanKeyword':
1029 case 'TSConstructorType':
1030 case 'TSFunctionType':
1031 case 'TSLiteralType':
1032 case 'TSNeverKeyword':
1033 case 'TSNumberKeyword':
1034 case 'TSStringKeyword':
1035 case 'TSSymbolKeyword':
1036 case 'TSTupleType':
1037 return false;
1038 }
1039 return true;
1040 } else if (annot.type === 'TypeAnnotation') {
1041 switch (annot.typeAnnotation.type) {
1042 case 'ArrayTypeAnnotation':
1043 case 'BooleanLiteralTypeAnnotation':
1044 case 'BooleanTypeAnnotation':
1045 case 'EmptyTypeAnnotation':
1046 case 'FunctionTypeAnnotation':
1047 case 'NumberLiteralTypeAnnotation':
1048 case 'NumberTypeAnnotation':
1049 case 'StringLiteralTypeAnnotation':
1050 case 'StringTypeAnnotation':
1051 case 'SymbolTypeAnnotation':
1052 case 'ThisTypeAnnotation':
1053 case 'TupleTypeAnnotation':
1054 return false;
1055 }
1056 return true;
1057 } else if (annot.type === 'Noop') {
1058 return true;
1059 } else {
1060 assertExhaustive(annot, `Unexpected annotation node \`${annot}\``);
1061 }
1062 }
1063
1064 function isValidComponentParams(
1065 params: Array<NodePath<t.Identifier | t.Pattern | t.RestElement>>,
1066 ): boolean {
1067 if (params.length === 0) {
1068 return true;
1069 } else if (params.length > 0 && params.length <= 2) {
1070 if (!isValidPropsAnnotation(params[0].node.typeAnnotation)) {
1071 return false;
1072 }
1073
1074 if (params.length === 1) {
1075 return !params[0].isRestElement();
1076 } else if (params[1].isIdentifier()) {
1077 // check if second param might be a ref
1078 const {name} = params[1].node;
1079 return name.includes('ref') || name.includes('Ref');
1080 } else {
1081 /**
1082 * Otherwise, avoid helper functions that take more than one argument.
1083 * Helpers are _usually_ named with lowercase, but some code may
1084 * violate this rule
1085 */
1086 return false;
1087 }
1088 }
1089 return false;
1090 }
1091
1092 /*
1093 * Adapted from the ESLint rule at
1094 * https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/src/RulesOfHooks.js#L90-L103
1095 */
1096 function getComponentOrHookLike(
1097 node: NodePath<
1098 t.FunctionDeclaration | t.ArrowFunctionExpression | t.FunctionExpression
1099 >,
1100 ): ReactFunctionType | null {
1101 const functionName = getFunctionName(node);
1102 // Check if the name is component or hook like:
1103 if (functionName !== null && isComponentName(functionName)) {
1104 let isComponent =
1105 callsHooksOrCreatesJsx(node) &&
1106 isValidComponentParams(node.get('params')) &&
1107 !returnsNonNode(node);
1108 return isComponent ? 'Component' : null;
1109 } else if (functionName !== null && isHook(functionName)) {
1110 // Hooks have hook invocations or JSX, but can take any # of arguments
1111 return callsHooksOrCreatesJsx(node) ? 'Hook' : null;
1112 }
1113
1114 /*
1115 * Otherwise for function or arrow function expressions, check if they
1116 * appear as the argument to React.forwardRef() or React.memo():
1117 */
1118 if (node.isFunctionExpression() || node.isArrowFunctionExpression()) {
1119 if (isForwardRefCallback(node) || isMemoCallback(node)) {
1120 // As an added check we also look for hook invocations or JSX
1121 return callsHooksOrCreatesJsx(node) ? 'Component' : null;
1122 }
1123 }
1124 return null;
1125 }
1126
1127 function skipNestedFunctions(
1128 node: NodePath<
1129 t.FunctionDeclaration | t.ArrowFunctionExpression | t.FunctionExpression
1130 >,
1131 ) {
1132 return (
1133 fn: NodePath<
1134 t.FunctionDeclaration | t.ArrowFunctionExpression | t.FunctionExpression
1135 >,
1136 ): void => {
1137 if (fn.node !== node.node) {
1138 fn.skip();
1139 }
1140 };
1141 }
1142
1143 function callsHooksOrCreatesJsx(
1144 node: NodePath<
1145 t.FunctionDeclaration | t.ArrowFunctionExpression | t.FunctionExpression
1146 >,
1147 ): boolean {
1148 let invokesHooks = false;
1149 let createsJsx = false;
1150
1151 node.traverse({
1152 JSX() {
1153 createsJsx = true;
1154 },
1155 CallExpression(call) {
1156 const callee = call.get('callee');
1157 if (callee.isExpression() && isHook(callee)) {
1158 invokesHooks = true;
1159 }
1160 },
1161 ArrowFunctionExpression: skipNestedFunctions(node),
1162 FunctionExpression: skipNestedFunctions(node),
1163 FunctionDeclaration: skipNestedFunctions(node),
1164 });
1165
1166 return invokesHooks || createsJsx;
1167 }
1168
1169 function isNonNode(node?: t.Expression | null): boolean {
1170 if (!node) {
1171 return true;
1172 }
1173 switch (node.type) {
1174 case 'ObjectExpression':
1175 case 'ArrowFunctionExpression':
1176 case 'FunctionExpression':
1177 case 'BigIntLiteral':
1178 case 'ClassExpression':
1179 case 'NewExpression': // technically `new Array()` is legit, but unlikely
1180 return true;
1181 }
1182 return false;
1183 }
1184
1185 function returnsNonNode(
1186 node: NodePath<
1187 t.FunctionDeclaration | t.ArrowFunctionExpression | t.FunctionExpression
1188 >,
1189 ): boolean {
1190 let returnsNonNode = false;
1191 if (
1192 // node.traverse#ArrowFunctionExpression isn't called for the root node
1193 node.type === 'ArrowFunctionExpression' &&
1194 node.node.body.type !== 'BlockStatement'
1195 ) {
1196 returnsNonNode = isNonNode(node.node.body);
1197 }
1198
1199 node.traverse({
1200 ReturnStatement(ret) {
1201 returnsNonNode = isNonNode(ret.node.argument);
1202 },
1203 // Skip traversing all nested functions and their return statements
1204 ArrowFunctionExpression: skipNestedFunctions(node),
1205 FunctionExpression: skipNestedFunctions(node),
1206 FunctionDeclaration: skipNestedFunctions(node),
1207 ObjectMethod: node => node.skip(),
1208 });
1209
1210 return returnsNonNode;
1211 }
1212
1213 /*
1214 * Gets the static name of a function AST node. For function declarations it is
1215 * easy. For anonymous function expressions it is much harder. If you search for
1216 * `IsAnonymousFunctionDefinition()` in the ECMAScript spec you'll find places
1217 * where JS gives anonymous function expressions names. We roughly detect the
1218 * same AST nodes with some exceptions to better fit our use case.
1219 */
1220
1221 function getFunctionName(
1222 path: NodePath<
1223 t.FunctionDeclaration | t.ArrowFunctionExpression | t.FunctionExpression
1224 >,
1225 ): NodePath<t.Expression> | null {
1226 if (path.isFunctionDeclaration()) {
1227 const id = path.get('id');
1228 if (id.isIdentifier()) {
1229 return id;
1230 }
1231 return null;
1232 }
1233 let id: NodePath<t.LVal | t.Expression | t.PrivateName> | null = null;
1234 const parent = path.parentPath;
1235 if (parent.isVariableDeclarator() && parent.get('init').node === path.node) {
1236 // const useHook = () => {};
1237 id = parent.get('id');
1238 } else if (
1239 parent.isAssignmentExpression() &&
1240 parent.get('right').node === path.node &&
1241 parent.get('operator') === '='
1242 ) {
1243 // useHook = () => {};
1244 id = parent.get('left');
1245 } else if (
1246 parent.isProperty() &&
1247 parent.get('value').node === path.node &&
1248 !parent.get('computed') &&
1249 parent.get('key').isLVal()
1250 ) {
1251 /*
1252 * {useHook: () => {}}
1253 * {useHook() {}}
1254 */
1255 id = parent.get('key');
1256 } else if (
1257 parent.isAssignmentPattern() &&
1258 parent.get('right').node === path.node &&
1259 !parent.get('computed')
1260 ) {
1261 /*
1262 * const {useHook = () => {}} = {};
1263 * ({useHook = () => {}} = {});
1264 *
1265 * Kinda clowny, but we'd said we'd follow spec convention for
1266 * `IsAnonymousFunctionDefinition()` usage.
1267 */
1268 id = parent.get('left');
1269 }
1270 if (id !== null && (id.isIdentifier() || id.isMemberExpression())) {
1271 return id;
1272 } else {
1273 return null;
1274 }
1275 }
1276
1277 function getFunctionReferencedBeforeDeclarationAtTopLevel(
1278 program: NodePath<t.Program>,
1279 fns: Array<CompileResult>,
1280 ): Set<CompileResult> {
1281 const fnNames = new Map<string, {id: t.Identifier; fn: CompileResult}>(
1282 fns
1283 .map<[NodePath<t.Expression> | null, CompileResult]>(fn => [
1284 getFunctionName(fn.originalFn),
1285 fn,
1286 ])
1287 .filter(
1288 (entry): entry is [NodePath<t.Identifier>, CompileResult] =>
1289 !!entry[0] && entry[0].isIdentifier(),
1290 )
1291 .map(entry => [entry[0].node.name, {id: entry[0].node, fn: entry[1]}]),
1292 );
1293 const referencedBeforeDeclaration = new Set<CompileResult>();
1294
1295 program.traverse({
1296 TypeAnnotation(path) {
1297 path.skip();
1298 },
1299 TSTypeAnnotation(path) {
1300 path.skip();
1301 },
1302 TypeAlias(path) {
1303 path.skip();
1304 },
1305 TSTypeAliasDeclaration(path) {
1306 path.skip();
1307 },
1308 Identifier(id) {
1309 const fn = fnNames.get(id.node.name);
1310 // We're not tracking this identifier.
1311 if (!fn) {
1312 return;
1313 }
1314
1315 /*
1316 * We've reached the declaration, hoisting is no longer possible, stop
1317 * checking for this component name.
1318 */
1319 if (id.node === fn.id) {
1320 fnNames.delete(id.node.name);
1321 return;
1322 }
1323
1324 const scope = id.scope.getFunctionParent();
1325 /*
1326 * A null scope means there's no function scope, which means we're at the
1327 * top level scope.
1328 */
1329 if (scope === null && id.isReferencedIdentifier()) {
1330 referencedBeforeDeclaration.add(fn.fn);
1331 }
1332 },
1333 });
1334
1335 return referencedBeforeDeclaration;
1336 }
1337
1338 export function getReactCompilerRuntimeModule(
1339 target: CompilerReactTarget,
1340 ): string {
1341 if (target === '19') {
1342 return 'react/compiler-runtime'; // from react namespace
1343 } else if (target === '17' || target === '18') {
1344 return 'react-compiler-runtime'; // npm package
1345 } else {
1346 CompilerError.invariant(
1347 target != null &&
1348 target.kind === 'donotuse_meta_internal' &&
1349 typeof target.runtimeModule === 'string',
1350 {
1351 reason: 'Expected target to already be validated',
1352 loc: GeneratedSource,
1353 },
1354 );
1355 return target.runtimeModule;
1356 }
1357 }