diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Imports.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Imports.ts index 704f696b3b..d8f3ad0904 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Imports.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Imports.ts @@ -18,8 +18,13 @@ import { import {getOrInsertWith} from '../Utils/utils'; import {ExternalFunction, isHookName} from '../HIR/Environment'; import {Err, Ok, Result} from '../Utils/Result'; -import {CompilerReactTarget} from './Options'; -import {getReactCompilerRuntimeModule} from './Program'; +import {LoggerEvent, PluginOptions} from './Options'; +import { + BabelFn, + findDirectiveDisablingMemoization, + getReactCompilerRuntimeModule, +} from './Program'; +import {SuppressionRange} from './Suppression'; export function validateRestrictedImports( path: NodePath, @@ -52,32 +57,64 @@ export function validateRestrictedImports( } } +type ProgramContextOptions = { + program: NodePath; + suppressions: Array; + opts: PluginOptions; + filename: string | null; + code: string | null; +}; export class ProgramContext { - /* Program and environment context */ + /** + * Program and environment context + */ scope: BabelScope; + opts: PluginOptions; + filename: string | null; + code: string | null; reactRuntimeModule: string; - hookPattern: string | null; + suppressions: Array; + hasModuleScopeOptOut: boolean; + /* + * This is a hack to work around what seems to be a Babel bug. Babel doesn't + * consistently respect the `skip()` function to avoid revisiting a node within + * a pass, so we use this set to track nodes that we have compiled. + */ + alreadyCompiled: WeakSet | Set = new (WeakSet ?? Set)(); // known generated or referenced identifiers in the program knownReferencedNames: Set = new Set(); // generated imports imports: Map> = new Map(); - constructor( - program: NodePath, - reactRuntimeModule: CompilerReactTarget, - hookPattern: string | null, - ) { - this.hookPattern = hookPattern; + /** + * Metadata from compilation + */ + retryErrors: Array<{fn: BabelFn; error: CompilerError}> = []; + inferredEffectLocations: Set = new Set(); + + constructor({ + program, + suppressions, + opts, + filename, + code, + }: ProgramContextOptions) { this.scope = program.scope; - this.reactRuntimeModule = getReactCompilerRuntimeModule(reactRuntimeModule); + this.opts = opts; + this.filename = filename; + this.code = code; + this.reactRuntimeModule = getReactCompilerRuntimeModule(opts.target); + this.suppressions = suppressions; + this.hasModuleScopeOptOut = + findDirectiveDisablingMemoization(program.node.directives) != null; } isHookName(name: string): boolean { - if (this.hookPattern == null) { + if (this.opts.environment.hookPattern == null) { return isHookName(name); } else { - const match = new RegExp(this.hookPattern).exec(name); + const match = new RegExp(this.opts.environment.hookPattern).exec(name); return ( match != null && typeof match[1] === 'string' && isHookName(match[1]) ); @@ -179,6 +216,12 @@ export class ProgramContext { }); return Err(error); } + + logEvent(event: LoggerEvent): void { + if (this.opts.logger != null) { + this.opts.logger.logEvent(this.filename, event); + } + } } function getExistingImports( diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts index c732e16410..ebf9f2467d 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Options.ts @@ -37,6 +37,10 @@ const PanicThresholdOptionsSchema = z.enum([ ]); export type PanicThresholdOptions = z.infer; +const DynamicGatingOptionsSchema = z.object({ + source: z.string(), +}); +export type DynamicGatingOptions = z.infer; export type PluginOptions = { environment: EnvironmentConfig; @@ -65,6 +69,27 @@ export type PluginOptions = { */ gating: ExternalFunction | null; + /** + * If specified, this enables dynamic gating which matches `use memo if(...)` + * directives. + * + * Example usage: + * ```js + * // @dynamicGating:{"source":"myModule"} + * export function MyComponent() { + * 'use memo if(isEnabled)'; + * return
...
; + * } + * ``` + * This will emit: + * ```js + * import {isEnabled} from 'myModule'; + * export const MyComponent = isEnabled() + * ? + * : ; + */ + dynamicGating: DynamicGatingOptions | null; + panicThreshold: PanicThresholdOptions; /* @@ -244,6 +269,7 @@ export const defaultOptions: PluginOptions = { logger: null, gating: null, noEmit: false, + dynamicGating: null, eslintSuppressionRules: null, flowSuppressions: true, ignoreUseNoForget: false, @@ -292,6 +318,25 @@ export function parsePluginOptions(obj: unknown): PluginOptions { } break; } + case 'dynamicGating': { + if (value == null) { + parsedOptions[key] = null; + } else { + const result = DynamicGatingOptionsSchema.safeParse(value); + if (result.success) { + parsedOptions[key] = result.data; + } else { + CompilerError.throwInvalidConfig({ + reason: + 'Could not parse dynamic gating. Update React Compiler config to fix the error', + description: `${fromZodError(result.error)}`, + loc: null, + suggestions: null, + }); + } + } + break; + } default: { parsedOptions[key] = value; } diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts index 34347f10b8..9aca4a1469 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Program.ts @@ -12,7 +12,7 @@ import { CompilerErrorDetail, ErrorSeverity, } from '../CompilerError'; -import {EnvironmentConfig, ReactFunctionType} from '../HIR/Environment'; +import {ExternalFunction, ReactFunctionType} from '../HIR/Environment'; import {CodegenFunction} from '../ReactiveScopes'; import {isComponentDeclaration} from '../Utils/ComponentDeclaration'; import {isHookDeclaration} from '../Utils/HookDeclaration'; @@ -31,6 +31,7 @@ import { suppressionsToCompilerError, } from './Suppression'; import {GeneratedSource} from '../HIR'; +import {Err, Ok, Result} from '../Utils/Result'; export type CompilerPass = { opts: PluginOptions; @@ -40,22 +41,83 @@ export type CompilerPass = { }; export const OPT_IN_DIRECTIVES = new Set(['use forget', 'use memo']); export const OPT_OUT_DIRECTIVES = new Set(['use no forget', 'use no memo']); +const DYNAMIC_GATING_DIRECTIVE = new RegExp('^use memo if\\(([^\\)]*)\\)$'); -export function findDirectiveEnablingMemoization( +export function tryFindDirectiveEnablingMemoization( directives: Array, -): Array { - return directives.filter(directive => + opts: PluginOptions, +): Result { + const optIn = directives.find(directive => OPT_IN_DIRECTIVES.has(directive.value.value), ); + if (optIn != null) { + return Ok(optIn); + } + const dynamicGating = findDirectivesDynamicGating(directives, opts); + if (dynamicGating.isOk()) { + return Ok(dynamicGating.unwrap()?.directive ?? null); + } else { + return Err(dynamicGating.unwrapErr()); + } } export function findDirectiveDisablingMemoization( directives: Array, -): Array { - return directives.filter(directive => - OPT_OUT_DIRECTIVES.has(directive.value.value), +): t.Directive | null { + return ( + directives.find(directive => + OPT_OUT_DIRECTIVES.has(directive.value.value), + ) ?? null ); } +function findDirectivesDynamicGating( + directives: Array, + opts: PluginOptions, +): Result< + { + gating: ExternalFunction; + directive: t.Directive; + } | null, + CompilerError +> { + if (opts.dynamicGating === null) { + return Ok(null); + } + const errors = new CompilerError(); + const result: Array<{directive: t.Directive; match: string}> = []; + + for (const directive of directives) { + const maybeMatch = DYNAMIC_GATING_DIRECTIVE.exec(directive.value.value); + if (maybeMatch != null && maybeMatch[1] != null) { + if (t.isValidIdentifier(maybeMatch[1])) { + result.push({directive, match: maybeMatch[1]}); + } else { + errors.push({ + reason: `Dynamic gating directive is not a valid JavaScript identifier`, + description: `Found '${directive.value.value}'`, + severity: ErrorSeverity.InvalidReact, + loc: directive.loc ?? null, + suggestions: null, + }); + } + } + } + if (errors.hasErrors()) { + return Err(errors); + } else { + return Ok( + result.length > 0 + ? { + gating: { + source: opts.dynamicGating.source, + importSpecifierName: result[0].match, + }, + directive: result[0].directive, + } + : null, + ); + } +} function isCriticalError(err: unknown): boolean { return !(err instanceof CompilerError) || err.isCritical(); @@ -88,13 +150,16 @@ export type CompileResult = { function logError( err: unknown, - pass: CompilerPass, + context: { + opts: PluginOptions; + filename: string | null; + }, fnLoc: t.SourceLocation | null, ): void { - if (pass.opts.logger) { + if (context.opts.logger) { if (err instanceof CompilerError) { for (const detail of err.details) { - pass.opts.logger.logEvent(pass.filename, { + context.opts.logger.logEvent(context.filename, { kind: 'CompileError', fnLoc, detail: detail.options, @@ -108,7 +173,7 @@ function logError( stringifiedError = err?.toString() ?? '[ null ]'; } - pass.opts.logger.logEvent(pass.filename, { + context.opts.logger.logEvent(context.filename, { kind: 'PipelineError', fnLoc, data: stringifiedError, @@ -118,13 +183,17 @@ function logError( } function handleError( err: unknown, - pass: CompilerPass, + context: { + opts: PluginOptions; + filename: string | null; + }, fnLoc: t.SourceLocation | null, ): void { - logError(err, pass, fnLoc); + logError(err, context, fnLoc); if ( - pass.opts.panicThreshold === 'all_errors' || - (pass.opts.panicThreshold === 'critical_errors' && isCriticalError(err)) || + context.opts.panicThreshold === 'all_errors' || + (context.opts.panicThreshold === 'critical_errors' && + isCriticalError(err)) || isConfigError(err) // Always throws regardless of panic threshold ) { throw err; @@ -187,7 +256,6 @@ export function createNewFunctionNode( } } // Avoid visiting the new transformed version - ALREADY_COMPILED.add(transformedFn); return transformedFn; } @@ -239,13 +307,6 @@ function insertNewOutlinedFunctionNode( } } -/* - * This is a hack to work around what seems to be a Babel bug. Babel doesn't - * consistently respect the `skip()` function to avoid revisiting a node within - * a pass, so we use this set to track nodes that we have compiled. - */ -const ALREADY_COMPILED: WeakSet | Set = new (WeakSet ?? Set)(); - const DEFAULT_ESLINT_SUPPRESSIONS = [ 'react-hooks/exhaustive-deps', 'react-hooks/rules-of-hooks', @@ -268,41 +329,43 @@ function isFilePartOfSources( return false; } -export type CompileProgramResult = { +export type CompileProgramMetadata = { retryErrors: Array<{fn: BabelFn; error: CompilerError}>; inferredEffectLocations: Set; }; /** - * `compileProgram` is directly invoked by the react-compiler babel plugin, so - * exceptions thrown by this function will fail the babel build. - * - call `handleError` if your error is recoverable. - * Unless the error is a warning / info diagnostic, compilation of a function - * / entire file should also be skipped. - * - throw an exception if the error is fatal / not recoverable. - * Examples of this are invalid compiler configs or failure to codegen outlined - * functions *after* already emitting optimized components / hooks that invoke - * the outlined functions. + * Main entrypoint for React Compiler. + * + * @param program The Babel program node to compile + * @param pass Compiler configuration and context + * @returns Compilation results or null if compilation was skipped */ export function compileProgram( program: NodePath, pass: CompilerPass, -): CompileProgramResult | null { +): CompileProgramMetadata | null { + /** + * This is directly invoked by the react-compiler babel plugin, so exceptions + * thrown by this function will fail the babel build. + * - call `handleError` if your error is recoverable. + * Unless the error is a warning / info diagnostic, compilation of a function + * / entire file should also be skipped. + * - throw an exception if the error is fatal / not recoverable. + * Examples of this are invalid compiler configs or failure to codegen outlined + * functions *after* already emitting optimized components / hooks that invoke + * the outlined functions. + */ if (shouldSkipCompilation(program, pass)) { return null; } - - const environment = pass.opts.environment; - const restrictedImportsErr = validateRestrictedImports(program, environment); + const restrictedImportsErr = validateRestrictedImports( + program, + pass.opts.environment, + ); if (restrictedImportsErr) { handleError(restrictedImportsErr, pass, null); return null; } - - const programContext = new ProgramContext( - program, - pass.opts.target, - environment.hookPattern, - ); /* * Record lint errors and critical errors as depending on Forget's config, * we may still need to run Forget's analysis on every function (even if we @@ -313,16 +376,100 @@ export function compileProgram( pass.opts.eslintSuppressionRules ?? DEFAULT_ESLINT_SUPPRESSIONS, pass.opts.flowSuppressions, ); - const queue: Array<{ - kind: 'original' | 'outlined'; - fn: BabelFn; - fnType: ReactFunctionType; - }> = []; + + const programContext = new ProgramContext({ + program: program, + opts: pass.opts, + filename: pass.filename, + code: pass.code, + suppressions, + }); + + const queue: Array = findFunctionsToCompile( + program, + pass, + programContext, + ); const compiledFns: Array = []; + while (queue.length !== 0) { + const current = queue.shift()!; + const compiled = processFn(current.fn, current.fnType, programContext); + + if (compiled != null) { + for (const outlined of compiled.outlined) { + CompilerError.invariant(outlined.fn.outlined.length === 0, { + reason: 'Unexpected nested outlined functions', + loc: outlined.fn.loc, + }); + const fn = insertNewOutlinedFunctionNode( + program, + current.fn, + outlined.fn, + ); + fn.skip(); + programContext.alreadyCompiled.add(fn.node); + if (outlined.type !== null) { + queue.push({ + kind: 'outlined', + fn, + fnType: outlined.type, + }); + } + } + compiledFns.push({ + kind: current.kind, + originalFn: current.fn, + compiledFn: compiled, + }); + } + } + + // Avoid modifying the program if we find a program level opt-out + if (programContext.hasModuleScopeOptOut) { + if (compiledFns.length > 0) { + const error = new CompilerError(); + error.pushErrorDetail( + new CompilerErrorDetail({ + reason: + 'Unexpected compiled functions when module scope opt-out is present', + severity: ErrorSeverity.Invariant, + loc: null, + }), + ); + handleError(error, programContext, null); + } + return null; + } + + // Insert React Compiler generated functions into the Babel AST + applyCompiledFunctions(program, compiledFns, pass, programContext); + + return { + retryErrors: programContext.retryErrors, + inferredEffectLocations: programContext.inferredEffectLocations, + }; +} + +type CompileSource = { + kind: 'original' | 'outlined'; + fn: BabelFn; + fnType: ReactFunctionType; +}; +/** + * Find all React components and hooks that need to be compiled + * + * @returns An array of React functions from @param program to transform + */ +function findFunctionsToCompile( + program: NodePath, + pass: CompilerPass, + programContext: ProgramContext, +): Array { + const queue: Array = []; const traverseFunction = (fn: BabelFn, pass: CompilerPass): void => { - const fnType = getReactFunctionType(fn, pass, environment); - if (fnType === null || ALREADY_COMPILED.has(fn.node)) { + const fnType = getReactFunctionType(fn, pass); + if (fnType === null || programContext.alreadyCompiled.has(fn.node)) { return; } @@ -331,7 +478,7 @@ export function compileProgram( * traversal will loop infinitely. * Ensure we avoid visiting the original function again. */ - ALREADY_COMPILED.add(fn.node); + programContext.alreadyCompiled.add(fn.node); fn.skip(); queue.push({kind: 'original', fn, fnType}); @@ -346,7 +493,6 @@ export function compileProgram( * can reference `this` which is unsafe for compilation */ node.skip(); - return; }, ClassExpression(node: NodePath) { @@ -355,7 +501,6 @@ export function compileProgram( * can reference `this` which is unsafe for compilation */ node.skip(); - return; }, FunctionDeclaration: traverseFunction, @@ -370,223 +515,241 @@ export function compileProgram( filename: pass.filename ?? null, }, ); - const retryErrors: Array<{fn: BabelFn; error: CompilerError}> = []; - const inferredEffectLocations = new Set(); - const processFn = ( - fn: BabelFn, - fnType: ReactFunctionType, - ): null | CodegenFunction => { - let optInDirectives: Array = []; - let optOutDirectives: Array = []; - if (fn.node.body.type === 'BlockStatement') { - optInDirectives = findDirectiveEnablingMemoization( - fn.node.body.directives, - ); - optOutDirectives = findDirectiveDisablingMemoization( - fn.node.body.directives, - ); - } + return queue; +} - /** - * Note that Babel does not attach comment nodes to nodes; they are dangling off of the - * Program node itself. We need to figure out whether an eslint suppression range - * applies to this function first. - */ - const suppressionsInFunction = filterSuppressionsThatAffectFunction( - suppressions, - fn, - ); - let compileResult: - | {kind: 'compile'; compiledFn: CodegenFunction} - | {kind: 'error'; error: unknown}; - if (suppressionsInFunction.length > 0) { - compileResult = { - kind: 'error', - error: suppressionsToCompilerError(suppressionsInFunction), - }; - } else { - try { - compileResult = { - kind: 'compile', - compiledFn: compileFn( - fn, - environment, - fnType, - 'all_features', - programContext, - pass.opts.logger, - pass.filename, - pass.code, - ), - }; - } catch (err) { - compileResult = {kind: 'error', error: err}; - } - } - - if (compileResult.kind === 'error') { - /** - * If an opt out directive is present, log only instead of throwing and don't mark as - * containing a critical error. - */ - if (optOutDirectives.length > 0) { - logError(compileResult.error, pass, fn.node.loc ?? null); - } else { - handleError(compileResult.error, pass, fn.node.loc ?? null); - } - // If non-memoization features are enabled, retry regardless of error kind - if ( - !(environment.enableFire || environment.inferEffectDependencies != null) - ) { - return null; - } - try { - compileResult = { - kind: 'compile', - compiledFn: compileFn( - fn, - environment, - fnType, - 'no_inferred_memo', - programContext, - pass.opts.logger, - pass.filename, - pass.code, - ), - }; - if ( - !compileResult.compiledFn.hasFireRewrite && - !compileResult.compiledFn.hasInferredEffect - ) { - return null; - } - } catch (err) { - // TODO: we might want to log error here, but this will also result in duplicate logging - if (err instanceof CompilerError) { - retryErrors.push({fn, error: err}); - } - return null; - } - } - - /** - * Otherwise if 'use no forget/memo' is present, we still run the code through the compiler - * for validation but we don't mutate the babel AST. This allows us to flag if there is an - * unused 'use no forget/memo' directive. - */ - if (pass.opts.ignoreUseNoForget === false && optOutDirectives.length > 0) { - for (const directive of optOutDirectives) { - pass.opts.logger?.logEvent(pass.filename, { - kind: 'CompileSkip', - fnLoc: fn.node.body.loc ?? null, - reason: `Skipped due to '${directive.value.value}' directive.`, - loc: directive.loc ?? null, - }); - } - return null; - } - - pass.opts.logger?.logEvent(pass.filename, { - kind: 'CompileSuccess', - fnLoc: fn.node.loc ?? null, - fnName: compileResult.compiledFn.id?.name ?? null, - memoSlots: compileResult.compiledFn.memoSlotsUsed, - memoBlocks: compileResult.compiledFn.memoBlocks, - memoValues: compileResult.compiledFn.memoValues, - prunedMemoBlocks: compileResult.compiledFn.prunedMemoBlocks, - prunedMemoValues: compileResult.compiledFn.prunedMemoValues, - }); - - /** - * Always compile functions with opt in directives. - */ - if (optInDirectives.length > 0) { - return compileResult.compiledFn; - } else if (pass.opts.compilationMode === 'annotation') { - /** - * No opt-in directive in annotation mode, so don't insert the compiled function. - */ - return null; - } - - if (!pass.opts.noEmit) { - return compileResult.compiledFn; - } - /** - * inferEffectDependencies + noEmit is currently only used for linting. In - * this mode, add source locations for where the compiler *can* infer effect - * dependencies. - */ - for (const loc of compileResult.compiledFn.inferredEffectLocations) { - if (loc !== GeneratedSource) inferredEffectLocations.add(loc); - } - return null; +/** + * Try to compile a source function, taking into account all local suppressions, + * opt-ins, and opt-outs. + * + * Errors encountered during compilation are either logged (if recoverable) or + * thrown (if non-recoverable). + * + * @returns the compiled function or null if the function was skipped (due to + * config settings and/or outputs) + */ +function processFn( + fn: BabelFn, + fnType: ReactFunctionType, + programContext: ProgramContext, +): null | CodegenFunction { + let directives: { + optIn: t.Directive | null; + optOut: t.Directive | null; }; + if (fn.node.body.type !== 'BlockStatement') { + directives = { + optIn: null, + optOut: null, + }; + } else { + const optIn = tryFindDirectiveEnablingMemoization( + fn.node.body.directives, + programContext.opts, + ); + if (optIn.isErr()) { + /** + * If parsing opt-in directive fails, it's most likely that React Compiler + * was not tested or rolled out on this function. In that case, we should + * fall back to the safest option which is to not optimize the function. + */ + handleError(optIn.unwrapErr(), programContext, fn.node.loc ?? null); + return null; + } + directives = { + optIn: optIn.unwrapOr(null), + optOut: findDirectiveDisablingMemoization(fn.node.body.directives), + }; + } - while (queue.length !== 0) { - const current = queue.shift()!; - const compiled = processFn(current.fn, current.fnType); - if (compiled === null) { - continue; + const compileResult = tryCompileFunction(fn, fnType, programContext); + + if (compileResult.kind === 'error') { + if (directives.optOut != null) { + logError(compileResult.error, programContext, fn.node.loc ?? null); + } else { + handleError(compileResult.error, programContext, fn.node.loc ?? null); } - for (const outlined of compiled.outlined) { - CompilerError.invariant(outlined.fn.outlined.length === 0, { - reason: 'Unexpected nested outlined functions', - loc: outlined.fn.loc, - }); - const fn = insertNewOutlinedFunctionNode( - program, - current.fn, - outlined.fn, - ); - fn.skip(); - ALREADY_COMPILED.add(fn.node); - if (outlined.type !== null) { - queue.push({ - kind: 'outlined', - fn, - fnType: outlined.type, - }); - } - } - compiledFns.push({ - kind: current.kind, - compiledFn: compiled, - originalFn: current.fn, - }); + return retryCompileFunction(fn, fnType, programContext); } /** - * Do not modify source if there is a module scope level opt out directive. + * Otherwise if 'use no forget/memo' is present, we still run the code through the compiler + * for validation but we don't mutate the babel AST. This allows us to flag if there is an + * unused 'use no forget/memo' directive. */ - const moduleScopeOptOutDirectives = findDirectiveDisablingMemoization( - program.node.directives, - ); - if (moduleScopeOptOutDirectives.length > 0) { + if ( + programContext.opts.ignoreUseNoForget === false && + directives.optOut != null + ) { + programContext.logEvent({ + kind: 'CompileSkip', + fnLoc: fn.node.body.loc ?? null, + reason: `Skipped due to '${directives.optOut.value}' directive.`, + loc: directives.optOut.loc ?? null, + }); return null; } - /* - * Only insert Forget-ified functions if we have not encountered a critical - * error elsewhere in the file, regardless of bailout mode. + const compiledFn = compileResult.compiledFn; + programContext.logEvent({ + kind: 'CompileSuccess', + fnLoc: fn.node.loc ?? null, + fnName: compiledFn.id?.name ?? null, + memoSlots: compiledFn.memoSlotsUsed, + memoBlocks: compiledFn.memoBlocks, + memoValues: compiledFn.memoValues, + prunedMemoBlocks: compiledFn.prunedMemoBlocks, + prunedMemoValues: compiledFn.prunedMemoValues, + }); + + /** + * inferEffectDependencies + noEmit is currently only used for linting. In + * this mode, add source locations for where the compiler *can* infer effect + * dependencies. */ - const referencedBeforeDeclared = - pass.opts.gating != null - ? getFunctionReferencedBeforeDeclarationAtTopLevel(program, compiledFns) - : null; + for (const loc of compileResult.compiledFn.inferredEffectLocations) { + if (loc !== GeneratedSource) { + programContext.inferredEffectLocations.add(loc); + } + } + + /** + * Always compile functions with opt in directives. + */ + if (programContext.hasModuleScopeOptOut || programContext.opts.noEmit) { + return null; + } else if (directives.optIn != null) { + return compiledFn; + } else if (programContext.opts.compilationMode === 'annotation') { + /** + * If no opt-in directive is found and the compiler is configured in + * annotation mode, don't insert the compiled function. + */ + return null; + } else { + return compiledFn; + } +} + +function tryCompileFunction( + fn: BabelFn, + fnType: ReactFunctionType, + programContext: ProgramContext, +): + | {kind: 'compile'; compiledFn: CodegenFunction} + | {kind: 'error'; error: unknown} { + /** + * Note that Babel does not attach comment nodes to nodes; they are dangling off of the + * Program node itself. We need to figure out whether an eslint suppression range + * applies to this function first. + */ + const suppressionsInFunction = filterSuppressionsThatAffectFunction( + programContext.suppressions, + fn, + ); + if (suppressionsInFunction.length > 0) { + return { + kind: 'error', + error: suppressionsToCompilerError(suppressionsInFunction), + }; + } + + try { + return { + kind: 'compile', + compiledFn: compileFn( + fn, + programContext.opts.environment, + fnType, + 'all_features', + programContext, + programContext.opts.logger, + programContext.filename, + programContext.code, + ), + }; + } catch (err) { + return {kind: 'error', error: err}; + } +} + +/** + * If non-memo feature flags are enabled, retry compilation with a more minimal + * feature set. + * + * @returns a CodegenFunction if retry was successful + */ +function retryCompileFunction( + fn: BabelFn, + fnType: ReactFunctionType, + programContext: ProgramContext, +): CodegenFunction | null { + const environment = programContext.opts.environment; + if ( + !(environment.enableFire || environment.inferEffectDependencies != null) + ) { + return null; + } + try { + const retryResult = compileFn( + fn, + environment, + fnType, + 'no_inferred_memo', + programContext, + programContext.opts.logger, + programContext.filename, + programContext.code, + ); + + if (!retryResult.hasFireRewrite && !retryResult.hasInferredEffect) { + return null; + } + return retryResult; + } catch (err) { + // TODO: we might want to log error here, but this will also result in duplicate logging + if (err instanceof CompilerError) { + programContext.retryErrors.push({fn, error: err}); + } + return null; + } +} + +/** + * Applies React Compiler generated functions to the babel AST by replacing + * existing functions in place or inserting new declarations. + */ +function applyCompiledFunctions( + program: NodePath, + compiledFns: Array, + pass: CompilerPass, + programContext: ProgramContext, +): void { + let referencedBeforeDeclared = null; for (const result of compiledFns) { const {kind, originalFn, compiledFn} = result; const transformedFn = createNewFunctionNode(originalFn, compiledFn); + programContext.alreadyCompiled.add(transformedFn); - if (referencedBeforeDeclared != null && kind === 'original') { - CompilerError.invariant(pass.opts.gating != null, { - reason: "Expected 'gating' import to be present", - loc: null, - }); + let dynamicGating: ExternalFunction | null = null; + if (originalFn.node.body.type === 'BlockStatement') { + const result = findDirectivesDynamicGating( + originalFn.node.body.directives, + pass.opts, + ); + if (result.isOk()) { + dynamicGating = result.unwrap()?.gating ?? null; + } + } + const functionGating = dynamicGating ?? pass.opts.gating; + if (kind === 'original' && functionGating != null) { + referencedBeforeDeclared ??= + getFunctionReferencedBeforeDeclarationAtTopLevel(program, compiledFns); insertGatedFunctionDeclaration( originalFn, transformedFn, programContext, - pass.opts.gating, + functionGating, referencedBeforeDeclared.has(result), ); } else { @@ -598,7 +761,6 @@ export function compileProgram( if (compiledFns.length > 0) { addImportsToProgram(program, programContext); } - return {retryErrors, inferredEffectLocations}; } function shouldSkipCompilation( @@ -640,15 +802,16 @@ function shouldSkipCompilation( function getReactFunctionType( fn: BabelFn, pass: CompilerPass, - /** - * TODO(mofeiZ): remove once we validate PluginOptions with Zod - */ - environment: EnvironmentConfig, ): ReactFunctionType | null { - const hookPattern = environment.hookPattern; + const hookPattern = pass.opts.environment.hookPattern; if (fn.node.body.type === 'BlockStatement') { - if (findDirectiveEnablingMemoization(fn.node.body.directives).length > 0) + const optInDirectives = tryFindDirectiveEnablingMemoization( + fn.node.body.directives, + pass.opts, + ); + if (optInDirectives.isOk() && optInDirectives.unwrap() !== null) { return getComponentOrHookLike(fn, hookPattern) ?? 'Other'; + } } // Component and hook declarations are known components/hooks diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/ValidateNoUntransformedReferences.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/ValidateNoUntransformedReferences.ts index 5f6c6986e0..e288c227ad 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/ValidateNoUntransformedReferences.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/ValidateNoUntransformedReferences.ts @@ -18,7 +18,7 @@ import { import {getOrInsertWith} from '../Utils/utils'; import {Environment} from '../HIR'; import {DEFAULT_EXPORT} from '../HIR/Environment'; -import {CompileProgramResult} from './Program'; +import {CompileProgramMetadata} from './Program'; function throwInvalidReact( options: Omit, @@ -109,7 +109,7 @@ export default function validateNoUntransformedReferences( filename: string | null, logger: Logger | null, env: EnvironmentConfig, - compileResult: CompileProgramResult | null, + compileResult: CompileProgramMetadata | null, ): void { const moduleLoadChecks = new Map< string, @@ -236,7 +236,7 @@ function transformProgram( moduleLoadChecks: Map>, filename: string | null, logger: Logger | null, - compileResult: CompileProgramResult | null, + compileResult: CompileProgramMetadata | null, ): void { const traversalState: TraversalState = { shouldInvalidateScopes: true, diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-bailout-nopanic.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-bailout-nopanic.expect.md new file mode 100644 index 0000000000..dc3cc2b98d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-bailout-nopanic.expect.md @@ -0,0 +1,66 @@ + +## Input + +```javascript +// @dynamicGating:{"source":"shared-runtime"} @validatePreserveExistingMemoizationGuarantees @panicThreshold:"none" @loggerTestOnly + +import {useMemo} from 'react'; +import {identity} from 'shared-runtime'; + +function Foo({value}) { + 'use memo if(getTrue)'; + + const initialValue = useMemo(() => identity(value), []); + return ( + <> +
initial value {initialValue}
+
current value {value}
+ + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{value: 1}], + sequentialRenders: [{value: 1}, {value: 2}], +}; + +``` + +## Code + +```javascript +// @dynamicGating:{"source":"shared-runtime"} @validatePreserveExistingMemoizationGuarantees @panicThreshold:"none" @loggerTestOnly + +import { useMemo } from "react"; +import { identity } from "shared-runtime"; + +function Foo({ value }) { + "use memo if(getTrue)"; + + const initialValue = useMemo(() => identity(value), []); + return ( + <> +
initial value {initialValue}
+
current value {value}
+ + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{ value: 1 }], + sequentialRenders: [{ value: 1 }, { value: 2 }], +}; + +``` + +## Logs + +``` +{"kind":"CompileError","fnLoc":{"start":{"line":6,"column":0,"index":206},"end":{"line":16,"column":1,"index":433},"filename":"dynamic-gating-bailout-nopanic.ts"},"detail":{"reason":"React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected","description":"The inferred dependency was `value`, but the source dependencies were []. Inferred dependency not present in source","severity":"CannotPreserveMemoization","suggestions":null,"loc":{"start":{"line":9,"column":31,"index":288},"end":{"line":9,"column":52,"index":309},"filename":"dynamic-gating-bailout-nopanic.ts"}}} +``` + +### Eval output +(kind: ok)
initial value 1
current value 1
+
initial value 1
current value 2
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-bailout-nopanic.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-bailout-nopanic.js new file mode 100644 index 0000000000..ceddbefdd1 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-bailout-nopanic.js @@ -0,0 +1,22 @@ +// @dynamicGating:{"source":"shared-runtime"} @validatePreserveExistingMemoizationGuarantees @panicThreshold:"none" @loggerTestOnly + +import {useMemo} from 'react'; +import {identity} from 'shared-runtime'; + +function Foo({value}) { + 'use memo if(getTrue)'; + + const initialValue = useMemo(() => identity(value), []); + return ( + <> +
initial value {initialValue}
+
current value {value}
+ + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{value: 1}], + sequentialRenders: [{value: 1}, {value: 2}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-disabled.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-disabled.expect.md new file mode 100644 index 0000000000..7d95b54317 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-disabled.expect.md @@ -0,0 +1,50 @@ + +## Input + +```javascript +// @dynamicGating:{"source":"shared-runtime"} + +function Foo() { + 'use memo if(getFalse)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { getFalse } from "shared-runtime"; // @dynamicGating:{"source":"shared-runtime"} +const Foo = getFalse() + ? function Foo() { + "use memo if(getFalse)"; + const $ = _c(1); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 =
hello world
; + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; + } + : function Foo() { + "use memo if(getFalse)"; + return
hello world
; + }; + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +### Eval output +(kind: ok)
hello world
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-disabled.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-disabled.js new file mode 100644 index 0000000000..be29f10568 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-disabled.js @@ -0,0 +1,11 @@ +// @dynamicGating:{"source":"shared-runtime"} + +function Foo() { + 'use memo if(getFalse)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-enabled.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-enabled.expect.md new file mode 100644 index 0000000000..272c5a5714 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-enabled.expect.md @@ -0,0 +1,50 @@ + +## Input + +```javascript +// @dynamicGating:{"source":"shared-runtime"} + +function Foo() { + 'use memo if(getTrue)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { getTrue } from "shared-runtime"; // @dynamicGating:{"source":"shared-runtime"} +const Foo = getTrue() + ? function Foo() { + "use memo if(getTrue)"; + const $ = _c(1); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t0 =
hello world
; + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; + } + : function Foo() { + "use memo if(getTrue)"; + return
hello world
; + }; + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +### Eval output +(kind: ok)
hello world
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-enabled.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-enabled.js new file mode 100644 index 0000000000..9280e25d11 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-enabled.js @@ -0,0 +1,11 @@ +// @dynamicGating:{"source":"shared-runtime"} + +function Foo() { + 'use memo if(getTrue)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-invalid-identifier-nopanic.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-invalid-identifier-nopanic.expect.md new file mode 100644 index 0000000000..c8c91910b0 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-invalid-identifier-nopanic.expect.md @@ -0,0 +1,37 @@ + +## Input + +```javascript +// @dynamicGating:{"source":"shared-runtime"} @panicThreshold:"none" + +function Foo() { + 'use memo if(true)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +## Code + +```javascript +// @dynamicGating:{"source":"shared-runtime"} @panicThreshold:"none" + +function Foo() { + "use memo if(true)"; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +### Eval output +(kind: ok)
hello world
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-invalid-identifier-nopanic.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-invalid-identifier-nopanic.js new file mode 100644 index 0000000000..4d0d9c3bb8 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/dynamic-gating-invalid-identifier-nopanic.js @@ -0,0 +1,11 @@ +// @dynamicGating:{"source":"shared-runtime"} @panicThreshold:"none" + +function Foo() { + 'use memo if(true)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier-nopanic-required-feature.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier-nopanic-required-feature.expect.md new file mode 100644 index 0000000000..7f9f608383 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier-nopanic-required-feature.expect.md @@ -0,0 +1,35 @@ + +## Input + +```javascript +// @dynamicGating:{"source":"shared-runtime"} @panicThreshold:"none" @inferEffectDependencies +import {useEffect} from 'react'; +import {print} from 'shared-runtime'; + +function ReactiveVariable({propVal}) { + 'use memo if(invalid identifier)'; + const arr = [propVal]; + useEffect(() => print(arr)); +} + +export const FIXTURE_ENTRYPOINT = { + fn: ReactiveVariable, + params: [{}], +}; + +``` + + +## Error + +``` + 6 | 'use memo if(invalid identifier)'; + 7 | const arr = [propVal]; +> 8 | useEffect(() => print(arr)); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ InvalidReact: [InferEffectDependencies] React Compiler is unable to infer dependencies of this effect. This will break your build! To resolve, either pass your own dependency array or fix reported compiler bailout diagnostics. (8:8) + 9 | } + 10 | + 11 | export const FIXTURE_ENTRYPOINT = { +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier-nopanic-required-feature.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier-nopanic-required-feature.js new file mode 100644 index 0000000000..7d5b74acc7 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier-nopanic-required-feature.js @@ -0,0 +1,14 @@ +// @dynamicGating:{"source":"shared-runtime"} @panicThreshold:"none" @inferEffectDependencies +import {useEffect} from 'react'; +import {print} from 'shared-runtime'; + +function ReactiveVariable({propVal}) { + 'use memo if(invalid identifier)'; + const arr = [propVal]; + useEffect(() => print(arr)); +} + +export const FIXTURE_ENTRYPOINT = { + fn: ReactiveVariable, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier.expect.md new file mode 100644 index 0000000000..c824afd680 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier.expect.md @@ -0,0 +1,32 @@ + +## Input + +```javascript +// @dynamicGating:{"source":"shared-runtime"} + +function Foo() { + 'use memo if(true)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + + +## Error + +``` + 2 | + 3 | function Foo() { +> 4 | 'use memo if(true)'; + | ^^^^^^^^^^^^^^^^^^^^ InvalidReact: Dynamic gating directive is not a valid JavaScript identifier. Found 'use memo if(true)' (4:4) + 5 | return
hello world
; + 6 | } + 7 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier.js new file mode 100644 index 0000000000..c400554497 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/gating/error.dynamic-gating-invalid-identifier.js @@ -0,0 +1,11 @@ +// @dynamicGating:{"source":"shared-runtime"} + +function Foo() { + 'use memo if(true)'; + return
hello world
; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-bailout-nopanic-shouldnt-outline.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-bailout-nopanic-shouldnt-outline.expect.md index f24d949205..cfbaa34568 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-bailout-nopanic-shouldnt-outline.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-bailout-nopanic-shouldnt-outline.expect.md @@ -20,9 +20,6 @@ function Foo() { function Foo() { return ; } -function _temp() { - return alert("hello!"); -} ``` diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/use-memo-noemit.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/use-memo-noemit.expect.md index dfc831555f..c47501945b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/use-memo-noemit.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/use-memo-noemit.expect.md @@ -19,22 +19,11 @@ export const FIXTURE_ENTRYPOINT = { ## Code ```javascript -import { c as _c } from "react/compiler-runtime"; // @noEmit +// @noEmit function Foo() { "use memo"; - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = ; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; -} -function _temp() { - return alert("hello!"); + return ; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/index.ts b/compiler/packages/babel-plugin-react-compiler/src/index.ts index 086e010fea..cbae672e50 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/index.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/index.ts @@ -20,7 +20,7 @@ export { OPT_OUT_DIRECTIVES, OPT_IN_DIRECTIVES, ProgramContext, - findDirectiveEnablingMemoization, + tryFindDirectiveEnablingMemoization as findDirectiveEnablingMemoization, findDirectiveDisablingMemoization, type CompilerPipelineValue, type Logger, diff --git a/compiler/packages/snap/src/sprout/shared-runtime.ts b/compiler/packages/snap/src/sprout/shared-runtime.ts index 1b8648f4ff..569d31cbd4 100644 --- a/compiler/packages/snap/src/sprout/shared-runtime.ts +++ b/compiler/packages/snap/src/sprout/shared-runtime.ts @@ -128,6 +128,14 @@ export function getNull(): null { return null; } +export function getTrue(): true { + return true; +} + +export function getFalse(): false { + return false; +} + export function calculateExpensiveNumber(x: number): number { return x; }