/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ import * as t from '@babel/types'; import {codeFrameColumns} from '@babel/code-frame'; import {type SourceLocation} from './HIR'; import {Err, Ok, Result} from './Utils/Result'; import {assertExhaustive} from './Utils/utils'; import invariant from 'invariant'; export enum ErrorSeverity { /** * Invalid JS syntax, or valid syntax that is semantically invalid which may indicate some * misunderstanding on the user’s part. */ InvalidJS = 'InvalidJS', /** * JS syntax that is not supported and which we do not plan to support. Developers should * rewrite to use supported forms. */ UnsupportedJS = 'UnsupportedJS', /** * Code that breaks the rules of React. */ InvalidReact = 'InvalidReact', /** * Incorrect configuration of the compiler. */ InvalidConfig = 'InvalidConfig', /** * Code that can reasonably occur and that doesn't break any rules, but is unsafe to preserve * memoization. */ CannotPreserveMemoization = 'CannotPreserveMemoization', /** * An API that is known to be incompatible with the compiler. Generally as a result of * the library using "interior mutability", ie having a value whose referential identity * stays the same but which provides access to values that can change. For example a * function that doesn't change but returns different results, or an object that doesn't * change identity but whose properties change. */ IncompatibleLibrary = 'IncompatibleLibrary', /** * Unhandled syntax that we don't support yet. */ Todo = 'Todo', /** * An unexpected internal error in the compiler that indicates critical issues that can panic * the compiler. */ Invariant = 'Invariant', } export type CompilerDiagnosticOptions = { category: ErrorCategory; severity: ErrorSeverity; reason: string; description: string; details: Array; suggestions?: Array | null | undefined; }; export type CompilerDiagnosticDetail = /** * A/the source of the error */ | { kind: 'error'; loc: SourceLocation | null; message: string; } | { kind: 'hint'; message: string; }; export enum CompilerSuggestionOperation { InsertBefore, InsertAfter, Remove, Replace, } export type CompilerSuggestion = | { op: | CompilerSuggestionOperation.InsertAfter | CompilerSuggestionOperation.InsertBefore | CompilerSuggestionOperation.Replace; range: [number, number]; description: string; text: string; } | { op: CompilerSuggestionOperation.Remove; range: [number, number]; description: string; }; export type CompilerErrorDetailOptions = { category: ErrorCategory; severity: ErrorSeverity; reason: string; description?: string | null | undefined; loc: SourceLocation | null; suggestions?: Array | null | undefined; }; export type PrintErrorMessageOptions = { /** * ESLint uses 1-indexed columns and prints one error at a time * So it doesn't require the "Found # error(s)" text */ eslint: boolean; }; export class CompilerDiagnostic { options: CompilerDiagnosticOptions; constructor(options: CompilerDiagnosticOptions) { this.options = options; } static create( options: Omit, ): CompilerDiagnostic { return new CompilerDiagnostic({...options, details: []}); } get reason(): CompilerDiagnosticOptions['reason'] { return this.options.reason; } get description(): CompilerDiagnosticOptions['description'] { return this.options.description; } get severity(): CompilerDiagnosticOptions['severity'] { return this.options.severity; } get suggestions(): CompilerDiagnosticOptions['suggestions'] { return this.options.suggestions; } get category(): ErrorCategory { return this.options.category; } withDetail(detail: CompilerDiagnosticDetail): CompilerDiagnostic { this.options.details.push(detail); return this; } primaryLocation(): SourceLocation | null { const firstErrorDetail = this.options.details.filter( d => d.kind === 'error', )[0]; return firstErrorDetail != null && firstErrorDetail.kind === 'error' ? firstErrorDetail.loc : null; } printErrorMessage(source: string, options: PrintErrorMessageOptions): string { const buffer = [ printErrorSummary(this.severity, this.reason), '\n\n', this.description, ]; for (const detail of this.options.details) { switch (detail.kind) { case 'error': { const loc = detail.loc; if (loc == null || typeof loc === 'symbol') { continue; } let codeFrame: string; try { codeFrame = printCodeFrame(source, loc, detail.message); } catch (e) { codeFrame = detail.message; } buffer.push('\n\n'); if (loc.filename != null) { const line = loc.start.line; const column = options.eslint ? loc.start.column + 1 : loc.start.column; buffer.push(`${loc.filename}:${line}:${column}\n`); } buffer.push(codeFrame); break; } case 'hint': { buffer.push('\n\n'); buffer.push(detail.message); break; } default: { assertExhaustive( detail, `Unexpected detail kind ${(detail as any).kind}`, ); } } } return buffer.join(''); } toString(): string { const buffer = [printErrorSummary(this.severity, this.reason)]; if (this.description != null) { buffer.push(`. ${this.description}.`); } const loc = this.primaryLocation(); if (loc != null && typeof loc !== 'symbol') { buffer.push(` (${loc.start.line}:${loc.start.column})`); } return buffer.join(''); } } /* * Each bailout or invariant in HIR lowering creates an {@link CompilerErrorDetail}, which is then * aggregated into a single {@link CompilerError} later. */ export class CompilerErrorDetail { options: CompilerErrorDetailOptions; constructor(options: CompilerErrorDetailOptions) { this.options = options; } get reason(): CompilerErrorDetailOptions['reason'] { return this.options.reason; } get description(): CompilerErrorDetailOptions['description'] { return this.options.description; } get severity(): CompilerErrorDetailOptions['severity'] { return this.options.severity; } get loc(): CompilerErrorDetailOptions['loc'] { return this.options.loc; } get suggestions(): CompilerErrorDetailOptions['suggestions'] { return this.options.suggestions; } get category(): ErrorCategory { return this.options.category; } primaryLocation(): SourceLocation | null { return this.loc; } printErrorMessage(source: string, options: PrintErrorMessageOptions): string { const buffer = [printErrorSummary(this.severity, this.reason)]; if (this.description != null) { buffer.push(`\n\n${this.description}.`); } const loc = this.loc; if (loc != null && typeof loc !== 'symbol') { let codeFrame: string; try { codeFrame = printCodeFrame(source, loc, this.reason); } catch (e) { codeFrame = ''; } buffer.push(`\n\n`); if (loc.filename != null) { const line = loc.start.line; const column = options.eslint ? loc.start.column + 1 : loc.start.column; buffer.push(`${loc.filename}:${line}:${column}\n`); } buffer.push(codeFrame); buffer.push('\n\n'); } return buffer.join(''); } toString(): string { const buffer = [printErrorSummary(this.severity, this.reason)]; if (this.description != null) { buffer.push(`. ${this.description}.`); } const loc = this.loc; if (loc != null && typeof loc !== 'symbol') { buffer.push(` (${loc.start.line}:${loc.start.column})`); } return buffer.join(''); } } export class CompilerError extends Error { details: Array = []; printedMessage: string | null = null; static invariant( condition: unknown, options: Omit, ): asserts condition { if (!condition) { const errors = new CompilerError(); errors.pushErrorDetail( new CompilerErrorDetail({ ...options, category: ErrorCategory.Invariant, severity: ErrorSeverity.Invariant, }), ); throw errors; } } static throwDiagnostic(options: CompilerDiagnosticOptions): never { const errors = new CompilerError(); errors.pushDiagnostic(new CompilerDiagnostic(options)); throw errors; } static throwTodo( options: Omit, ): never { const errors = new CompilerError(); errors.pushErrorDetail( new CompilerErrorDetail({ ...options, severity: ErrorSeverity.Todo, category: ErrorCategory.Todo, }), ); throw errors; } static throwInvalidJS( options: Omit, ): never { const errors = new CompilerError(); errors.pushErrorDetail( new CompilerErrorDetail({ ...options, severity: ErrorSeverity.InvalidJS, category: ErrorCategory.Syntax, }), ); throw errors; } static throwInvalidReact( options: Omit, ): never { const errors = new CompilerError(); errors.pushErrorDetail( new CompilerErrorDetail({ ...options, severity: ErrorSeverity.InvalidReact, }), ); throw errors; } static throwInvalidConfig( options: Omit, ): never { const errors = new CompilerError(); errors.pushErrorDetail( new CompilerErrorDetail({ ...options, severity: ErrorSeverity.InvalidConfig, category: ErrorCategory.Config, }), ); throw errors; } static throw(options: CompilerErrorDetailOptions): never { const errors = new CompilerError(); errors.pushErrorDetail(new CompilerErrorDetail(options)); throw errors; } constructor(...args: Array) { super(...args); this.name = 'ReactCompilerError'; this.details = []; } override get message(): string { return this.printedMessage ?? this.toString(); } override set message(_message: string) {} override toString(): string { if (this.printedMessage) { return this.printedMessage; } if (Array.isArray(this.details)) { return this.details.map(detail => detail.toString()).join('\n\n'); } return this.name; } withPrintedMessage( source: string, options: PrintErrorMessageOptions, ): CompilerError { this.printedMessage = this.printErrorMessage(source, options); return this; } printErrorMessage(source: string, options: PrintErrorMessageOptions): string { if (options.eslint && this.details.length === 1) { return this.details[0].printErrorMessage(source, options); } return ( `Found ${this.details.length} error${this.details.length === 1 ? '' : 's'}:\n\n` + this.details .map(detail => detail.printErrorMessage(source, options).trim()) .join('\n\n') ); } merge(other: CompilerError): void { this.details.push(...other.details); } pushDiagnostic(diagnostic: CompilerDiagnostic): void { this.details.push(diagnostic); } push(options: CompilerErrorDetailOptions): CompilerErrorDetail { const detail = new CompilerErrorDetail({ category: options.category, reason: options.reason, description: options.description ?? null, severity: options.severity, suggestions: options.suggestions, loc: typeof options.loc === 'symbol' ? null : options.loc, }); return this.pushErrorDetail(detail); } pushErrorDetail(detail: CompilerErrorDetail): CompilerErrorDetail { this.details.push(detail); return detail; } hasErrors(): boolean { return this.details.length > 0; } asResult(): Result { return this.hasErrors() ? Err(this) : Ok(undefined); } /* * An error is critical if it means the compiler has entered into a broken state and cannot * continue safely. Other expected errors such as Todos mean that we can skip over that component * but otherwise continue compiling the rest of the app. */ isCritical(): boolean { return this.details.some(detail => { switch (detail.severity) { case ErrorSeverity.Invariant: case ErrorSeverity.InvalidJS: case ErrorSeverity.InvalidReact: case ErrorSeverity.InvalidConfig: case ErrorSeverity.UnsupportedJS: case ErrorSeverity.IncompatibleLibrary: { return true; } case ErrorSeverity.CannotPreserveMemoization: case ErrorSeverity.Todo: { return false; } default: { assertExhaustive(detail.severity, 'Unhandled error severity'); } } }); } } function printCodeFrame( source: string, loc: t.SourceLocation, message: string, ): string { return codeFrameColumns( source, { start: { line: loc.start.line, column: loc.start.column + 1, }, end: { line: loc.end.line, column: loc.end.column + 1, }, }, { message, }, ); } function printErrorSummary(severity: ErrorSeverity, message: string): string { let severityCategory: string; switch (severity) { case ErrorSeverity.InvalidConfig: case ErrorSeverity.InvalidJS: case ErrorSeverity.InvalidReact: case ErrorSeverity.UnsupportedJS: { severityCategory = 'Error'; break; } case ErrorSeverity.IncompatibleLibrary: case ErrorSeverity.CannotPreserveMemoization: { severityCategory = 'Compilation Skipped'; break; } case ErrorSeverity.Invariant: { severityCategory = 'Invariant'; break; } case ErrorSeverity.Todo: { severityCategory = 'Todo'; break; } default: { assertExhaustive(severity, `Unexpected severity '${severity}'`); } } return `${severityCategory}: ${message}`; } /** * See getRuleForCategory() for how these map to ESLint rules */ export enum ErrorCategory { // Checking for valid hooks usage (non conditional, non-first class, non reactive, etc) Hooks = 'Hooks', // Checking for no capitalized calls (not definitively an error, hence separating) CapitalizedCalls = 'CapitalizedCalls', // Checking for static components StaticComponents = 'StaticComponents', // Checking for valid usage of manual memoization UseMemo = 'UseMemo', // Checking for higher order functions acting as factories for components/hooks Factories = 'Factories', // Checks that manual memoization is preserved PreserveManualMemo = 'PreserveManualMemo', // Checks for known incompatible libraries IncompatibleLibrary = 'IncompatibleLibrary', // Checking for no mutations of props, hook arguments, hook return values Immutability = 'Immutability', // Checking for assignments to globals Globals = 'Globals', // Checking for valid usage of refs, ie no access during render Refs = 'Refs', // Checks for memoized effect deps EffectDependencies = 'EffectDependencies', // Checks for no setState in effect bodies EffectSetState = 'EffectSetState', EffectDerivationsOfState = 'EffectDerivationsOfState', // Validates against try/catch in place of error boundaries ErrorBoundaries = 'ErrorBoundaries', // Checking for pure functions Purity = 'Purity', // Validates against setState in render RenderSetState = 'RenderSetState', // Internal invariants Invariant = 'Invariant', // Todos Todo = 'Todo', // Syntax errors Syntax = 'Syntax', // Checks for use of unsupported syntax UnsupportedSyntax = 'UnsupportedSyntax', // Config errors Config = 'Config', // Gating error Gating = 'Gating', // Suppressions Suppression = 'Suppression', // Issues with auto deps AutomaticEffectDependencies = 'AutomaticEffectDependencies', // Issues with `fire` Fire = 'Fire', // fbt-specific issues FBT = 'FBT', } export type LintRule = { // Stores the category the rule corresponds to, used to filter errors when reporting category: ErrorCategory; /** * The "name" of the rule as it will be used by developers to enable/disable, eg * "eslint-disable-nest line " */ name: string; /** * A description of the rule that appears somewhere in ESLint. This does not affect * how error messages are formatted */ description: string; /** * If true, this rule will automatically appear in the default, "recommended" ESLint * rule set. Otherwise it will be part of an `allRules` export that developers can * use to opt-in to showing output of all possible rules. * * NOTE: not all validations are enabled by default! Setting this flag only affects * whether a given rule is part of the recommended set. The corresponding validation * also should be enabled by default if you want the error to actually show up! */ recommended: boolean; }; const RULE_NAME_PATTERN = /^[a-z]+(-[a-z]+)*$/; export function getRuleForCategory(category: ErrorCategory): LintRule { const rule = getRuleForCategoryImpl(category); invariant( RULE_NAME_PATTERN.test(rule.name), `Invalid rule name, got '${rule.name}' but rules must match ${RULE_NAME_PATTERN.toString()}`, ); return rule; } function getRuleForCategoryImpl(category: ErrorCategory): LintRule { switch (category) { case ErrorCategory.AutomaticEffectDependencies: { return { category, name: 'automatic-effect-dependencies', description: 'Verifies that automatic effect dependencies are compiled if opted-in', recommended: false, }; } case ErrorCategory.CapitalizedCalls: { return { category, name: 'capitalized-calls', description: 'Validates against calling capitalized functions/methods instead of using JSX', recommended: false, }; } case ErrorCategory.Config: { return { category, name: 'config', description: 'Validates the compiler configuration options', recommended: true, }; } case ErrorCategory.EffectDependencies: { return { category, name: 'memoized-effect-dependencies', description: 'Validates that effect dependencies are memoized', recommended: false, }; } case ErrorCategory.EffectDerivationsOfState: { return { category, name: 'no-deriving-state-in-effects', description: 'Validates against deriving values from state in an effect', recommended: false, }; } case ErrorCategory.EffectSetState: { return { category, name: 'set-state-in-effect', description: 'Validates against calling setState synchronously in an effect, which can lead to re-renders that degrade performance', recommended: true, }; } case ErrorCategory.ErrorBoundaries: { return { category, name: 'error-boundaries', description: 'Validates usage of error boundaries instead of try/catch for errors in child components', recommended: true, }; } case ErrorCategory.Factories: { return { category, name: 'component-hook-factories', description: 'Validates against higher order functions defining nested components or hooks. ' + 'Components and hooks should be defined at the module level', recommended: true, }; } case ErrorCategory.FBT: { return { category, name: 'fbt', description: 'Validates usage of fbt', recommended: false, }; } case ErrorCategory.Fire: { return { category, name: 'fire', description: 'Validates usage of `fire`', recommended: false, }; } case ErrorCategory.Gating: { return { category, name: 'gating', description: 'Validates configuration of [gating mode](https://react.dev/reference/react-compiler/gating)', recommended: true, }; } case ErrorCategory.Globals: { return { category, name: 'globals', description: 'Validates against assignment/mutation of globals during render, part of ensuring that ' + '[side effects must render outside of render](https://react.dev/reference/rules/components-and-hooks-must-be-pure#side-effects-must-run-outside-of-render)', recommended: true, }; } case ErrorCategory.Hooks: { return { category, name: 'hooks', description: 'Validates the rules of hooks', /** * TODO: the "Hooks" rule largely reimplements the "rules-of-hooks" non-compiler rule. * We need to dedeupe these (moving the remaining bits into the compiler) and then enable * this rule. */ recommended: false, }; } case ErrorCategory.Immutability: { return { category, name: 'immutability', description: 'Validates against mutating props, state, and other values that [are immutable](https://react.dev/reference/rules/components-and-hooks-must-be-pure#props-and-state-are-immutable)', recommended: true, }; } case ErrorCategory.Invariant: { return { category, name: 'invariant', description: 'Internal invariants', recommended: false, }; } case ErrorCategory.PreserveManualMemo: { return { category, name: 'preserve-manual-memoization', description: 'Validates that existing manual memoized is preserved by the compiler. ' + 'React Compiler will only compile components and hooks if its inference ' + '[matches or exceeds the existing manual memoization](https://react.dev/learn/react-compiler/introduction#what-should-i-do-about-usememo-usecallback-and-reactmemo)', recommended: true, }; } case ErrorCategory.Purity: { return { category, name: 'purity', description: 'Validates that [components/hooks are pure](https://react.dev/reference/rules/components-and-hooks-must-be-pure) by checking that they do not call known-impure functions', recommended: true, }; } case ErrorCategory.Refs: { return { category, name: 'refs', description: 'Validates correct usage of refs, not reading/writing during render. See the "pitfalls" section in [`useRef()` usage](https://react.dev/reference/react/useRef#usage)', recommended: true, }; } case ErrorCategory.RenderSetState: { return { category, name: 'set-state-in-render', description: 'Validates against setting state during render, which can trigger additional renders and potential infinite render loops', recommended: true, }; } case ErrorCategory.StaticComponents: { return { category, name: 'static-components', description: 'Validates that components are static, not recreated every render. Components that are recreated dynamically can reset state and trigger excessive re-rendering', recommended: true, }; } case ErrorCategory.Suppression: { return { category, name: 'rule-suppression', description: 'Validates against suppression of other rules', recommended: false, }; } case ErrorCategory.Syntax: { return { category, name: 'syntax', description: 'Validates against invalid syntax', recommended: false, }; } case ErrorCategory.Todo: { return { category, name: 'todo', description: 'Unimplemented features', recommended: false, }; } case ErrorCategory.UnsupportedSyntax: { return { category, name: 'unsupported-syntax', description: 'Validates against syntax that we do not plan to support in React Compiler', recommended: true, }; } case ErrorCategory.UseMemo: { return { category, name: 'use-memo', description: 'Validates usage of the useMemo() hook against common mistakes. See [`useMemo()` docs](https://react.dev/reference/react/useMemo) for more information.', recommended: true, }; } case ErrorCategory.IncompatibleLibrary: { return { category, name: 'incompatible-library', description: 'Validates against usage of libraries which are incompatible with memoization (manual or automatic)', recommended: true, }; } default: { assertExhaustive(category, `Unsupported category ${category}`); } } } export const LintRules: Array = Object.keys(ErrorCategory).map( category => getRuleForCategory(category as any), );