@samitouri / QOS-React-1 / commits / 19476aa5f6

Option to bail on Flow react-rule suppressions

Mike Vitousek committed Feb 9, 2024 at 14:21 UTC 19476aa5f6f448a97733dc95739b4873a8f8ab12
9 files changed +152 -33
compiler/packages/babel-plugin-react-forget/src/Entrypoint/Options.ts
+3
@@ -102,6 +102,8 @@ export type PluginOptions = {
102 * even if the default ESLint is suppressed), pass an empty array.
103 */
104 eslintSuppressionRules?: Array<string> | null | undefined;
105 +
106 + flowSuppressions: boolean;
107 };
108
109 const CompilationModeSchema = z.enum([
@@ -169,6 +171,7 @@ export const defaultOptions: PluginOptions = {
171 noEmit: false,
172 enableUseMemoCachePolyfill: false,
173 eslintSuppressionRules: null,
174 + flowSuppressions: false,
175 } as const;
176
177 export function parsePluginOptions(obj: unknown): PluginOptions {
compiler/packages/babel-plugin-react-forget/src/Entrypoint/Program.ts
+12 -11
@@ -20,15 +20,15 @@ import {
20 import { CodegenFunction } from "../ReactiveScopes";
21 import { isComponentDeclaration } from "../Utils/ComponentDeclaration";
22 import { assertExhaustive } from "../Utils/utils";
23 -import {
24 - filterEslintSuppressionsThatAffectFunction,
25 - findProgramEslintSuppressions,
26 - suppressionsToCompilerError,
27 -} from "./EslintSuppression";
23 import { insertGatedFunctionDeclaration } from "./Gating";
24 import { addImportsToProgram, updateUseMemoCacheImport } from "./Imports";
25 import { PluginOptions, parsePluginOptions } from "./Options";
26 import { compileFn } from "./Pipeline";
27 +import {
28 + filterSuppressionsThatAffectFunction,
29 + findProgramSuppressions,
30 + suppressionsToCompilerError,
31 +} from "./Suppression";
32
33 export type CompilerPass = {
34 opts: PluginOptions;
@@ -196,11 +196,12 @@ export function compileProgram(
196 * we may still need to run Forget's analysis on every function (even if we
197 * have already encountered errors) for reporting.
198 */
199 - const eslintSuppressions = findProgramEslintSuppressions(
199 + const suppressions = findProgramSuppressions(
200 pass.comments,
201 - options.eslintSuppressionRules ?? DEFAULT_ESLINT_SUPPRESSIONS
201 + options.eslintSuppressionRules ?? DEFAULT_ESLINT_SUPPRESSIONS,
202 + options.flowSuppressions,
203 );
203 - const lintError = suppressionsToCompilerError(eslintSuppressions);
204 + const lintError = suppressionsToCompilerError(suppressions);
205 let hasCriticalError = lintError != null;
206 const compiledFns: CompileResult[] = [];
207
@@ -223,9 +224,9 @@ export function compileProgram(
224 * Program node itself. We need to figure out whether an eslint suppression range
225 * applies to this function first.
226 */
226 - const eslintSuppressionsInFunction =
227 - filterEslintSuppressionsThatAffectFunction(eslintSuppressions, fn);
228 - if (eslintSuppressionsInFunction.length > 0) {
227 + const suppressionsInFunction =
228 + filterSuppressionsThatAffectFunction(suppressions, fn);
229 + if (suppressionsInFunction.length > 0) {
230 handleError(lintError, pass, fn.node.loc ?? null);
231 }
232 }
compiler/packages/babel-plugin-react-forget/src/Entrypoint/Suppression.ts renamed
+50 -18
@@ -16,26 +16,31 @@ import {
16
17 /**
18 * Captures the start and end range of a pair of eslint-disable ... eslint-enable comments. In the
19 - * case of a CommentLine, both the disable and enable point to the same comment.
19 + * case of a CommentLine or a relevant Flow suppression, both the disable and enable point to the
20 + * same comment.
21 *
22 * The enable comment can be missing in the case where only a disable block is present, ie the rest
23 * of the file has potential React violations.
24 */
24 -export type EslintSuppressionRange = {
25 +export type SuppressionRange = {
26 disableComment: t.Comment;
27 enableComment: t.Comment | null;
28 + source: SuppressionSource;
29 };
30
31 +type SuppressionSource =
32 + 'Eslint' | 'Flow'
33 +
34 /**
30 - * An eslint suppression affects a function if:
35 + * An suppression affects a function if:
36 * 1. The suppression is within the function's body; or
37 * 2. The suppression wraps the function
38 */
34 -export function filterEslintSuppressionsThatAffectFunction(
35 - suppressionRanges: Array<EslintSuppressionRange>,
39 +export function filterSuppressionsThatAffectFunction(
40 + suppressionRanges: Array<SuppressionRange>,
41 fn: NodePath<t.Function>
37 -): Array<EslintSuppressionRange> {
38 - const suppressionsInScope: Array<EslintSuppressionRange> = [];
42 +): Array<SuppressionRange> {
43 + const suppressionsInScope: Array<SuppressionRange> = [];
44 const fnNode = fn.node;
45 for (const suppressionRange of suppressionRanges) {
46 if (
@@ -70,13 +75,15 @@ export function filterEslintSuppressionsThatAffectFunction(
75 return suppressionsInScope;
76 }
77
73 -export function findProgramEslintSuppressions(
78 +export function findProgramSuppressions(
79 programComments: Array<t.Comment>,
75 - ruleNames: Array<string>
76 -): Array<EslintSuppressionRange> {
77 - const suppressionRanges: Array<EslintSuppressionRange> = [];
80 + ruleNames: Array<string>,
81 + flowSuppressions: boolean,
82 +): Array<SuppressionRange> {
83 + const suppressionRanges: Array<SuppressionRange> = [];
84 let disableComment: t.Comment | null = null;
85 let enableComment: t.Comment | null = null;
86 + let source: SuppressionSource | null = null;
87
88 const rulePattern = `(${ruleNames.join("|")})`;
89 const disableNextLinePattern = new RegExp(
@@ -84,6 +91,9 @@ export function findProgramEslintSuppressions(
91 );
92 const disablePattern = new RegExp(`eslint-disable ${rulePattern}`);
93 const enablePattern = new RegExp(`eslint-enable ${rulePattern}`);
94 + const flowSuppressionPattern = new RegExp(
95 + '\\$(FlowFixMe\\w*|FlowExpectedError|FlowIssue)\\[react\\-rule'
96 + );
97
98 for (const comment of programComments) {
99 if (comment.start == null || comment.end == null) {
@@ -100,36 +110,48 @@ export function findProgramEslintSuppressions(
110 ) {
111 disableComment = comment;
112 enableComment = comment;
113 + source = 'Eslint';
114 + }
115 +
116 + if (
117 + flowSuppressions &&
118 + disableComment == null &&
119 + flowSuppressionPattern.test(comment.value)
120 + ) {
121 + disableComment = comment;
122 + enableComment = comment;
123 + source = 'Flow';
124 }
125
126 if (disablePattern.test(comment.value)) {
127 disableComment = comment;
128 + source = 'Eslint';
129 }
130
109 - if (enablePattern.test(comment.value)) {
131 + if (enablePattern.test(comment.value) && source === 'Eslint') {
132 enableComment = comment;
133 }
134
113 - if (disableComment != null) {
135 + if (disableComment != null && source != null) {
136 suppressionRanges.push({
137 disableComment: disableComment,
138 enableComment: enableComment,
139 + source,
140 });
141 disableComment = null;
142 enableComment = null;
143 + source = null;
144 }
145 }
146 return suppressionRanges;
147 }
148
149 export function suppressionsToCompilerError(
126 - suppressionRanges: Array<EslintSuppressionRange>
150 + suppressionRanges: Array<SuppressionRange>
151 ): CompilerError | null {
152 if (suppressionRanges.length === 0) {
153 return null;
154 }
131 - const reason =
132 - "React Forget has bailed out of optimizing this component as one or more React eslint rules were disabled. React Forget only works when your components follow all the rules of React, disabling them may result in undefined behavior";
155 const error = new CompilerError();
156 for (const suppressionRange of suppressionRanges) {
157 if (
@@ -138,15 +160,25 @@ export function suppressionsToCompilerError(
160 ) {
161 continue;
162 }
163 + let reason, suggestion;
164 + switch (suppressionRange.source) {
165 + case 'Eslint':
166 + reason = "React Forget has bailed out of optimizing this component as one or more React eslint rules were disabled";
167 + suggestion = "Remove the eslint disable";
168 + break;
169 + case 'Flow':
170 + reason = "React Forget has bailed out of optimizing this component as one or more React rule violations were reported by Flow";
171 + suggestion = "Remove the Flow suppression and address the React error";
172 + }
173 error.pushErrorDetail(
174 new CompilerErrorDetail({
143 - reason,
175 + reason: `${reason}. React Forget only works when your components follow all the rules of React, disabling them may result in undefined behavior`,
176 description: suppressionRange.disableComment.value.trim(),
177 severity: ErrorSeverity.InvalidReact,
178 loc: suppressionRange.disableComment.loc ?? null,
179 suggestions: [
180 {
149 - description: "Remove the eslint disable",
181 + description: suggestion,
182 range: [
183 suppressionRange.disableComment.start,
184 suppressionRange.disableComment.end,
compiler/packages/babel-plugin-react-forget/src/Entrypoint/index.ts
+1 -1
@@ -5,9 +5,9 @@
5 * LICENSE file in the root directory of this source tree.
6 */
7
8 -export * from "./EslintSuppression";
8 export * from "./Gating";
9 export * from "./Imports";
10 export * from "./Options";
11 export * from "./Pipeline";
12 export * from "./Program";
13 +export * from "./Suppression";
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.bailout-on-flow-suppression.expect.md new
+22
@@ -0,0 +1,22 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @enableFlowSuppressions
6 +
7 +function Foo(props) {
8 + // $FlowFixMe[react-rule-hook]
9 + useX();
10 + return null;
11 +}
12 +
13 +```
14 +
15 +
16 +## Error
17 +
18 +```
19 +[ReactForget] InvalidReact: React Forget has bailed out of optimizing this component as one or more React rule violations were reported by Flow. React Forget only works when your components follow all the rules of React, disabling them may result in undefined behavior. $FlowFixMe[react-rule-hook] (4:4)
20 +```
21 +
22 +
\ No newline at end of file
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/error.bailout-on-flow-suppression.js new
+7
@@ -0,0 +1,7 @@
1 +// @enableFlowSuppressions
2 +
3 +function Foo(props) {
4 + // $FlowFixMe[react-rule-hook]
5 + useX();
6 + return null;
7 +}
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/no-flow-bailout-unrelated.expect.md new
+39
@@ -0,0 +1,39 @@
1 +
2 +## Input
3 +
4 +```javascript
5 +// @enableFlowSuppressions
6 +
7 +function Foo(props) {
8 + // $FlowFixMe[incompatible-type]
9 + useX();
10 + const x = new Foo(...props.foo, null, ...[props.bar]);
11 + return x;
12 +}
13 +
14 +```
15 +
16 +## Code
17 +
18 +```javascript
19 +import { unstable_useMemoCache as useMemoCache } from "react"; // @enableFlowSuppressions
20 +
21 +function Foo(props) {
22 + const $ = useMemoCache(3);
23 +
24 + useX();
25 + let t0;
26 + if ($[0] !== props.bar || $[1] !== props.foo) {
27 + t0 = new Foo(...props.foo, null, ...[props.bar]);
28 + $[0] = props.bar;
29 + $[1] = props.foo;
30 + $[2] = t0;
31 + } else {
32 + t0 = $[2];
33 + }
34 + const x = t0;
35 + return x;
36 +}
37 +
38 +```
39 +
\ No newline at end of file
compiler/packages/babel-plugin-react-forget/src/__tests__/fixtures/compiler/no-flow-bailout-unrelated.js new
+8
@@ -0,0 +1,8 @@
1 +// @enableFlowSuppressions
2 +
3 +function Foo(props) {
4 + // $FlowFixMe[incompatible-type]
5 + useX();
6 + const x = new Foo(...props.foo, null, ...[props.bar]);
7 + return x;
8 +}
compiler/packages/fixture-test-utils/src/compiler-utils.ts
+10 -3
@@ -93,6 +93,12 @@ export function transformFixtureInput(
93 eslintSuppressionRules = eslintSuppressionMatch[1].split("|");
94 }
95
96 + let flowSuppressions: boolean = false;
97 + if (firstLine.includes("@enableFlowSuppressions")) {
98 + flowSuppressions = true;
99 + }
100 +
101 +
102 const hookPatternMatch = /@hookPattern:"([^"]+)"/.exec(firstLine);
103 if (
104 hookPatternMatch &&
@@ -156,6 +162,7 @@ export function transformFixtureInput(
162 noEmit: false,
163 enableUseMemoCachePolyfill,
164 eslintSuppressionRules,
165 + flowSuppressions,
166 },
167 includeAst
168 );
@@ -165,9 +172,9 @@ export function transformFixtureInput(
172 code:
173 result.code != null
174 ? prettier.format(result.code, {
168 - semi: true,
169 - parser: language === "typescript" ? "babel-ts" : "flow",
170 - })
175 + semi: true,
176 + parser: language === "typescript" ? "babel-ts" : "flow",
177 + })
178 : result.code,
179 };
180 }