diff --git a/change/react-native-windows-2020-07-06-14-00-59-inlineviewconfigs.json b/change/react-native-windows-2020-07-06-14-00-59-inlineviewconfigs.json new file mode 100644 index 00000000000..1a96dbf4eae --- /dev/null +++ b/change/react-native-windows-2020-07-06-14-00-59-inlineviewconfigs.json @@ -0,0 +1,8 @@ +{ + "type": "none", + "comment": "Use babel-plugin-inline-view-configs to verify viewmanager props match expected", + "packageName": "react-native-windows", + "email": "acoates@microsoft.com", + "dependentChangeType": "none", + "date": "2020-07-06T21:00:59.234Z" +} diff --git a/packages/playground/babel.config.js b/packages/playground/babel.config.js index f842b77fcfb..346a83edfe7 100644 --- a/packages/playground/babel.config.js +++ b/packages/playground/babel.config.js @@ -1,3 +1,4 @@ module.exports = { presets: ['module:metro-react-native-babel-preset'], + plugins: [require('../../vnext/src/babel-plugin-inline-view-configs')], }; diff --git a/vnext/.gitignore b/vnext/.gitignore index a4ee101d4d5..b870b317ad0 100644 --- a/vnext/.gitignore +++ b/vnext/.gitignore @@ -94,5 +94,8 @@ temp /third-party /packages/ +# Required to run babel-plugin-inline-view-configs - will be removed once the babel-plugin-inline-view-configs package is published +/babel-plugin-inline-view-configs + # Copied from root as part of build /README.md \ No newline at end of file diff --git a/vnext/overrides.json b/vnext/overrides.json index 3f791e453be..ad366f1bf5d 100644 --- a/vnext/overrides.json +++ b/vnext/overrides.json @@ -1458,6 +1458,30 @@ "baseHash": "8158050a00a7bd38409d1ec152608751655c9bff", "issue": 4054 }, + { + "type": "patch", + "file": "src\\babel-plugin-inline-view-configs\\GenerateViewConfigJs.js", + "baseFile": "packages\\react-native-codegen\\src\\generators\\components\\GenerateViewConfigJs.js", + "baseVersion": "0.0.0-56cf99a96", + "baseHash": "415a69596caa402d0f510fe2a2eb2ca792169cab", + "issue": 5430 + }, + { + "type": "patch", + "file": "src\\babel-plugin-inline-view-configs\\index.js", + "baseFile": "packages\\babel-plugin-inline-view-configs\\index.js", + "baseVersion": "0.0.0-56cf99a96", + "baseHash": "181443fa916d64e9bd56f0c45f629941f90b15e4", + "issue": 5430 + }, + { + "type": "patch", + "file": "src\\babel-plugin-inline-view-configs\\package.json", + "baseFile": "packages\\babel-plugin-inline-view-configs\\package.json", + "baseVersion": "0.0.0-56cf99a96", + "baseHash": "8d37fac510a533c0badffacd9c3aa9c392fd58c6", + "issue": 5430 + }, { "type": "platform", "file": "src\\index.windows.ts" diff --git a/vnext/src/babel-plugin-inline-view-configs/GenerateViewConfigJs.js b/vnext/src/babel-plugin-inline-view-configs/GenerateViewConfigJs.js new file mode 100644 index 00000000000..7c46f492ea4 --- /dev/null +++ b/vnext/src/babel-plugin-inline-view-configs/GenerateViewConfigJs.js @@ -0,0 +1,432 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * //[Win: Removed flow types throughout whole file] + * @format + */ + +'use strict'; + +const j = require('jscodeshift'); + +// File path -> contents + +const template = ` +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +'use strict'; + +::_IMPORTS_:: + +::_COMPONENT_CONFIG_:: +`; + +// We use this to add to a set. Need to make sure we aren't importing +// this multiple times. +const UIMANAGER_IMPORT = 'const {UIManager} = require("react-native")'; + +function getReactDiffProcessValue(typeAnnotation) { + switch (typeAnnotation.type) { + case 'BooleanTypeAnnotation': + case 'StringTypeAnnotation': + case 'Int32TypeAnnotation': + case 'DoubleTypeAnnotation': + case 'FloatTypeAnnotation': + case 'ObjectTypeAnnotation': + case 'StringEnumTypeAnnotation': + case 'Int32EnumTypeAnnotation': + return j.literal(true); + case 'NativePrimitiveTypeAnnotation': + switch (typeAnnotation.name) { + case 'ColorPrimitive': + // [Win applied https://github.com/facebook/react-native/pull/29230 + return j.template + .expression`{ process: require('react-native/Libraries/StyleSheet/processColor') }`; + // Win] + case 'ImageSourcePrimitive': + // [Win applied https://github.com/facebook/react-native/pull/29230 + return j.template + .expression`{ process: require('react-native/Libraries/Image/resolveAssetSource') }`; + // Win] + case 'PointPrimitive': + // [Win applied https://github.com/facebook/react-native/pull/29230 + return j.template + .expression`{ diff: require('react-native/Libraries/Utilities/differ/pointsDiffer') }`; + // Win] + case 'EdgeInsetsPrimitive': + // [Win applied https://github.com/facebook/react-native/pull/29230 + return j.template + .expression`{ diff: require('react-native/Libraries/Utilities/differ/insetsDiffer') }`; + // Win] + default: + typeAnnotation.name; + throw new Error( + `Received unknown native typeAnnotation: "${typeAnnotation.name}"`, + ); + } + case 'ArrayTypeAnnotation': + if (typeAnnotation.elementType.type === 'NativePrimitiveTypeAnnotation') { + switch (typeAnnotation.elementType.name) { + case 'ColorPrimitive': + // [Win applied https://github.com/facebook/react-native/pull/29230 + return j.template + .expression`{ process: require('react-native/Libraries/StyleSheet/processColorArray') }`; + // Win] + case 'ImageSourcePrimitive': + return j.literal(true); + case 'PointPrimitive': + return j.literal(true); + default: + throw new Error( + `Received unknown array native typeAnnotation: "${ + typeAnnotation.elementType.name + }"`, + ); + } + } + return j.literal(true); + default: + typeAnnotation; + throw new Error( + `Received unknown typeAnnotation: "${typeAnnotation.type}"`, + ); + } +} + +const componentTemplate = ` +const ::_COMPONENT_NAME_::ViewConfig = VIEW_CONFIG; + +let nativeComponentName = '::_COMPONENT_NAME_WITH_COMPAT_SUPPORT_::'; +::_DEPRECATION_CHECK_:: +registerGeneratedViewConfig(nativeComponentName, ::_COMPONENT_NAME_::ViewConfig); + +export const __INTERNAL_VIEW_CONFIG = ::_COMPONENT_NAME_::ViewConfig; + +export default nativeComponentName; +`.trim(); + +const deprecatedComponentTemplate = ` +if (UIManager.getViewManagerConfig('::_COMPONENT_NAME_::')) { + nativeComponentName = '::_COMPONENT_NAME_::'; +} else if (UIManager.getViewManagerConfig('::_COMPONENT_NAME_DEPRECATED_::')){ + nativeComponentName = '::_COMPONENT_NAME_DEPRECATED_::'; +} else { + throw new Error('Failed to find native component for either "::_COMPONENT_NAME_::" or "::_COMPONENT_NAME_DEPRECATED_::"') +} +`.trim(); + +// Replicates the behavior of RCTNormalizeInputEventName in RCTEventDispatcher.m +function normalizeInputEventName(name) { + if (name.startsWith('on')) { + return name.replace(/^on/, 'top'); + } else if (!name.startsWith('top')) { + return `top${name[0].toUpperCase()}${name.slice(1)}`; + } + + return name; +} + +// Replicates the behavior of viewConfig in RCTComponentData.m +function getValidAttributesForEvents(events) { + return events.map(eventType => { + return j.property('init', j.identifier(eventType.name), j.literal(true)); + }); +} + +function generateBubblingEventInfo(event, nameOveride) { + return j.property( + 'init', + j.identifier(nameOveride || normalizeInputEventName(event.name)), + j.objectExpression([ + j.property( + 'init', + j.identifier('phasedRegistrationNames'), + j.objectExpression([ + j.property( + 'init', + j.identifier('captured'), + j.literal(`${event.name}Capture`), + ), + j.property('init', j.identifier('bubbled'), j.literal(event.name)), + ]), + ), + ]), + ); +} + +function generateDirectEventInfo(event, nameOveride) { + return j.property( + 'init', + j.identifier(nameOveride || normalizeInputEventName(event.name)), + j.objectExpression([ + j.property( + 'init', + j.identifier('registrationName'), + j.literal(event.name), + ), + ]), + ); +} + +function buildViewConfig(schema, componentName, component, imports) { + const componentProps = component.props; + const componentEvents = component.events; + + component.extendsProps.forEach(extendProps => { + switch (extendProps.type) { + case 'ReactNativeBuiltInType': + switch (extendProps.knownTypeName) { + case 'ReactNativeCoreViewProps': + // [Win: applied https://github.com/facebook/react-native/pull/29230 + imports.add( + "const registerGeneratedViewConfig = require('react-native/Libraries/Utilities/registerGeneratedViewConfig');", + ); + // Win] + + return; + default: + extendProps.knownTypeName; + throw new Error('Invalid knownTypeName'); + } + default: + extendProps.type; + throw new Error('Invalid extended type'); + } + }); + + const validAttributes = j.objectExpression([ + ...componentProps.map(schemaProp => { + return j.property( + 'init', + j.identifier(schemaProp.name), + getReactDiffProcessValue(schemaProp.typeAnnotation), + ); + }), + ...getValidAttributesForEvents(componentEvents), + ]); + + const bubblingEventNames = component.events + .filter(event => event.bubblingType === 'bubble') + .reduce((bubblingEvents, event) => { + // We add in the deprecated paper name so that it is in the view config. + // This means either the old event name or the new event name can fire + // and be sent to the listener until the old top level name is removed. + if (event.paperTopLevelNameDeprecated) { + bubblingEvents.push( + generateBubblingEventInfo(event, event.paperTopLevelNameDeprecated), + ); + } + bubblingEvents.push(generateBubblingEventInfo(event)); + return bubblingEvents; + }, []); + + const bubblingEvents = + bubblingEventNames.length > 0 + ? j.property( + 'init', + j.identifier('bubblingEventTypes'), + j.objectExpression(bubblingEventNames), + ) + : null; + + const directEventNames = component.events + .filter(event => event.bubblingType === 'direct') + .reduce((directEvents, event) => { + // We add in the deprecated paper name so that it is in the view config. + // This means either the old event name or the new event name can fire + // and be sent to the listener until the old top level name is removed. + if (event.paperTopLevelNameDeprecated) { + directEvents.push( + generateDirectEventInfo(event, event.paperTopLevelNameDeprecated), + ); + } + directEvents.push(generateDirectEventInfo(event)); + return directEvents; + }, []); + + const directEvents = + directEventNames.length > 0 + ? j.property( + 'init', + j.identifier('directEventTypes'), + j.objectExpression(directEventNames), + ) + : null; + + const properties = [ + j.property( + 'init', + j.identifier('uiViewClassName'), + j.literal(componentName), + ), + bubblingEvents, + directEvents, + j.property('init', j.identifier('validAttributes'), validAttributes), + ].filter(Boolean); + + return j.objectExpression(properties); +} + +function buildCommands(schema, componentName, component, imports) { + const commands = component.commands; + + if (commands.length === 0) { + return null; + } + + imports.add( + 'const {dispatchCommand} = require("react-native/Libraries/Renderer/shims/ReactNative");', + ); + + const properties = commands.map(command => { + const commandName = command.name; + const params = command.typeAnnotation.params; + + const commandNameLiteral = j.literal(commandName); + const commandNameIdentifier = j.identifier(commandName); + const arrayParams = j.arrayExpression( + params.map(param => { + return j.identifier(param.name); + }), + ); + + const expression = j.template + .expression`dispatchCommand(ref, ${commandNameLiteral}, ${arrayParams})`; + + const functionParams = params.map(param => { + return j.identifier(param.name); + }); + + const property = j.property( + 'init', + commandNameIdentifier, + j.functionExpression( + null, + [j.identifier('ref'), ...functionParams], + j.blockStatement([j.expressionStatement(expression)]), + ), + ); + property.method = true; + + return property; + }); + + return j.exportNamedDeclaration( + j.variableDeclaration('const', [ + j.variableDeclarator( + j.identifier('Commands'), + j.objectExpression(properties), + ), + ]), + ); +} + +module.exports = { + generate(libraryName, schema) { + try { + const fileName = `${libraryName}NativeViewConfig.js`; + const imports = new Set(); + + const moduleResults = Object.keys(schema.modules) + .map(moduleName => { + const components = schema.modules[moduleName].components; + // No components in this module + if (components == null) { + return null; + } + + return Object.keys(components) + .map(componentName => { + const component = components[componentName]; + + const paperComponentName = component.paperComponentName + ? component.paperComponentName + : componentName; + + if (component.paperComponentNameDeprecated) { + imports.add(UIMANAGER_IMPORT); + } + + const deprecatedCheckBlock = component.paperComponentNameDeprecated + ? deprecatedComponentTemplate + .replace(/::_COMPONENT_NAME_::/g, componentName) + .replace( + /::_COMPONENT_NAME_DEPRECATED_::/g, + component.paperComponentNameDeprecated || '', + ) + : ''; + + const replacedTemplate = componentTemplate + .replace(/::_COMPONENT_NAME_::/g, componentName) + .replace( + /::_COMPONENT_NAME_WITH_COMPAT_SUPPORT_::/g, + paperComponentName, + ) + .replace(/::_DEPRECATION_CHECK_::/, deprecatedCheckBlock); + + const replacedSourceRoot = j.withParser('flow')(replacedTemplate); + + replacedSourceRoot + .find(j.Identifier, { + name: 'VIEW_CONFIG', + }) + .replaceWith( + buildViewConfig( + schema, + paperComponentName, + component, + imports, + ), + ); + + const commands = buildCommands( + schema, + paperComponentName, + component, + imports, + ); + if (commands) { + replacedSourceRoot + .find(j.ExportDefaultDeclaration) + .insertAfter(j(commands).toSource()); + } + + const replacedSource = replacedSourceRoot.toSource({ + quote: 'single', + trailingComma: true, + }); + + return replacedSource; + }) + .join('\n\n'); + }) + .filter(Boolean) + .join('\n\n'); + + const replacedTemplate = template + .replace(/::_COMPONENT_CONFIG_::/g, moduleResults) + .replace( + '::_IMPORTS_::', + Array.from(imports) + .sort() + .join('\n'), + ); + + return new Map([[fileName, replacedTemplate]]); + } catch (error) { + console.error(`\nError parsing schema for ${libraryName}\n`); + console.error(JSON.stringify(schema)); + throw error; + } + }, +}; diff --git a/vnext/src/babel-plugin-inline-view-configs/index.js b/vnext/src/babel-plugin-inline-view-configs/index.js new file mode 100644 index 00000000000..7249aa4136d --- /dev/null +++ b/vnext/src/babel-plugin-inline-view-configs/index.js @@ -0,0 +1,163 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +'use strict'; + +// [Win - changes to use local react-native-codegen from tscodegen, which has the flow types removed +const { + parseString, +} = require('../../../node_modules/react-native-tscodegen/lib/rncodegen/src/parsers/flow'); +const RNCodegen = { + generateViewConfig: ({libraryName, schema}) => { + // schemaValidator.validate(schema); + + const result = require('./GenerateViewConfigJs') + .generate(libraryName, schema) + .values() + .next(); + + if (typeof result.value !== 'string') { + throw new Error(`Failed to generate view config for ${libraryName}`); + } + + return result.value; + }, +}; +// Win] +const path = require('path'); + +function generateViewConfig(filename, code) { + const schema = parseString(code); + + const libraryName = path + .basename(filename) + .replace(/NativeComponent\.js$/, ''); + return RNCodegen.generateViewConfig({ + schema, + libraryName, + }); +} + +function isCodegenDeclaration(declaration) { + if (!declaration) { + return false; + } + + if ( + declaration.left && + declaration.left.left && + declaration.left.left.name === 'codegenNativeComponent' + ) { + return true; + } else if ( + declaration.callee && + declaration.callee.name && + declaration.callee.name === 'codegenNativeComponent' + ) { + return true; + } else if ( + declaration.type === 'TypeCastExpression' && + declaration.expression && + declaration.expression.callee && + declaration.expression.callee.name && + declaration.expression.callee.name === 'codegenNativeComponent' + ) { + return true; + } + + return false; +} + +module.exports = function(context) { + return { + pre(state) { + this.code = state.code; + this.filename = state.opts.filename; + this.defaultExport = null; + this.commandsExport = null; + this.codeInserted = false; + }, + visitor: { + ExportNamedDeclaration(nodePath) { + if (this.codeInserted) { + return; + } + + if ( + nodePath.node.declaration && + nodePath.node.declaration.declarations && + nodePath.node.declaration.declarations[0] + ) { + const firstDeclaration = nodePath.node.declaration.declarations[0]; + + if (firstDeclaration.type === 'VariableDeclarator') { + if ( + firstDeclaration.init.type === 'CallExpression' && + firstDeclaration.init.callee.type === 'Identifier' && + firstDeclaration.init.callee.name === 'codegenNativeCommands' + ) { + if ( + firstDeclaration.id.type === 'Identifier' && + firstDeclaration.id.name !== 'Commands' + ) { + throw new Error( + "Native commands must be exported with the name 'Commands'", + ); + } + this.commandsExport = nodePath; + return; + } else { + if (firstDeclaration.id.name === 'Commands') { + throw new Error( + "'Commands' is a reserved export and may only be used to export the result of codegenNativeCommands.", + ); + } + } + } + } else if ( + nodePath.node.specifiers && + nodePath.node.specifiers.length > 0 + ) { + nodePath.node.specifiers.forEach(specifier => { + if ( + specifier.type === 'ExportSpecifier' && + specifier.local.type === 'Identifier' && + specifier.local.name === 'Commands' + ) { + throw new Error( + "'Commands' is a reserved export and may only be used to export the result of codegenNativeCommands.", + ); + } + }); + } + }, + ExportDefaultDeclaration(nodePath, state) { + if (isCodegenDeclaration(nodePath.node.declaration)) { + this.defaultExport = nodePath; + } + }, + Program: { + exit() { + if (this.defaultExport) { + const viewConfig = generateViewConfig(this.filename, this.code); + this.defaultExport.replaceWithMultiple( + // [Win adding filename param see: https://github.com/facebook/react-native/pull/29230 + context.parse(viewConfig, {filename: this.filename}).program.body, + // Win] + ); + if (this.commandsExport != null) { + this.commandsExport.remove(); + } + this.codeInserted = true; + } + }, + }, + }, + }; +}; diff --git a/vnext/src/babel-plugin-inline-view-configs/package.json b/vnext/src/babel-plugin-inline-view-configs/package.json new file mode 100644 index 00000000000..488aaec9dbf --- /dev/null +++ b/vnext/src/babel-plugin-inline-view-configs/package.json @@ -0,0 +1,17 @@ +{ + "version": "0.0.5", + "name": "babel-plugin-inline-view-configs", + "private": true, + "description": "Babel plugin to inline view configs for React Native", + "repository": { + "type": "git", + "url": "git@github.com:facebook/react-native.git" + }, + "dependencies": { + "react-native-codegen": "*" + }, + "devDependencies": { + "@babel/core": "^7.0.0" + }, + "license": "MIT" +}