@samitouri / QOS-React / commits / d724ba96ff

[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 }