/** * 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. * * @flow strict * @format */ /** * This transforms component syntax (https://flow.org/en/docs/react/component-syntax/) * and hook syntax (https://flow.org/en/docs/react/hook-syntax/). * * It is expected that all transforms create valid ESTree AST output. If * the transform requires outputting Babel specific AST nodes then it * should live in `ConvertESTreeToBabel.js` */ 'use strict'; import type {ParserOptions} from '../ParserOptions'; import type { Program, ESNode, DeclareComponent, DeclareVariable, ComponentDeclaration, FunctionDeclaration, TypeAnnotation, ComponentParameter, SourceLocation, Position, ObjectPattern, Identifier, Range, RestElement, DestructuringObjectProperty, VariableDeclaration, ModuleDeclaration, DeclareHook, DeclareFunction, HookDeclaration, Statement, AssignmentPattern, BindingName, ObjectTypePropertySignature, ObjectTypeSpreadProperty, } from 'hermes-estree'; import {SimpleTransform} from '../transform/SimpleTransform'; import {shallowCloneNode} from '../transform/astNodeMutationHelpers'; import {SimpleTraverser} from '../traverse/SimpleTraverser'; import {createSyntaxError} from '../utils/createSyntaxError'; const nodeWith = SimpleTransform.nodeWith; // Rely on the mapper to fix up parent relationships. const EMPTY_PARENT: $FlowFixMe = null; function createDefaultPosition(): Position { return { line: 1, column: 0, }; } function mapDeclareComponent(node: DeclareComponent): DeclareVariable { return { type: 'DeclareVariable', id: nodeWith(node.id, { typeAnnotation: { type: 'TypeAnnotation', typeAnnotation: { type: 'AnyTypeAnnotation', loc: node.loc, range: node.range, parent: EMPTY_PARENT, }, loc: node.loc, range: node.range, parent: EMPTY_PARENT, }, }), kind: 'const', loc: node.loc, range: node.range, parent: node.parent, }; } function getComponentParameterName( paramName: ComponentParameter['name'], ): string { switch (paramName.type) { case 'Identifier': return paramName.name; case 'Literal': return paramName.value; default: throw createSyntaxError( paramName, `Unknown Component parameter name type of "${paramName.type}"`, ); } } function createPropsTypeAnnotation( propTypes: Array, spread: ?ObjectTypeSpreadProperty, loc: ?SourceLocation, range: ?Range, ): TypeAnnotation { // Create empty loc for type annotation nodes const createParamsTypeLoc = () => ({ loc: { start: loc?.start != null ? loc.start : createDefaultPosition(), end: loc?.end != null ? loc.end : createDefaultPosition(), }, range: range ?? [0, 0], parent: EMPTY_PARENT, }); // Optimize `{...Props}` -> `Props` if (spread != null && propTypes.length === 0) { return { type: 'TypeAnnotation', typeAnnotation: spread.argument, ...createParamsTypeLoc(), }; } const typeProperties: Array< ObjectTypePropertySignature | ObjectTypeSpreadProperty, > = [...propTypes]; if (spread != null) { // Spread needs to be the first type, as inline properties take precedence. typeProperties.unshift(spread); } const propTypeObj = { type: 'ObjectTypeAnnotation', callProperties: [], properties: typeProperties, indexers: [], internalSlots: [], exact: false, inexact: false, ...createParamsTypeLoc(), }; return { type: 'TypeAnnotation', typeAnnotation: { type: 'GenericTypeAnnotation', id: { type: 'Identifier', name: '$ReadOnly', optional: false, typeAnnotation: null, ...createParamsTypeLoc(), }, typeParameters: { type: 'TypeParameterInstantiation', params: [propTypeObj], ...createParamsTypeLoc(), }, ...createParamsTypeLoc(), }, ...createParamsTypeLoc(), }; } function mapComponentParameters( params: $ReadOnlyArray, options: ParserOptions, ): $ReadOnly<{ props: ?(ObjectPattern | Identifier), ref: ?(BindingName | AssignmentPattern), }> { if (params.length === 0) { return {props: null, ref: null}; } // Optimize `component Foo(...props: Props) {}` to `function Foo(props: Props) {} if ( params.length === 1 && params[0].type === 'RestElement' && params[0].argument.type === 'Identifier' ) { const restElementArgument = params[0].argument; return { props: restElementArgument, ref: null, }; } // Filter out any ref param and capture it's details when targeting React 18. // React 19+ treats ref as a regular prop for function components. let refParam = null; const paramsWithoutRef = (options.reactRuntimeTarget ?? '18') === '18' ? params.filter(param => { if ( param.type === 'ComponentParameter' && getComponentParameterName(param.name) === 'ref' ) { refParam = param; return false; } return true; }) : params; const [propTypes, spread] = paramsWithoutRef.reduce< [Array, ?ObjectTypeSpreadProperty], >( ([propTypes, spread], param) => { switch (param.type) { case 'RestElement': { if (spread != null) { throw createSyntaxError( param, `Invalid state, multiple rest elements found as Component Parameters`, ); } return [propTypes, mapComponentParameterRestElementType(param)]; } case 'ComponentParameter': { propTypes.push(mapComponentParameterType(param)); return [propTypes, spread]; } } }, [[], null], ); const propsProperties = paramsWithoutRef.flatMap(mapComponentParameter); let props = null; if (propsProperties.length === 0) { if (refParam == null) { throw new Error( 'StripComponentSyntax: Invalid state, ref should always be set at this point if props are empty', ); } const emptyParamsLoc = { start: refParam.loc.start, end: refParam.loc.start, }; const emptyParamsRange = [refParam.range[0], refParam.range[0]]; // no properties provided (must have had a single ref) props = { type: 'Identifier', name: '_$$empty_props_placeholder$$', optional: false, typeAnnotation: createPropsTypeAnnotation( [], null, emptyParamsLoc, emptyParamsRange, ), loc: emptyParamsLoc, range: emptyParamsRange, parent: EMPTY_PARENT, }; } else { const lastPropsProperty = propsProperties[propsProperties.length - 1]; props = { type: 'ObjectPattern', properties: propsProperties, typeAnnotation: createPropsTypeAnnotation( propTypes, spread, { start: lastPropsProperty.loc.end, end: lastPropsProperty.loc.end, }, [lastPropsProperty.range[1], lastPropsProperty.range[1]], ), loc: { start: propsProperties[0].loc.start, end: lastPropsProperty.loc.end, }, range: [propsProperties[0].range[0], lastPropsProperty.range[1]], parent: EMPTY_PARENT, }; } let ref = null; if (refParam != null) { ref = refParam.local; } return { props, ref, }; } function mapComponentParameterType( param: ComponentParameter, ): ObjectTypePropertySignature { const typeAnnotation = param.local.type === 'AssignmentPattern' ? param.local.left.typeAnnotation : param.local.typeAnnotation; const optional = param.local.type === 'AssignmentPattern' ? true : param.local.type === 'Identifier' ? param.local.optional : false; return { type: 'ObjectTypeProperty', key: shallowCloneNode(param.name), value: typeAnnotation?.typeAnnotation ?? { type: 'AnyTypeAnnotation', loc: param.local.loc, range: param.local.range, parent: EMPTY_PARENT, }, kind: 'init', optional, method: false, static: false, proto: false, variance: null, loc: param.local.loc, range: param.local.range, parent: EMPTY_PARENT, }; } function mapComponentParameterRestElementType( param: RestElement, ): ObjectTypeSpreadProperty { if ( param.argument.type !== 'Identifier' && param.argument.type !== 'ObjectPattern' ) { throw createSyntaxError( param, `Invalid ${param.argument.type} encountered in restParameter`, ); } return { type: 'ObjectTypeSpreadProperty', argument: param.argument.typeAnnotation?.typeAnnotation ?? { type: 'AnyTypeAnnotation', loc: param.loc, range: param.range, parent: EMPTY_PARENT, }, loc: param.loc, range: param.range, parent: EMPTY_PARENT, }; } function mapComponentParameter( param: ComponentParameter | RestElement, ): Array { switch (param.type) { case 'RestElement': { switch (param.argument.type) { case 'Identifier': { const a = nodeWith(param, { typeAnnotation: null, argument: nodeWith(param.argument, {typeAnnotation: null}), }); return [a]; } case 'ObjectPattern': { return param.argument.properties.map(property => { return nodeWith(property, { typeAnnotation: null, }); }); } default: { throw createSyntaxError( param, `Unhandled ${param.argument.type} encountered in restParameter`, ); } } } case 'ComponentParameter': { let value; if (param.local.type === 'AssignmentPattern') { value = nodeWith(param.local, { left: nodeWith(param.local.left, { typeAnnotation: null, optional: false, }), }); } else { value = nodeWith(param.local, { typeAnnotation: null, optional: false, }); } // Shorthand params if ( param.name.type === 'Identifier' && param.shorthand && (value.type === 'Identifier' || value.type === 'AssignmentPattern') ) { return [ { type: 'Property', key: param.name, kind: 'init', value, method: false, shorthand: true, computed: false, loc: param.loc, range: param.range, parent: EMPTY_PARENT, }, ]; } // Complex params return [ { type: 'Property', key: param.name, kind: 'init', value, method: false, shorthand: false, computed: false, loc: param.loc, range: param.range, parent: EMPTY_PARENT, }, ]; } default: { throw createSyntaxError( param, `Unknown Component parameter type of "${param.type}"`, ); } } } type ForwardRefDetails = { forwardRefStatement: VariableDeclaration, internalCompId: Identifier, forwardRefCompId: Identifier, }; function createForwardRefWrapper( originalComponent: ComponentDeclaration, ): ForwardRefDetails { const internalCompId = { type: 'Identifier', name: `${originalComponent.id.name}_withRef`, optional: false, typeAnnotation: null, loc: originalComponent.id.loc, range: originalComponent.id.range, parent: EMPTY_PARENT, }; return { forwardRefStatement: { type: 'VariableDeclaration', kind: 'const', declarations: [ { type: 'VariableDeclarator', id: shallowCloneNode(originalComponent.id), init: { type: 'CallExpression', callee: { type: 'MemberExpression', object: { type: 'Identifier', name: 'React', optional: false, typeAnnotation: null, loc: originalComponent.loc, range: originalComponent.range, parent: EMPTY_PARENT, }, property: { type: 'Identifier', name: 'forwardRef', optional: false, typeAnnotation: null, loc: originalComponent.loc, range: originalComponent.range, parent: EMPTY_PARENT, }, computed: false, optional: false, loc: originalComponent.loc, range: originalComponent.range, parent: EMPTY_PARENT, }, arguments: [shallowCloneNode(internalCompId)], typeArguments: null, optional: false, loc: originalComponent.loc, range: originalComponent.range, parent: EMPTY_PARENT, }, loc: originalComponent.loc, range: originalComponent.range, parent: EMPTY_PARENT, }, ], loc: originalComponent.loc, range: originalComponent.range, parent: originalComponent.parent, }, internalCompId: internalCompId, forwardRefCompId: originalComponent.id, }; } function mapComponentDeclaration( node: ComponentDeclaration, options: ParserOptions, ): { comp: FunctionDeclaration, forwardRefDetails: ?ForwardRefDetails, } { // Create empty loc for return type annotation nodes const createRendersTypeLoc = () => ({ loc: { start: node.body.loc.end, end: node.body.loc.end, }, range: [node.body.range[1], node.body.range[1]], parent: EMPTY_PARENT, }); const returnType: TypeAnnotation = { type: 'TypeAnnotation', typeAnnotation: { type: 'GenericTypeAnnotation', id: { type: 'QualifiedTypeIdentifier', qualification: { type: 'Identifier', name: 'React', optional: false, typeAnnotation: null, ...createRendersTypeLoc(), }, id: { type: 'Identifier', name: 'Node', optional: false, typeAnnotation: null, ...createRendersTypeLoc(), }, ...createRendersTypeLoc(), }, typeParameters: null, ...createRendersTypeLoc(), }, ...createRendersTypeLoc(), }; const {props, ref} = mapComponentParameters(node.params, options); let forwardRefDetails: ?ForwardRefDetails = null; if (ref != null) { forwardRefDetails = createForwardRefWrapper(node); } const comp = { type: 'FunctionDeclaration', id: forwardRefDetails != null ? shallowCloneNode(forwardRefDetails.internalCompId) : shallowCloneNode(node.id), __componentDeclaration: true, typeParameters: node.typeParameters, params: props == null ? [] : ref == null ? [props] : [props, ref], returnType, body: node.body, async: false, generator: false, predicate: null, loc: node.loc, range: node.range, parent: node.parent, }; return {comp, forwardRefDetails}; } function mapDeclareHook(node: DeclareHook): DeclareFunction { return { type: 'DeclareFunction', id: { type: 'Identifier', name: node.id.name, optional: node.id.optional, typeAnnotation: { type: 'TypeAnnotation', typeAnnotation: { type: 'FunctionTypeAnnotation', this: null, params: node.id.typeAnnotation.typeAnnotation.params, typeParameters: node.id.typeAnnotation.typeAnnotation.typeParameters, rest: node.id.typeAnnotation.typeAnnotation.rest, returnType: node.id.typeAnnotation.typeAnnotation.returnType, loc: node.id.typeAnnotation.typeAnnotation.loc, range: node.id.typeAnnotation.typeAnnotation.range, parent: node.id.typeAnnotation.typeAnnotation.parent, }, loc: node.id.typeAnnotation.loc, range: node.id.typeAnnotation.range, parent: node.id.typeAnnotation.parent, }, loc: node.id.loc, range: node.id.range, parent: node.id.parent, }, loc: node.loc, range: node.range, parent: node.parent, predicate: null, }; } function mapHookDeclaration(node: HookDeclaration): FunctionDeclaration { const comp = { type: 'FunctionDeclaration', id: node.id && shallowCloneNode(node.id), __hookDeclaration: true, typeParameters: node.typeParameters, params: node.params, returnType: node.returnType, body: node.body, async: false, generator: false, predicate: null, loc: node.loc, range: node.range, parent: node.parent, }; return comp; } /** * Scan a list of statements and return the position of the * first statement that contains a reference to a given component * or null of no references were found. */ function scanForFirstComponentReference( compName: string, bodyList: Array, ): ?number { for (let i = 0; i < bodyList.length; i++) { const bodyNode = bodyList[i]; let referencePos = null; SimpleTraverser.traverse(bodyNode, { enter(node: ESNode) { switch (node.type) { case 'Identifier': { if (node.name === compName) { // We found a reference, record it and stop. referencePos = i; throw SimpleTraverser.Break; } } } }, leave(_node: ESNode) {}, }); if (referencePos != null) { return referencePos; } } return null; } function mapComponentDeclarationIntoList( node: ComponentDeclaration, newBody: Array, options: ParserOptions, insertExport?: (Identifier | FunctionDeclaration) => ModuleDeclaration, ) { const {comp, forwardRefDetails} = mapComponentDeclaration(node, options); if (forwardRefDetails != null) { // Scan for references to our component. const referencePos = scanForFirstComponentReference( forwardRefDetails.forwardRefCompId.name, newBody, ); // If a reference is found insert the forwardRef statement before it (to simulate function hoisting). if (referencePos != null) { newBody.splice(referencePos, 0, forwardRefDetails.forwardRefStatement); } else { newBody.push(forwardRefDetails.forwardRefStatement); } newBody.push(comp); if (insertExport != null) { newBody.push(insertExport(forwardRefDetails.forwardRefCompId)); } return; } newBody.push(insertExport != null ? insertExport(comp) : comp); } function mapStatementList( stmts: $ReadOnlyArray, options: ParserOptions, ) { const newBody: Array = []; for (const node of stmts) { switch (node.type) { case 'ComponentDeclaration': { mapComponentDeclarationIntoList(node, newBody, options); break; } case 'HookDeclaration': { const decl = mapHookDeclaration(node); newBody.push(decl); break; } case 'ExportNamedDeclaration': { if (node.declaration?.type === 'ComponentDeclaration') { mapComponentDeclarationIntoList( node.declaration, newBody, options, componentOrRef => { switch (componentOrRef.type) { case 'FunctionDeclaration': { // No ref, so we can export the component directly. return nodeWith(node, {declaration: componentOrRef}); } case 'Identifier': { // If a ref is inserted, we should just export that id. return { type: 'ExportNamedDeclaration', declaration: null, specifiers: [ { type: 'ExportSpecifier', exported: shallowCloneNode(componentOrRef), local: shallowCloneNode(componentOrRef), loc: node.loc, range: node.range, parent: EMPTY_PARENT, }, ], exportKind: 'value', source: null, loc: node.loc, range: node.range, parent: node.parent, }; } } }, ); break; } if (node.declaration?.type === 'HookDeclaration') { const comp = mapHookDeclaration(node.declaration); newBody.push(nodeWith(node, {declaration: comp})); break; } newBody.push(node); break; } case 'ExportDefaultDeclaration': { if (node.declaration?.type === 'ComponentDeclaration') { mapComponentDeclarationIntoList( node.declaration, newBody, options, componentOrRef => nodeWith(node, {declaration: componentOrRef}), ); break; } if (node.declaration?.type === 'HookDeclaration') { const comp = mapHookDeclaration(node.declaration); newBody.push(nodeWith(node, {declaration: comp})); break; } newBody.push(node); break; } default: { newBody.push(node); } } } return newBody; } export function transformProgram( program: Program, options: ParserOptions, ): Program { return SimpleTransform.transformProgram(program, { transform(node: ESNode) { switch (node.type) { case 'DeclareComponent': { return mapDeclareComponent(node); } case 'DeclareHook': { return mapDeclareHook(node); } case 'Program': case 'BlockStatement': { return nodeWith(node, {body: mapStatementList(node.body, options)}); } case 'SwitchCase': { const consequent = mapStatementList(node.consequent, options); return nodeWith(node, { /* $FlowExpectedError[incompatible-call] We know `mapStatementList` will not return `ModuleDeclaration` nodes if it is not passed any */ consequent, }); } case 'ComponentDeclaration': { throw createSyntaxError( node, `Components must be defined at the top level of a module or within a ` + `BlockStatement, instead got parent of "${node.parent?.type}".`, ); } case 'HookDeclaration': { throw createSyntaxError( node, `Hooks must be defined at the top level of a module or within a ` + `BlockStatement, instead got parent of "${node.parent?.type}".`, ); } default: { return node; } } }, }); }