[compiler] Validate type configs for hooks/non-hooks
Alternative to #30868. The goal is to ensure that the types coming out of moduleTypeProvider are valid wrt to hook typing. If something is named like a hook, then it must be typed as a hook (or don't type it). ghstack-source-id: 3e8b5a0a7010d0c484bbb417fb258e76bf4e32bc Pull Request resolved: https://github.com/facebook/react/pull/30888
Joe Savona committed
Sep 5, 2024 at 15:24 UTC
d724ba96ff452570afed321c3a672f2a5f4bfe96
11 files changed
+284
-60
compiler/packages/babel-plugin-react-compiler/src/HIR/Environment.ts
+37
-2
@@ -33,6 +33,7 @@ import {
33
Type,
34
ValidatedIdentifier,
35
ValueKind,
36
+ getHookKindForType,
37
makeBlockId,
38
makeIdentifierId,
39
makeIdentifierName,
@@ -737,6 +738,8 @@ export class Environment {
738
this.#globals,
739
this.#shapes,
740
moduleConfig,
741
+ moduleName,
742
+ loc,
743
);
744
} else {
745
moduleType = null;
@@ -794,6 +797,21 @@ export class Environment {
797
binding.imported,
798
);
799
if (importedType != null) {
800
+ /*
801
+ * Check that hook-like export names are hook types, and non-hook names are non-hook types.
802
+ * The user-assigned alias isn't decidable by the type provider, so we ignore that for the check.
803
+ * Thus we allow `import {fooNonHook as useFoo} from ...` because the name and type both say
804
+ * that it's not a hook.
805
+ */
806
+ const expectHook = isHookName(binding.imported);
807
+ const isHook = getHookKindForType(this, importedType) != null;
808
+ if (expectHook !== isHook) {
809
+ CompilerError.throwInvalidConfig({
810
+ reason: `Invalid type configuration for module`,
811
+ description: `Expected type for \`import {${binding.imported}} from '${binding.module}'\` ${expectHook ? 'to be a hook' : 'not to be a hook'} based on the exported name`,
812
+ loc,
813
+ });
814
+ }
815
return importedType;
816
}
817
}
@@ -822,13 +840,30 @@ export class Environment {
840
} else {
841
const moduleType = this.#resolveModuleType(binding.module, loc);
842
if (moduleType !== null) {
843
+ let importedType: Type | null = null;
844
if (binding.kind === 'ImportDefault') {
845
const defaultType = this.getPropertyType(moduleType, 'default');
846
if (defaultType !== null) {
828
- return defaultType;
847
+ importedType = defaultType;
848
}
849
} else {
831
- return moduleType;
850
+ importedType = moduleType;
851
+ }
852
+ if (importedType !== null) {
853
+ /*
854
+ * Check that the hook-like modules are defined as types, and non hook-like modules are not typed as hooks.
855
+ * So `import Foo from 'useFoo'` is expected to be a hook based on the module name
856
+ */
857
+ const expectHook = isHookName(binding.module);
858
+ const isHook = getHookKindForType(this, importedType) != null;
859
+ if (expectHook !== isHook) {
860
+ CompilerError.throwInvalidConfig({
861
+ reason: `Invalid type configuration for module`,
862
+ description: `Expected type for \`import ... from '${binding.module}'\` ${expectHook ? 'to be a hook' : 'not to be a hook'} based on the module name`,
863
+ loc,
864
+ });
865
+ }
866
+ return importedType;
867
}
868
}
869
return isHookName(binding.name) ? this.#getCustomHookType() : null;
compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts
+43
-6
@@ -28,6 +28,8 @@ import {
28
import {BuiltInType, PolyType} from './Types';
29
import {TypeConfig} from './TypeSchema';
30
import {assertExhaustive} from '../Utils/utils';
31
+import {isHookName} from './Environment';
32
+import {CompilerError, SourceLocation} from '..';
33
34
/*
35
* This file exports types and defaults for JavaScript global objects.
@@ -535,6 +537,8 @@ export function installTypeConfig(
537
globals: GlobalRegistry,
538
shapes: ShapeRegistry,
539
typeConfig: TypeConfig,
540
+ moduleName: string,
541
+ loc: SourceLocation,
542
): Global {
543
switch (typeConfig.kind) {
544
case 'type': {
@@ -567,7 +571,13 @@ export function installTypeConfig(
571
positionalParams: typeConfig.positionalParams,
572
restParam: typeConfig.restParam,
573
calleeEffect: typeConfig.calleeEffect,
570
- returnType: installTypeConfig(globals, shapes, typeConfig.returnType),
574
+ returnType: installTypeConfig(
575
+ globals,
576
+ shapes,
577
+ typeConfig.returnType,
578
+ moduleName,
579
+ loc,
580
+ ),
581
returnValueKind: typeConfig.returnValueKind,
582
noAlias: typeConfig.noAlias === true,
583
mutableOnlyIfOperandsAreMutable:
@@ -580,7 +590,13 @@ export function installTypeConfig(
590
positionalParams: typeConfig.positionalParams ?? [],
591
restParam: typeConfig.restParam ?? Effect.Freeze,
592
calleeEffect: Effect.Read,
583
- returnType: installTypeConfig(globals, shapes, typeConfig.returnType),
593
+ returnType: installTypeConfig(
594
+ globals,
595
+ shapes,
596
+ typeConfig.returnType,
597
+ moduleName,
598
+ loc,
599
+ ),
600
returnValueKind: typeConfig.returnValueKind ?? ValueKind.Frozen,
601
noAlias: typeConfig.noAlias === true,
602
});
@@ -589,10 +605,31 @@ export function installTypeConfig(
605
return addObject(
606
shapes,
607
null,
592
- Object.entries(typeConfig.properties ?? {}).map(([key, value]) => [
593
- key,
594
- installTypeConfig(globals, shapes, value),
595
- ]),
608
+ Object.entries(typeConfig.properties ?? {}).map(([key, value]) => {
609
+ const type = installTypeConfig(
610
+ globals,
611
+ shapes,
612
+ value,
613
+ moduleName,
614
+ loc,
615
+ );
616
+ const expectHook = isHookName(key);
617
+ let isHook = false;
618
+ if (type.kind === 'Function' && type.shapeId !== null) {
619
+ const functionType = shapes.get(type.shapeId);
620
+ if (functionType?.functionType?.hookKind !== null) {
621
+ isHook = true;
622
+ }
623
+ }
624
+ if (expectHook !== isHook) {
625
+ CompilerError.throwInvalidConfig({
626
+ reason: `Invalid type configuration for module`,
627
+ description: `Expected type for object property '${key}' from module '${moduleName}' ${expectHook ? 'to be a hook' : 'not to be a hook'} based on the property name`,
628
+ loc,
629
+ });
630
+ }
631
+ return [key, type];
632
+ }),
633
);
634
}
635
default: {
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hook-name-not-typed-as-hook-namespace.expect.md
new
+25
@@ -0,0 +1,25 @@
1
+
2
+## Input
3
+
4
+```javascript
5
+import ReactCompilerTest from 'ReactCompilerTest';
6
+
7
+function Component() {
8
+ return ReactCompilerTest.useHookNotTypedAsHook();
9
+}
10
+
11
+```
12
+
13
+
14
+## Error
15
+
16
+```
17
+ 2 |
18
+ 3 | function Component() {
19
+> 4 | return ReactCompilerTest.useHookNotTypedAsHook();
20
+ | ^^^^^^^^^^^^^^^^^ InvalidConfig: Invalid type configuration for module. Expected type for object property 'useHookNotTypedAsHook' from module 'ReactCompilerTest' to be a hook based on the property name (4:4)
21
+ 5 | }
22
+ 6 |
23
+```
24
+
25
+
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hook-name-not-typed-as-hook-namespace.js
new
+5
@@ -0,0 +1,5 @@
1
+import ReactCompilerTest from 'ReactCompilerTest';
2
+
3
+function Component() {
4
+ return ReactCompilerTest.useHookNotTypedAsHook();
5
+}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hook-name-not-typed-as-hook.expect.md
new
+25
@@ -0,0 +1,25 @@
1
+
2
+## Input
3
+
4
+```javascript
5
+import {useHookNotTypedAsHook} from 'ReactCompilerTest';
6
+
7
+function Component() {
8
+ return useHookNotTypedAsHook();
9
+}
10
+
11
+```
12
+
13
+
14
+## Error
15
+
16
+```
17
+ 2 |
18
+ 3 | function Component() {
19
+> 4 | return useHookNotTypedAsHook();
20
+ | ^^^^^^^^^^^^^^^^^^^^^ InvalidConfig: Invalid type configuration for module. Expected type for object property 'useHookNotTypedAsHook' from module 'ReactCompilerTest' to be a hook based on the property name (4:4)
21
+ 5 | }
22
+ 6 |
23
+```
24
+
25
+
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hook-name-not-typed-as-hook.js
new
+5
@@ -0,0 +1,5 @@
1
+import {useHookNotTypedAsHook} from 'ReactCompilerTest';
2
+
3
+function Component() {
4
+ return useHookNotTypedAsHook();
5
+}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hooklike-module-default-not-hook.expect.md
new
+25
@@ -0,0 +1,25 @@
1
+
2
+## Input
3
+
4
+```javascript
5
+import foo from 'useDefaultExportNotTypedAsHook';
6
+
7
+function Component() {
8
+ return <div>{foo()}</div>;
9
+}
10
+
11
+```
12
+
13
+
14
+## Error
15
+
16
+```
17
+ 2 |
18
+ 3 | function Component() {
19
+> 4 | return <div>{foo()}</div>;
20
+ | ^^^ InvalidConfig: Invalid type configuration for module. Expected type for `import ... from 'useDefaultExportNotTypedAsHook'` to be a hook based on the module name (4:4)
21
+ 5 | }
22
+ 6 |
23
+```
24
+
25
+
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-hooklike-module-default-not-hook.js
new
+5
@@ -0,0 +1,5 @@
1
+import foo from 'useDefaultExportNotTypedAsHook';
2
+
3
+function Component() {
4
+ return <div>{foo()}</div>;
5
+}
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-nonhook-name-typed-as-hook.expect.md
new
+25
@@ -0,0 +1,25 @@
1
+
2
+## Input
3
+
4
+```javascript
5
+import {notAhookTypedAsHook} from 'ReactCompilerTest';
6
+
7
+function Component() {
8
+ return <div>{notAhookTypedAsHook()}</div>;
9
+}
10
+
11
+```
12
+
13
+
14
+## Error
15
+
16
+```
17
+ 2 |
18
+ 3 | function Component() {
19
+> 4 | return <div>{notAhookTypedAsHook()}</div>;
20
+ | ^^^^^^^^^^^^^^^^^^^ InvalidConfig: Invalid type configuration for module. Expected type for object property 'useHookNotTypedAsHook' from module 'ReactCompilerTest' to be a hook based on the property name (4:4)
21
+ 5 | }
22
+ 6 |
23
+```
24
+
25
+
\ No newline at end of file
compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-type-provider-nonhook-name-typed-as-hook.js
new
+5
@@ -0,0 +1,5 @@
1
+import {notAhookTypedAsHook} from 'ReactCompilerTest';
2
+
3
+function Component() {
4
+ return <div>{notAhookTypedAsHook()}</div>;
5
+}
compiler/packages/snap/src/sprout/shared-runtime-type-provider.ts
+84
-52
@@ -18,60 +18,92 @@ export function makeSharedRuntimeTypeProvider({
18
return function sharedRuntimeTypeProvider(
19
moduleName: string,
20
): TypeConfig | null {
21
- if (moduleName !== 'shared-runtime') {
22
- return null;
23
- }
24
- return {
25
- kind: 'object',
26
- properties: {
27
- default: {
28
- kind: 'function',
29
- calleeEffect: EffectEnum.Read,
30
- positionalParams: [],
31
- restParam: EffectEnum.Read,
32
- returnType: {kind: 'type', name: 'Primitive'},
33
- returnValueKind: ValueKindEnum.Primitive,
34
- },
35
- graphql: {
36
- kind: 'function',
37
- calleeEffect: EffectEnum.Read,
38
- positionalParams: [],
39
- restParam: EffectEnum.Read,
40
- returnType: {kind: 'type', name: 'Primitive'},
41
- returnValueKind: ValueKindEnum.Primitive,
42
- },
43
- typedArrayPush: {
44
- kind: 'function',
45
- calleeEffect: EffectEnum.Read,
46
- positionalParams: [EffectEnum.Store, EffectEnum.Capture],
47
- restParam: EffectEnum.Capture,
48
- returnType: {kind: 'type', name: 'Primitive'},
49
- returnValueKind: ValueKindEnum.Primitive,
21
+ if (moduleName === 'shared-runtime') {
22
+ return {
23
+ kind: 'object',
24
+ properties: {
25
+ default: {
26
+ kind: 'function',
27
+ calleeEffect: EffectEnum.Read,
28
+ positionalParams: [],
29
+ restParam: EffectEnum.Read,
30
+ returnType: {kind: 'type', name: 'Primitive'},
31
+ returnValueKind: ValueKindEnum.Primitive,
32
+ },
33
+ graphql: {
34
+ kind: 'function',
35
+ calleeEffect: EffectEnum.Read,
36
+ positionalParams: [],
37
+ restParam: EffectEnum.Read,
38
+ returnType: {kind: 'type', name: 'Primitive'},
39
+ returnValueKind: ValueKindEnum.Primitive,
40
+ },
41
+ typedArrayPush: {
42
+ kind: 'function',
43
+ calleeEffect: EffectEnum.Read,
44
+ positionalParams: [EffectEnum.Store, EffectEnum.Capture],
45
+ restParam: EffectEnum.Capture,
46
+ returnType: {kind: 'type', name: 'Primitive'},
47
+ returnValueKind: ValueKindEnum.Primitive,
48
+ },
49
+ typedLog: {
50
+ kind: 'function',
51
+ calleeEffect: EffectEnum.Read,
52
+ positionalParams: [],
53
+ restParam: EffectEnum.Read,
54
+ returnType: {kind: 'type', name: 'Primitive'},
55
+ returnValueKind: ValueKindEnum.Primitive,
56
+ },
57
+ useFreeze: {
58
+ kind: 'hook',
59
+ returnType: {kind: 'type', name: 'Any'},
60
+ },
61
+ useFragment: {
62
+ kind: 'hook',
63
+ returnType: {kind: 'type', name: 'MixedReadonly'},
64
+ noAlias: true,
65
+ },
66
+ useNoAlias: {
67
+ kind: 'hook',
68
+ returnType: {kind: 'type', name: 'Any'},
69
+ returnValueKind: ValueKindEnum.Mutable,
70
+ noAlias: true,
71
+ },
72
},
51
- typedLog: {
52
- kind: 'function',
53
- calleeEffect: EffectEnum.Read,
54
- positionalParams: [],
55
- restParam: EffectEnum.Read,
56
- returnType: {kind: 'type', name: 'Primitive'},
57
- returnValueKind: ValueKindEnum.Primitive,
73
+ };
74
+ } else if (moduleName === 'ReactCompilerTest') {
75
+ /**
76
+ * Fake module used for testing validation that type providers return hook
77
+ * types for hook names and non-hook types for non-hook names
78
+ */
79
+ return {
80
+ kind: 'object',
81
+ properties: {
82
+ useHookNotTypedAsHook: {
83
+ kind: 'type',
84
+ name: 'Any',
85
+ },
86
+ notAhookTypedAsHook: {
87
+ kind: 'hook',
88
+ returnType: {kind: 'type', name: 'Any'},
89
+ },
90
},
59
- useFreeze: {
60
- kind: 'hook',
61
- returnType: {kind: 'type', name: 'Any'},
91
+ };
92
+ } else if (moduleName === 'useDefaultExportNotTypedAsHook') {
93
+ /**
94
+ * Fake module used for testing validation that type providers return hook
95
+ * types for hook names and non-hook types for non-hook names
96
+ */
97
+ return {
98
+ kind: 'object',
99
+ properties: {
100
+ default: {
101
+ kind: 'type',
102
+ name: 'Any',
103
+ },
104
},
63
- useFragment: {
64
- kind: 'hook',
65
- returnType: {kind: 'type', name: 'MixedReadonly'},
66
- noAlias: true,
67
- },
68
- useNoAlias: {
69
- kind: 'hook',
70
- returnType: {kind: 'type', name: 'Any'},
71
- returnValueKind: ValueKindEnum.Mutable,
72
- noAlias: true,
73
- },
74
- },
75
- };
105
+ };
106
+ }
107
+ return null;
108
};
109
}