From d2413bf377e7f73661b0700aeb95d07fb2911efc Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Fri, 16 Aug 2024 17:05:29 -0700 Subject: [PATCH] [compiler] Validate against JSX in try statements Per comments on the new validation pass, this disallows creating JSX (expression/fragment) within a try statement. Developers sometimes use this pattern thinking that they can catch errors during the rendering of the element, without realizing that rendering is lazy. The validation allows us to teach developers about the error boundary pattern. ghstack-source-id: 0bc722aeaed426ddd40e075c008f0ff2576e0c33 Pull Request resolved: https://github.com/facebook/react/pull/30725 --- .../src/Entrypoint/Pipeline.ts | 5 ++ .../src/HIR/Environment.ts | 6 ++ .../Validation/ValidateNoJSXInTryStatement.ts | 52 +++++++++++++++++ ...in-catch-in-outer-try-with-catch.expect.md | 38 +++++++++++++ ...id-jsx-in-catch-in-outer-try-with-catch.js | 17 ++++++ ...or.invalid-jsx-in-try-with-catch.expect.md | 31 ++++++++++ .../error.invalid-jsx-in-try-with-catch.js | 10 ++++ ...-catch-in-outer-try-with-finally.expect.md | 56 +++++++++++++++++++ ...-jsx-in-catch-in-outer-try-with-finally.js | 17 ++++++ ...-invalid-jsx-in-try-with-finally.expect.md | 39 +++++++++++++ ...or.todo-invalid-jsx-in-try-with-finally.js | 10 ++++ 11 files changed, 281 insertions(+) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoJSXInTryStatement.ts create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-in-catch-in-outer-try-with-catch.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-in-catch-in-outer-try-with-catch.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-in-try-with-catch.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-in-try-with-catch.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-catch-in-outer-try-with-finally.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-catch-in-outer-try-with-finally.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-try-with-finally.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-try-with-finally.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts index 590aba2fdc..8307e8817b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts @@ -105,6 +105,7 @@ import {outlineFunctions} from '../Optimization/OutlineFunctions'; import {propagatePhiTypes} from '../TypeInference/PropagatePhiTypes'; import {lowerContextAccess} from '../Optimization/LowerContextAccess'; import {validateNoSetStateInPassiveEffects} from '../Validation/ValidateNoSetStateInPassiveEffects'; +import {validateNoJSXInTryStatement} from '../Validation/ValidateNoJSXInTryStatement'; export type CompilerPipelineValue = | {kind: 'ast'; name: string; value: CodegenFunction} @@ -249,6 +250,10 @@ function* runWithEnvironment( validateNoSetStateInPassiveEffects(hir); } + if (env.config.validateNoJSXInTryStatements) { + validateNoJSXInTryStatement(hir); + } + inferReactivePlaces(hir); yield log({kind: 'hir', name: 'InferReactivePlaces', value: hir}); diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts index ca03b8a7b1..a5614ac244 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts @@ -237,6 +237,12 @@ const EnvironmentConfigSchema = z.object({ */ validateNoSetStateInPassiveEffects: z.boolean().default(false), + /** + * Validates against creating JSX within a try block and recommends using an error boundary + * instead. + */ + validateNoJSXInTryStatements: z.boolean().default(false), + /** * Validates that the dependencies of all effect hooks are memoized. This helps ensure * that Forget does not introduce infinite renders caused by a dependency changing, diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoJSXInTryStatement.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoJSXInTryStatement.ts new file mode 100644 index 0000000000..b92a89d764 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateNoJSXInTryStatement.ts @@ -0,0 +1,52 @@ +/** + * 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 {CompilerError, ErrorSeverity} from '..'; +import {BlockId, HIRFunction} from '../HIR'; +import {retainWhere} from '../Utils/utils'; + +/** + * Developers may not be aware of error boundaries and lazy evaluation of JSX, leading them + * to use patterns such as `let el; try { el = } catch { ... }` to attempt to + * catch rendering errors. Such code will fail to catch errors in rendering, but developers + * may not realize this right away. + * + * This validation pass validates against this pattern: specifically, it errors for JSX + * created within a try block. JSX is allowed within a catch statement, unless that catch + * is itself nested inside an outer try. + */ +export function validateNoJSXInTryStatement(fn: HIRFunction): void { + const activeTryBlocks: Array = []; + const errors = new CompilerError(); + for (const [, block] of fn.body.blocks) { + retainWhere(activeTryBlocks, id => id !== block.id); + + if (activeTryBlocks.length !== 0) { + for (const instr of block.instructions) { + const {value} = instr; + switch (value.kind) { + case 'JsxExpression': + case 'JsxFragment': { + errors.push({ + severity: ErrorSeverity.InvalidReact, + reason: `Unexpected JSX element within a try statement. To catch errors in rendering a given component, wrap that component in an error boundary. (https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary)`, + loc: value.loc, + }); + break; + } + } + } + } + + if (block.terminal.kind === 'try') { + activeTryBlocks.push(block.terminal.handler); + } + } + if (errors.hasErrors()) { + throw errors; + } +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-in-catch-in-outer-try-with-catch.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-in-catch-in-outer-try-with-catch.expect.md new file mode 100644 index 0000000000..40cebff89a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-in-catch-in-outer-try-with-catch.expect.md @@ -0,0 +1,38 @@ + +## Input + +```javascript +// @validateNoJSXInTryStatements +import {identity} from 'shared-runtime'; + +function Component(props) { + let el; + try { + let value; + try { + value = identity(props.foo); + } catch { + el =
; + } + } catch { + return null; + } + return el; +} + +``` + + +## Error + +``` + 9 | value = identity(props.foo); + 10 | } catch { +> 11 | el =
; + | ^^^^^^^^^^^^^^^^^^^^^ InvalidReact: Unexpected JSX element within a try statement. To catch errors in rendering a given component, wrap that component in an error boundary. (https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary) (11:11) + 12 | } + 13 | } catch { + 14 | return null; +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-in-catch-in-outer-try-with-catch.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-in-catch-in-outer-try-with-catch.js new file mode 100644 index 0000000000..0935a1a63c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-in-catch-in-outer-try-with-catch.js @@ -0,0 +1,17 @@ +// @validateNoJSXInTryStatements +import {identity} from 'shared-runtime'; + +function Component(props) { + let el; + try { + let value; + try { + value = identity(props.foo); + } catch { + el =
; + } + } catch { + return null; + } + return el; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-in-try-with-catch.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-in-try-with-catch.expect.md new file mode 100644 index 0000000000..ee1f5335ef --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-in-try-with-catch.expect.md @@ -0,0 +1,31 @@ + +## Input + +```javascript +// @validateNoJSXInTryStatements +function Component(props) { + let el; + try { + el =
; + } catch { + return null; + } + return el; +} + +``` + + +## Error + +``` + 3 | let el; + 4 | try { +> 5 | el =
; + | ^^^^^^^ InvalidReact: Unexpected JSX element within a try statement. To catch errors in rendering a given component, wrap that component in an error boundary. (https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary) (5:5) + 6 | } catch { + 7 | return null; + 8 | } +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-in-try-with-catch.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-in-try-with-catch.js new file mode 100644 index 0000000000..3e7747c875 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-jsx-in-try-with-catch.js @@ -0,0 +1,10 @@ +// @validateNoJSXInTryStatements +function Component(props) { + let el; + try { + el =
; + } catch { + return null; + } + return el; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-catch-in-outer-try-with-finally.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-catch-in-outer-try-with-finally.expect.md new file mode 100644 index 0000000000..a7ea7b7739 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-catch-in-outer-try-with-finally.expect.md @@ -0,0 +1,56 @@ + +## Input + +```javascript +// @validateNoJSXInTryStatements +import {identity} from 'shared-runtime'; + +function Component(props) { + let el; + try { + let value; + try { + value = identity(props.foo); + } catch { + el =
; + } + } finally { + console.log(el); + } + return el; +} + +``` + + +## Error + +``` + 4 | function Component(props) { + 5 | let el; +> 6 | try { + | ^^^^^ +> 7 | let value; + | ^^^^^^^^^^^^^^ +> 8 | try { + | ^^^^^^^^^^^^^^ +> 9 | value = identity(props.foo); + | ^^^^^^^^^^^^^^ +> 10 | } catch { + | ^^^^^^^^^^^^^^ +> 11 | el =
; + | ^^^^^^^^^^^^^^ +> 12 | } + | ^^^^^^^^^^^^^^ +> 13 | } finally { + | ^^^^^^^^^^^^^^ +> 14 | console.log(el); + | ^^^^^^^^^^^^^^ +> 15 | } + | ^^^^ Todo: (BuildHIR::lowerStatement) Handle TryStatement without a catch clause (6:15) + 16 | return el; + 17 | } + 18 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-catch-in-outer-try-with-finally.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-catch-in-outer-try-with-finally.js new file mode 100644 index 0000000000..9db091a2fb --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-catch-in-outer-try-with-finally.js @@ -0,0 +1,17 @@ +// @validateNoJSXInTryStatements +import {identity} from 'shared-runtime'; + +function Component(props) { + let el; + try { + let value; + try { + value = identity(props.foo); + } catch { + el =
; + } + } finally { + console.log(el); + } + return el; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-try-with-finally.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-try-with-finally.expect.md new file mode 100644 index 0000000000..a6a85d4519 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-try-with-finally.expect.md @@ -0,0 +1,39 @@ + +## Input + +```javascript +// @validateNoJSXInTryStatements +function Component(props) { + let el; + try { + el =
; + } finally { + console.log(el); + } + return el; +} + +``` + + +## Error + +``` + 2 | function Component(props) { + 3 | let el; +> 4 | try { + | ^^^^^ +> 5 | el =
; + | ^^^^^^^^^^^^^^^^^ +> 6 | } finally { + | ^^^^^^^^^^^^^^^^^ +> 7 | console.log(el); + | ^^^^^^^^^^^^^^^^^ +> 8 | } + | ^^^^ Todo: (BuildHIR::lowerStatement) Handle TryStatement without a catch clause (4:8) + 9 | return el; + 10 | } + 11 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-try-with-finally.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-try-with-finally.js new file mode 100644 index 0000000000..f0a17391c0 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-invalid-jsx-in-try-with-finally.js @@ -0,0 +1,10 @@ +// @validateNoJSXInTryStatements +function Component(props) { + let el; + try { + el =
; + } finally { + console.log(el); + } + return el; +}