Add runtime validation for EnvironmentConfig
Sathya Gunasekaran committed
Nov 7, 2023 at 11:04 UTC
00cb557b12ecc3991ed35570f69b3f0284a9a545
9 files changed
+137
-151
compiler/apps/playground/components/Editor/EditorImpl.tsx
+13
-3
@@ -39,7 +39,7 @@ import {
39
} from "./Output";
40
41
function parseFunctions(
42
- source: string
42
+ source: string,
43
): Array<NodePath<t.FunctionDeclaration>> {
44
const items: Array<NodePath<t.FunctionDeclaration>> = [];
45
try {
@@ -77,6 +77,8 @@ const COMMON_HOOKS: Array<[string, Hook]> = [
77
{
78
valueKind: ValueKind.Frozen,
79
effectKind: Effect.Freeze,
80
+ noAlias: true,
81
+ transitiveMixedData: true,
82
},
83
],
84
[
@@ -84,6 +86,8 @@ const COMMON_HOOKS: Array<[string, Hook]> = [
86
{
87
valueKind: ValueKind.Frozen,
88
effectKind: Effect.Freeze,
89
+ noAlias: true,
90
+ transitiveMixedData: true,
91
},
92
],
93
[
@@ -91,6 +95,8 @@ const COMMON_HOOKS: Array<[string, Hook]> = [
95
{
96
valueKind: ValueKind.Frozen,
97
effectKind: Effect.Freeze,
98
+ noAlias: true,
99
+ transitiveMixedData: true,
100
},
101
],
102
[
@@ -98,6 +104,8 @@ const COMMON_HOOKS: Array<[string, Hook]> = [
104
{
105
valueKind: ValueKind.Frozen,
106
effectKind: Effect.Freeze,
107
+ noAlias: true,
108
+ transitiveMixedData: true,
109
},
110
],
111
[
@@ -105,6 +113,8 @@ const COMMON_HOOKS: Array<[string, Hook]> = [
113
{
114
valueKind: ValueKind.Frozen,
115
effectKind: Effect.Freeze,
116
+ noAlias: true,
117
+ transitiveMixedData: true,
118
},
119
],
120
];
@@ -199,7 +209,7 @@ export default function Editor() {
209
const { enqueueSnackbar } = useSnackbar();
210
const compilerOutput = useMemo(
211
() => compile(deferredStore.source),
202
- [deferredStore.source]
212
+ [deferredStore.source],
213
);
214
215
useMountEffect(() => {
@@ -213,7 +223,7 @@ export default function Editor() {
223
...createMessage(
224
"Bad URL - fell back to the default Playground.",
225
MessageLevel.Info,
216
- MessageSource.Playground
226
+ MessageSource.Playground,
227
),
228
});
229
mountStore = defaultStore;
compiler/packages/babel-plugin-react-forget/scripts/jest/makeTransform.ts
+7
-5
@@ -7,14 +7,17 @@
7
8
import { jsx } from "@babel/plugin-syntax-jsx";
9
import babelJest from "babel-jest";
10
-import { DEFAULT_ENVIRONMENT_CONFIG, compile } from "babel-plugin-react-forget";
10
+import { compile } from "babel-plugin-react-forget";
11
import { execSync } from "child_process";
12
13
import type { NodePath, Visitor } from "@babel/traverse";
14
import type { CallExpression, FunctionDeclaration } from "@babel/types";
15
import * as t from "@babel/types";
16
+import {
17
+ EnvironmentConfig,
18
+ validateEnvironmentConfig,
19
+} from "babel-plugin-react-forget";
20
import { basename } from "path";
17
-import { EnvironmentConfig } from "../../src/HIR/Environment";
21
22
/**
23
* -- IMPORTANT --
@@ -23,10 +26,9 @@ import { EnvironmentConfig } from "../../src/HIR/Environment";
26
* as our script files are currently not used for babel cache breaking!!
27
*/
28
const e2eTransformerCacheKey = 1;
26
-const forgetOptions: EnvironmentConfig = {
27
- ...DEFAULT_ENVIRONMENT_CONFIG,
29
+const forgetOptions: EnvironmentConfig = validateEnvironmentConfig({
30
enableAssumeHooksFollowRulesOfReact: true,
29
-};
31
+});
32
const debugMode = process.env["DEBUG_FORGET_COMPILER"] != null;
33
34
module.exports = (useForget: boolean) => {
compiler/packages/babel-plugin-react-forget/src/Entrypoint/Imports.ts
+2
-2
@@ -8,9 +8,9 @@
8
import { NodePath } from "@babel/core";
9
import * as t from "@babel/types";
10
import { CompilerError } from "../CompilerError";
11
-import { GeneratedSource } from "../HIR";
11
+import { ExternalFunction, GeneratedSource } from "../HIR";
12
import { getOrInsertDefault } from "../Utils/utils";
13
-import { ExternalFunction, PluginOptions } from "./Options";
13
+import { PluginOptions } from "./Options";
14
15
export function addImportsToProgram(
16
path: NodePath<t.Program>,
compiler/packages/babel-plugin-react-forget/src/Entrypoint/Options.ts
+2
-33
@@ -6,39 +6,8 @@
6
*/
7
8
import * as t from "@babel/types";
9
-import { z } from "zod";
10
-import { CompilerError, CompilerErrorDetailOptions } from "../CompilerError";
11
-import { PartialEnvironmentConfig } from "../HIR/Environment";
12
-
13
-import { fromZodError } from "zod-validation-error";
14
-
15
-export const ExternalFunctionSchema = z.object({
16
- // Source for the imported module that exports the `importSpecifierName` functions
17
- source: z.string(),
18
-
19
- // Unique name for the feature flag test condition, eg `isForgetEnabled_ProjectName`
20
- importSpecifierName: z.string(),
21
-});
22
-
23
-export function tryParseExternalFunction(
24
- maybeExternalFunction: any
25
-): ExternalFunction {
26
- const externalFunction = ExternalFunctionSchema.safeParse(
27
- maybeExternalFunction
28
- );
29
- if (externalFunction.success) {
30
- return externalFunction.data;
31
- }
32
-
33
- CompilerError.invalidConfig({
34
- reason: `${fromZodError(externalFunction.error)}`,
35
- description: "Update Forget config to fix the error",
36
- loc: null,
37
- suggestions: null,
38
- });
39
-}
40
-
41
-export type ExternalFunction = z.infer<typeof ExternalFunctionSchema>;
9
+import { CompilerErrorDetailOptions } from "../CompilerError";
10
+import { ExternalFunction, PartialEnvironmentConfig } from "../HIR/Environment";
11
12
export type PanicThresholdOptions =
13
// Any errors will panic the compiler by throwing an exception, which will
compiler/packages/babel-plugin-react-forget/src/Entrypoint/Program.ts
+6
-7
@@ -13,19 +13,18 @@ import {
13
CompilerSuggestionOperation,
14
ErrorSeverity,
15
} from "../CompilerError";
16
-import { validateEnvironmentConfig } from "../HIR/Environment";
16
+import {
17
+ ExternalFunction,
18
+ tryParseExternalFunction,
19
+ validateEnvironmentConfig,
20
+} from "../HIR/Environment";
21
import { CodegenFunction } from "../ReactiveScopes";
22
import { isComponentDeclaration } from "../Utils/ComponentDeclaration";
23
import { assertExhaustive } from "../Utils/utils";
24
import { insertGatedFunctionDeclaration } from "./Gating";
25
import { addImportsToProgram, updateUseMemoCacheImport } from "./Imports";
26
import { addInstrumentForget } from "./Instrumentation";
23
-import {
24
- ExternalFunction,
25
- PluginOptions,
26
- parsePluginOptions,
27
- tryParseExternalFunction,
28
-} from "./Options";
27
+import { PluginOptions, parsePluginOptions } from "./Options";
28
import { compileFn } from "./Pipeline";
29
30
export type CompilerPass = {
compiler/packages/babel-plugin-react-forget/src/HIR/Environment.ts
+91
-91
@@ -6,8 +6,9 @@
6
*/
7
8
import * as t from "@babel/types";
9
+import { z } from "zod";
10
+import { fromZodError } from "zod-validation-error";
11
import { CompilerError } from "../CompilerError";
10
-import { ExternalFunction } from "../Entrypoint/Options";
12
import { log } from "../Utils/logger";
13
import {
14
DEFAULT_GLOBALS,
@@ -36,26 +37,35 @@ import {
37
addHook,
38
} from "./ObjectShape";
39
39
-export type Hook = {
40
+export const ExternalFunctionSchema = z.object({
41
+ // Source for the imported module that exports the `importSpecifierName` functions
42
+ source: z.string(),
43
+
44
+ // Unique name for the feature flag test condition, eg `isForgetEnabled_ProjectName`
45
+ importSpecifierName: z.string(),
46
+});
47
+export type ExternalFunction = z.infer<typeof ExternalFunctionSchema>;
48
+
49
+const HookSchema = z.object({
50
/**
51
* The effect of arguments to this hook. Describes whether the hook may or may
52
* not mutate arguments, etc.
53
*/
44
- effectKind: Effect;
54
+ effectKind: z.nativeEnum(Effect),
55
56
/**
57
* The kind of value returned by the hook. Allows indicating that a hook returns
58
* a primitive or already-frozen value, which can allow more precise memoization
59
* of callers.
60
*/
51
- valueKind: ValueKind;
61
+ valueKind: z.nativeEnum(ValueKind),
62
63
/**
64
* Specifies whether hook arguments may be aliased by other arguments or by the
65
* return value of the function. Defaults to false. When enabled, this allows the
66
* compiler to avoid memoizing arguments.
67
*/
58
- noAlias?: boolean;
68
+ noAlias: z.boolean().default(false),
69
70
/**
71
* Specifies whether the hook returns data that is composed of:
@@ -74,8 +84,10 @@ export type Hook = {
84
* like `data.items.map(...)` since these builtin types have few built-in
85
* methods.
86
*/
77
- transitiveMixedData?: boolean;
78
-};
87
+ transitiveMixedData: z.boolean().default(false),
88
+});
89
+
90
+export type Hook = z.infer<typeof HookSchema>;
91
92
// TODO(mofeiZ): User defined global types (with corresponding shapes).
93
// User defined global types should have inline ObjectShapes instead of directly
@@ -85,52 +97,42 @@ export type Hook = {
97
// missing required shapes (BuiltInArray for [] and BuiltInObject for {})
98
// missing some recursive Object / Function shapeIds
99
88
-export type EnvironmentConfig = {
89
- customHooks: Map<string, Hook> | null;
100
+const EnvironmentConfigSchema = z.object({
101
+ customHooks: z.map(z.string(), HookSchema).nullish(),
102
103
// 🌲
92
- enableForest: boolean;
104
+ enableForest: z.boolean().default(false),
105
106
/**
107
* Enable memoization of JSX elements in addition to other types of values. When disabled,
108
* other types (objects, arrays, call expressions, etc) are memoized, but not known JSX
109
* values.
98
- *
99
- * Defaults to true
110
*/
101
- memoizeJsxElements: boolean;
111
+ memoizeJsxElements: z.boolean().default(true),
112
113
/**
114
* Enable validation of hooks to partially check that the component honors the rules of hooks.
115
* When disabled, the component is assumed to follow the rules (though the Babel plugin looks
116
* for suppressions of the lint rule).
107
- *
108
- * Defaults to false
117
*/
110
- validateHooksUsage: boolean;
118
+ validateHooksUsage: z.boolean().default(true),
119
120
/**
121
* Validate that ref values (`ref.current`) are not accessed during render.
114
- *
115
- * Defaults to false
122
*/
117
- validateRefAccessDuringRender: boolean;
123
+ validateRefAccessDuringRender: z.boolean().default(false),
124
125
/**
126
* Validate that mutable lambdas are not passed where a frozen value is expected, since mutable
127
* lambdas cannot be frozen. The only mutation allowed inside a frozen lambda is of ref values.
122
- *
123
- * Defaults to false
128
*/
125
- validateFrozenLambdas: boolean;
129
+ validateFrozenLambdas: z.boolean().default(false),
130
131
/**
132
* Validates that setState is not unconditionally called during render, as it can lead to
133
* infinite loops.
130
- *
131
- * Defaults to false
134
*/
133
- validateNoSetStateInRender: boolean;
135
+ validateNoSetStateInRender: z.boolean().default(false),
136
137
/**
138
* When enabled, the compiler assumes that hooks follow the Rules of React:
@@ -138,19 +140,15 @@ export type EnvironmentConfig = {
140
* any arguments to a hook are assumed frozen after calling the hook.
141
* - Hooks may memoize the result they return, thus the return value is
142
* assumed frozen.
141
-
142
- * Defaults to false
143
*/
144
- enableAssumeHooksFollowRulesOfReact: boolean;
144
+ enableAssumeHooksFollowRulesOfReact: z.boolean().default(false),
145
146
/**
147
* When enabled, removes *all* memoization from the function: this includes
148
* removing manually added useMemo/useCallback as well as not adding Forget's
149
* usual useMemoCache-based memoization.
150
- *
151
- * Defaults to false (ie, by default memoization is enabled)
150
*/
153
- disableAllMemoization: boolean;
151
+ disableAllMemoization: z.boolean().default(false),
152
153
/**
154
* Enables codegen mutability debugging. This emits a dev-mode only to log mutations
@@ -173,7 +171,7 @@ export type EnvironmentConfig = {
171
* }
172
* }
173
*/
176
- enableEmitFreeze: ExternalFunction | null;
174
+ enableEmitFreeze: ExternalFunctionSchema.nullish(),
175
176
/**
177
* Forget infers certain operations as "freezing" a value, such that those
@@ -208,21 +206,17 @@ export type EnvironmentConfig = {
206
* are transitively frozen when the function itself is frozen. So in this case,
207
* `y` and `z` would be frozen when `x` is frozen.
208
*/
211
- enableTransitivelyFreezeFunctionExpressions: boolean;
209
+ enableTransitivelyFreezeFunctionExpressions: z.boolean().default(false),
210
211
/**
212
* Enable merging consecutive scopes that invalidate together.
215
- *
216
- * Defaults to false.
213
*/
218
- enableMergeConsecutiveScopes: boolean;
214
+ enableMergeConsecutiveScopes: z.boolean().default(true),
215
216
/**
217
* Enable validation of mutable ranges
222
- *
223
- * Defaults to false
218
*/
225
- assertValidMutableRanges: boolean;
219
+ assertValidMutableRanges: z.boolean().default(false),
220
221
/**
222
* Instead of handling holey arrays, bail out with a TODO error.
@@ -244,7 +238,7 @@ export type EnvironmentConfig = {
238
* PR that changed the AST definition
239
* https://github.com/babel/babel/pull/10917/files#diff-19b555d2f3904c206af406540d9df200b1e16befedb83ff39ebfcbd876f7fa8aL52-R56
240
*/
247
- bailoutOnHoleyArrays: boolean;
241
+ bailoutOnHoleyArrays: z.boolean().default(false),
242
243
/**
244
* Enable emitting "change variables" which store the result of whether a particular
@@ -264,55 +258,44 @@ export type EnvironmentConfig = {
258
* if ($[0] !== input) ...
259
* ```
260
*/
267
- enableChangeVariableCodegen: boolean;
268
-};
269
-
270
-export const DEFAULT_ENVIRONMENT_CONFIG: Readonly<EnvironmentConfig> = {
271
- customHooks: null,
272
-
273
- memoizeJsxElements: true,
274
- validateHooksUsage: true,
275
- enableMergeConsecutiveScopes: true,
276
-
277
- assertValidMutableRanges: false,
278
- bailoutOnHoleyArrays: false,
279
- disableAllMemoization: false,
280
- enableAssumeHooksFollowRulesOfReact: false,
281
- enableEmitFreeze: null,
282
- enableForest: false,
283
- enableChangeVariableCodegen: false,
284
- enableTransitivelyFreezeFunctionExpressions: false,
285
-
286
- validateFrozenLambdas: false,
287
- validateNoSetStateInRender: false,
288
- validateRefAccessDuringRender: false,
289
-};
261
+ enableChangeVariableCodegen: z.boolean().default(false),
262
+});
263
+
264
+export type EnvironmentConfig = z.infer<typeof EnvironmentConfigSchema>;
265
266
export function parseConfigPragma(pragma: string): EnvironmentConfig {
292
- const config = { ...DEFAULT_ENVIRONMENT_CONFIG };
293
- for (const key of Object.keys(DEFAULT_ENVIRONMENT_CONFIG)) {
294
- if (!isEnvironmentConfigKey(key)) {
267
+ const maybeConfig: any = {};
268
+ // Get the defaults to programmatically check for boolean properties
269
+ const defaultConfig = EnvironmentConfigSchema.parse({});
270
+
271
+ for (const token of pragma.split(" ")) {
272
+ if (!token.startsWith("@")) {
273
continue;
274
}
297
- const value = config[key];
298
- if (typeof value !== "boolean") {
299
- // We only support setting boolean flags via pragma strings
275
+ const keyVal = token.slice(1);
276
+ let [key, val]: any = keyVal.split(":");
277
+ if (typeof defaultConfig[key as keyof EnvironmentConfig] !== "boolean") {
278
+ // skip parsing non-boolean properties
279
continue;
280
}
302
- if (pragma.includes(`@${key}:true`)) {
303
- config[key] = true as any;
304
- } else if (pragma.includes(`@${key}:false`)) {
305
- config[key] = false as any;
306
- } else if (pragma.includes(`@${key}`)) {
307
- config[key] = true as any;
281
+ if (val === undefined || val === "true") {
282
+ val = true;
283
+ } else {
284
+ val = false;
285
}
286
+ maybeConfig[key] = val;
287
}
288
311
- return config;
312
-}
313
-
314
-function isEnvironmentConfigKey(key: string): key is keyof EnvironmentConfig {
315
- return Object.prototype.hasOwnProperty.call(DEFAULT_ENVIRONMENT_CONFIG, key);
289
+ const config = EnvironmentConfigSchema.safeParse(maybeConfig);
290
+ if (config.success) {
291
+ return config.data;
292
+ }
293
+ CompilerError.invalidConfig({
294
+ reason: `${fromZodError(config.error)}`,
295
+ description: "Update Forget config to fix the error",
296
+ loc: null,
297
+ suggestions: null,
298
+ });
299
}
300
301
export type PartialEnvironmentConfig = Partial<EnvironmentConfig>;
@@ -466,16 +449,33 @@ function isHookName(name: string): boolean {
449
export function validateEnvironmentConfig(
450
partialConfig: PartialEnvironmentConfig | null
451
): EnvironmentConfig {
469
- const config: EnvironmentConfig = { ...DEFAULT_ENVIRONMENT_CONFIG };
470
- if (partialConfig != null) {
471
- for (const key of Object.keys(DEFAULT_ENVIRONMENT_CONFIG)) {
472
- if (!isEnvironmentConfigKey(key)) {
473
- continue;
474
- }
475
- if (Object.prototype.hasOwnProperty.call(partialConfig, key)) {
476
- config[key] = partialConfig[key] as any; // we know the key is present from hasOwnProperty
477
- }
478
- }
452
+ const config = EnvironmentConfigSchema.safeParse(partialConfig);
453
+ if (config.success) {
454
+ return config.data;
455
+ }
456
+
457
+ CompilerError.invalidConfig({
458
+ reason: `${fromZodError(config.error)}`,
459
+ description: "Update Forget config to fix the error",
460
+ loc: null,
461
+ suggestions: null,
462
+ });
463
+}
464
+
465
+export function tryParseExternalFunction(
466
+ maybeExternalFunction: any
467
+): ExternalFunction {
468
+ const externalFunction = ExternalFunctionSchema.safeParse(
469
+ maybeExternalFunction
470
+ );
471
+ if (externalFunction.success) {
472
+ return externalFunction.data;
473
}
480
- return config;
474
+
475
+ CompilerError.invalidConfig({
476
+ reason: `${fromZodError(externalFunction.error)}`,
477
+ description: "Update Forget config to fix the error",
478
+ loc: null,
479
+ suggestions: null,
480
+ });
481
}
compiler/packages/babel-plugin-react-forget/src/HIR/index.ts
+3
-1
@@ -11,10 +11,12 @@ export { assertValidMutableRanges } from "./AssertValidMutableRanges";
11
export { lower } from "./BuildHIR";
12
export { computeDominatorTree, computePostDominatorTree } from "./Dominator";
13
export {
14
- DEFAULT_ENVIRONMENT_CONFIG,
14
Environment,
15
+ EnvironmentConfig,
16
+ ExternalFunction,
17
Hook,
18
parseConfigPragma,
19
+ validateEnvironmentConfig,
20
} from "./Environment";
21
export * from "./HIR";
22
export {
compiler/packages/babel-plugin-react-forget/src/__tests__/parseConfigPragma-test.ts
+10
-8
@@ -5,21 +5,23 @@
5
* LICENSE file in the root directory of this source tree.
6
*/
7
8
-import { DEFAULT_ENVIRONMENT_CONFIG, parseConfigPragma } from "..";
8
+import { parseConfigPragma, validateEnvironmentConfig } from "..";
9
10
describe("parseConfigPragma()", () => {
11
it("parses flags in various forms", () => {
12
- const config = parseConfigPragma(
13
- "@enableForest @validateFrozenLambdas:true @memoizeJsxElements:false"
14
- );
12
+ const defaultConfig = validateEnvironmentConfig({});
13
+
14
// Validate defaults first to make sure that the parser is getting the value from the pragma,
15
// and not just missing it and getting the default value
17
- expect(DEFAULT_ENVIRONMENT_CONFIG.enableForest).toBe(false);
18
- expect(DEFAULT_ENVIRONMENT_CONFIG.validateFrozenLambdas).toBe(false);
19
- expect(DEFAULT_ENVIRONMENT_CONFIG.memoizeJsxElements).toBe(true);
16
+ expect(defaultConfig.enableForest).toBe(false);
17
+ expect(defaultConfig.validateFrozenLambdas).toBe(false);
18
+ expect(defaultConfig.memoizeJsxElements).toBe(true);
19
20
+ const config = parseConfigPragma(
21
+ "@enableForest @validateFrozenLambdas:true @memoizeJsxElements:false"
22
+ );
23
expect(config).toEqual({
22
- ...DEFAULT_ENVIRONMENT_CONFIG,
24
+ ...defaultConfig,
25
enableForest: true,
26
validateFrozenLambdas: true,
27
memoizeJsxElements: false,
compiler/packages/babel-plugin-react-forget/src/index.ts
+3
-1
@@ -20,13 +20,15 @@ export {
20
run,
21
} from "./Entrypoint";
22
export {
23
- DEFAULT_ENVIRONMENT_CONFIG,
23
Effect,
24
+ EnvironmentConfig,
25
+ ExternalFunction,
26
Hook,
27
SourceLocation,
28
ValueKind,
29
parseConfigPragma,
30
printHIR,
31
+ validateEnvironmentConfig,
32
} from "./HIR";
33
export { printReactiveFunction } from "./ReactiveScopes";
34