diff --git a/Libraries/Utilities/__tests__/verifyComponentAttributeEquivalence-test.js b/Libraries/Utilities/__tests__/verifyComponentAttributeEquivalence-test.js new file mode 100644 index 00000000000..fade9976f66 --- /dev/null +++ b/Libraries/Utilities/__tests__/verifyComponentAttributeEquivalence-test.js @@ -0,0 +1,99 @@ +/** + * 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 + * @emails oncall+react_native + */ + +'use strict'; + +const getNativeComponentAttributes = require('getNativeComponentAttributes'); +const verifyComponentAttributeEquivalence = require('verifyComponentAttributeEquivalence'); + +jest.mock('getNativeComponentAttributes', () => () => ({ + NativeProps: { + value: 'BOOL', + }, + bubblingEventTypes: { + topChange: { + phasedRegistrationNames: { + bubbled: 'onChange', + captured: 'onChangeCapture', + }, + }, + }, + directEventTypes: { + topAccessibilityAction: { + registrationName: 'onAccessibilityAction', + }, + }, + validAttributes: { + borderColor: true, + style: { + borderColor: true, + transform: 'CATransform3D', + }, + transform: 'CATransform3D', + }, +})); + +beforeEach(() => { + global.__DEV__ = true; + console.error = jest.fn(); + jest.resetModules(); +}); + +describe('verifyComponentAttributeEquivalence', () => { + test('should not verify in prod', () => { + global.__DEV__ = false; + verifyComponentAttributeEquivalence('TestComponent', {}); + }); + + test('should not error with native config that is a subset of the given config', () => { + const configWithAdditionalProperties = getNativeComponentAttributes( + 'TestComponent', + ); + + configWithAdditionalProperties.bubblingEventTypes.topFocus = { + phasedRegistrationNames: { + bubbled: 'onFocus', + captured: 'onFocusCapture', + }, + }; + + configWithAdditionalProperties.directEventTypes.topSlidingComplete = { + registrationName: 'onSlidingComplete', + }; + + configWithAdditionalProperties.validAttributes.active = true; + + verifyComponentAttributeEquivalence( + 'TestComponent', + configWithAdditionalProperties, + ); + verifyComponentAttributeEquivalence( + 'TestComponent', + configWithAdditionalProperties, + ); + + expect(console.error).not.toBeCalled(); + }); + + test('should error if given config is missing native config properties', () => { + verifyComponentAttributeEquivalence('TestComponent', {}); + + expect(console.error).toBeCalledTimes(3); + expect(console.error).toBeCalledWith( + 'TestComponent generated view config for directEventTypes does not match native, missing: topAccessibilityAction', + ); + expect(console.error).toBeCalledWith( + 'TestComponent generated view config for bubblingEventTypes does not match native, missing: topChange', + ); + expect(console.error).toBeCalledWith( + 'TestComponent generated view config for validAttributes does not match native, missing: borderColor style', + ); + }); +}); diff --git a/Libraries/Utilities/verifyComponentAttributeEquivalence.js b/Libraries/Utilities/verifyComponentAttributeEquivalence.js new file mode 100644 index 00000000000..f950b6cf13e --- /dev/null +++ b/Libraries/Utilities/verifyComponentAttributeEquivalence.js @@ -0,0 +1,104 @@ +/** + * 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 + * @flow + */ + +'use strict'; + +const getNativeComponentAttributes = require('getNativeComponentAttributes'); + +import type {ReactNativeBaseComponentViewConfig} from 'ReactNativeTypes'; + +const IGNORED_KEYS = ['transform']; +/** + * The purpose of this function is to validate that the view config that + * native exposes for a given view manager is the same as the view config + * that is specified for that view manager in JS. + * + * In order to improve perf, we want to avoid calling into native to get + * the view config when each view manager is used. To do this, we are moving + * the configs to JS. In the future we will use these JS based view configs + * to codegen the view manager on native to ensure they stay in sync without + * this runtime check. + * + * If this function fails, that likely means a change was made to the native + * view manager without updating the JS config as well. Ideally you can make + * that direct change to the JS config. If you don't know what the differences + * are, the best approach I've found is to create a view that prints + * the return value of getNativeComponentAttributes, and then copying that + * text and pasting it back into JS: + * {JSON.stringify(getNativeComponentAttributes('RCTView'))} + * + * This is meant to be a stopgap until the time comes when we only have a + * single source of truth. I wonder if this message will still be here two + * years from now... + */ +function verifyComponentAttributeEquivalence( + componentName: string, + config: ReactNativeBaseComponentViewConfig<>, +) { + if (__DEV__) { + const nativeAttributes = getNativeComponentAttributes(componentName); + + ['validAttributes', 'bubblingEventTypes', 'directEventTypes'].forEach( + prop => { + const diffKeys = Object.keys( + lefthandObjectDiff(nativeAttributes[prop], config[prop]), + ); + + if (diffKeys.length) { + console.error( + `${componentName} generated view config for ${prop} does not match native, missing: ${diffKeys.join( + ' ', + )}`, + ); + } + }, + ); + } +} + +function lefthandObjectDiff(leftObj: Object, rightObj: Object) { + const differentKeys = {}; + + function compare(leftItem, rightItem, key) { + if (typeof leftItem !== typeof rightItem && leftItem != null) { + differentKeys[key] = rightItem; + return; + } + + if (typeof leftItem === 'object') { + const objDiff = lefthandObjectDiff(leftItem, rightItem); + if (Object.keys(objDiff).length > 1) { + differentKeys[key] = objDiff; + } + return; + } + + if (leftItem !== rightItem) { + differentKeys[key] = rightItem; + return; + } + } + + for (const key in leftObj) { + if (IGNORED_KEYS.includes(key)) { + continue; + } + + if (!rightObj) { + differentKeys[key] = {}; + } else if (leftObj.hasOwnProperty(key)) { + compare(leftObj[key], rightObj[key], key); + } + } + + return differentKeys; +} + +module.exports = verifyComponentAttributeEquivalence; diff --git a/packages/react-native-codegen/src/generators/GenerateViewConfigJs.js b/packages/react-native-codegen/src/generators/GenerateViewConfigJs.js index f7fdb72c843..27763f726cf 100644 --- a/packages/react-native-codegen/src/generators/GenerateViewConfigJs.js +++ b/packages/react-native-codegen/src/generators/GenerateViewConfigJs.js @@ -68,6 +68,8 @@ function getReactDiffProcessValue(prop) { const componentTemplate = ` const ::_COMPONENT_NAME_::ViewConfig = VIEW_CONFIG; +verifyComponentAttributeEquivalence('::_COMPONENT_NAME_::', ::_COMPONENT_NAME_::ViewConfig); + ReactNativeViewConfigRegistry.register( '::_COMPONENT_NAME_::', () => ::_COMPONENT_NAME_::ViewConfig, @@ -247,6 +249,9 @@ module.exports = { imports.add( "const ReactNativeViewConfigRegistry = require('ReactNativeViewConfigRegistry');", ); + imports.add( + "const verifyComponentAttributeEquivalence = require('verifyComponentAttributeEquivalence');", + ); const moduleResults = Object.keys(schema.modules) .map(moduleName => { diff --git a/packages/react-native-codegen/src/generators/__tests__/__snapshots__/GenerateViewConfigJs-test.js.snap b/packages/react-native-codegen/src/generators/__tests__/__snapshots__/GenerateViewConfigJs-test.js.snap index b853d6c7ca4..9508f8f515a 100644 --- a/packages/react-native-codegen/src/generators/__tests__/__snapshots__/GenerateViewConfigJs-test.js.snap +++ b/packages/react-native-codegen/src/generators/__tests__/__snapshots__/GenerateViewConfigJs-test.js.snap @@ -17,6 +17,7 @@ Map { const ReactNativeViewConfigRegistry = require('ReactNativeViewConfigRegistry'); const ReactNativeViewViewConfig = require('ReactNativeViewViewConfig'); +const verifyComponentAttributeEquivalence = require('verifyComponentAttributeEquivalence'); const ArrayPropsNativeComponentViewConfig = { uiViewClassName: 'ArrayPropsNativeComponent', @@ -42,6 +43,8 @@ const ArrayPropsNativeComponentViewConfig = { } }; +verifyComponentAttributeEquivalence('ArrayPropsNativeComponent', ArrayPropsNativeComponentViewConfig); + ReactNativeViewConfigRegistry.register( 'ArrayPropsNativeComponent', () => ArrayPropsNativeComponentViewConfig, @@ -69,6 +72,7 @@ Map { const ReactNativeViewConfigRegistry = require('ReactNativeViewConfigRegistry'); const ReactNativeViewViewConfig = require('ReactNativeViewViewConfig'); +const verifyComponentAttributeEquivalence = require('verifyComponentAttributeEquivalence'); const BooleanPropNativeComponentViewConfig = { uiViewClassName: 'BooleanPropNativeComponent', @@ -88,6 +92,8 @@ const BooleanPropNativeComponentViewConfig = { } }; +verifyComponentAttributeEquivalence('BooleanPropNativeComponent', BooleanPropNativeComponentViewConfig); + ReactNativeViewConfigRegistry.register( 'BooleanPropNativeComponent', () => BooleanPropNativeComponentViewConfig, @@ -115,6 +121,7 @@ Map { const ReactNativeViewConfigRegistry = require('ReactNativeViewConfigRegistry'); const ReactNativeViewViewConfig = require('ReactNativeViewViewConfig'); +const verifyComponentAttributeEquivalence = require('verifyComponentAttributeEquivalence'); const ColorPropNativeComponentViewConfig = { uiViewClassName: 'ColorPropNativeComponent', @@ -134,6 +141,8 @@ const ColorPropNativeComponentViewConfig = { } }; +verifyComponentAttributeEquivalence('ColorPropNativeComponent', ColorPropNativeComponentViewConfig); + ReactNativeViewConfigRegistry.register( 'ColorPropNativeComponent', () => ColorPropNativeComponentViewConfig, @@ -161,6 +170,7 @@ Map { const ReactNativeViewConfigRegistry = require('ReactNativeViewConfigRegistry'); const ReactNativeViewViewConfig = require('ReactNativeViewViewConfig'); +const verifyComponentAttributeEquivalence = require('verifyComponentAttributeEquivalence'); const EnumPropsNativeComponentViewConfig = { uiViewClassName: 'EnumPropsNativeComponent', @@ -180,6 +190,8 @@ const EnumPropsNativeComponentViewConfig = { } }; +verifyComponentAttributeEquivalence('EnumPropsNativeComponent', EnumPropsNativeComponentViewConfig); + ReactNativeViewConfigRegistry.register( 'EnumPropsNativeComponent', () => EnumPropsNativeComponentViewConfig, @@ -207,6 +219,7 @@ Map { const ReactNativeViewConfigRegistry = require('ReactNativeViewConfigRegistry'); const ReactNativeViewViewConfig = require('ReactNativeViewViewConfig'); +const verifyComponentAttributeEquivalence = require('verifyComponentAttributeEquivalence'); const EventsNestedObjectNativeComponentViewConfig = { uiViewClassName: 'EventsNestedObjectNativeComponent', @@ -234,6 +247,8 @@ const EventsNestedObjectNativeComponentViewConfig = { } }; +verifyComponentAttributeEquivalence('EventsNestedObjectNativeComponent', EventsNestedObjectNativeComponentViewConfig); + ReactNativeViewConfigRegistry.register( 'EventsNestedObjectNativeComponent', () => EventsNestedObjectNativeComponentViewConfig, @@ -261,6 +276,7 @@ Map { const ReactNativeViewConfigRegistry = require('ReactNativeViewConfigRegistry'); const ReactNativeViewViewConfig = require('ReactNativeViewViewConfig'); +const verifyComponentAttributeEquivalence = require('verifyComponentAttributeEquivalence'); const EventsNativeComponentViewConfig = { uiViewClassName: 'EventsNativeComponent', @@ -293,6 +309,8 @@ const EventsNativeComponentViewConfig = { } }; +verifyComponentAttributeEquivalence('EventsNativeComponent', EventsNativeComponentViewConfig); + ReactNativeViewConfigRegistry.register( 'EventsNativeComponent', () => EventsNativeComponentViewConfig, @@ -320,6 +338,7 @@ Map { const ReactNativeViewConfigRegistry = require('ReactNativeViewConfigRegistry'); const ReactNativeViewViewConfig = require('ReactNativeViewViewConfig'); +const verifyComponentAttributeEquivalence = require('verifyComponentAttributeEquivalence'); const FloatPropNativeComponentViewConfig = { uiViewClassName: 'FloatPropNativeComponent', @@ -344,6 +363,8 @@ const FloatPropNativeComponentViewConfig = { } }; +verifyComponentAttributeEquivalence('FloatPropNativeComponent', FloatPropNativeComponentViewConfig); + ReactNativeViewConfigRegistry.register( 'FloatPropNativeComponent', () => FloatPropNativeComponentViewConfig, @@ -371,6 +392,7 @@ Map { const ReactNativeViewConfigRegistry = require('ReactNativeViewConfigRegistry'); const ReactNativeViewViewConfig = require('ReactNativeViewViewConfig'); +const verifyComponentAttributeEquivalence = require('verifyComponentAttributeEquivalence'); const ImagePropNativeComponentViewConfig = { uiViewClassName: 'ImagePropNativeComponent', @@ -390,6 +412,8 @@ const ImagePropNativeComponentViewConfig = { } }; +verifyComponentAttributeEquivalence('ImagePropNativeComponent', ImagePropNativeComponentViewConfig); + ReactNativeViewConfigRegistry.register( 'ImagePropNativeComponent', () => ImagePropNativeComponentViewConfig, @@ -417,6 +441,7 @@ Map { const ReactNativeViewConfigRegistry = require('ReactNativeViewConfigRegistry'); const ReactNativeViewViewConfig = require('ReactNativeViewViewConfig'); +const verifyComponentAttributeEquivalence = require('verifyComponentAttributeEquivalence'); const IntegerPropNativeComponentViewConfig = { uiViewClassName: 'IntegerPropNativeComponent', @@ -438,6 +463,8 @@ const IntegerPropNativeComponentViewConfig = { } }; +verifyComponentAttributeEquivalence('IntegerPropNativeComponent', IntegerPropNativeComponentViewConfig); + ReactNativeViewConfigRegistry.register( 'IntegerPropNativeComponent', () => IntegerPropNativeComponentViewConfig, @@ -465,6 +492,7 @@ Map { const ReactNativeViewConfigRegistry = require('ReactNativeViewConfigRegistry'); const ReactNativeViewViewConfig = require('ReactNativeViewViewConfig'); +const verifyComponentAttributeEquivalence = require('verifyComponentAttributeEquivalence'); const InterfaceOnlyComponentViewConfig = { uiViewClassName: 'InterfaceOnlyComponent', @@ -492,6 +520,8 @@ const InterfaceOnlyComponentViewConfig = { } }; +verifyComponentAttributeEquivalence('InterfaceOnlyComponent', InterfaceOnlyComponentViewConfig); + ReactNativeViewConfigRegistry.register( 'InterfaceOnlyComponent', () => InterfaceOnlyComponentViewConfig, @@ -519,6 +549,7 @@ Map { const ReactNativeViewConfigRegistry = require('ReactNativeViewConfigRegistry'); const ReactNativeViewViewConfig = require('ReactNativeViewViewConfig'); +const verifyComponentAttributeEquivalence = require('verifyComponentAttributeEquivalence'); const ImageColorPropNativeComponentViewConfig = { uiViewClassName: 'ImageColorPropNativeComponent', @@ -541,6 +572,8 @@ const ImageColorPropNativeComponentViewConfig = { } }; +verifyComponentAttributeEquivalence('ImageColorPropNativeComponent', ImageColorPropNativeComponentViewConfig); + ReactNativeViewConfigRegistry.register( 'ImageColorPropNativeComponent', () => ImageColorPropNativeComponentViewConfig, @@ -568,6 +601,7 @@ Map { const ReactNativeViewConfigRegistry = require('ReactNativeViewConfigRegistry'); const ReactNativeViewViewConfig = require('ReactNativeViewViewConfig'); +const verifyComponentAttributeEquivalence = require('verifyComponentAttributeEquivalence'); const PointPropNativeComponentViewConfig = { uiViewClassName: 'PointPropNativeComponent', @@ -587,6 +621,8 @@ const PointPropNativeComponentViewConfig = { } }; +verifyComponentAttributeEquivalence('PointPropNativeComponent', PointPropNativeComponentViewConfig); + ReactNativeViewConfigRegistry.register( 'PointPropNativeComponent', () => PointPropNativeComponentViewConfig, @@ -614,6 +650,7 @@ Map { const ReactNativeViewConfigRegistry = require('ReactNativeViewConfigRegistry'); const ReactNativeViewViewConfig = require('ReactNativeViewViewConfig'); +const verifyComponentAttributeEquivalence = require('verifyComponentAttributeEquivalence'); const StringPropComponentViewConfig = { uiViewClassName: 'StringPropComponent', @@ -633,6 +670,8 @@ const StringPropComponentViewConfig = { } }; +verifyComponentAttributeEquivalence('StringPropComponent', StringPropComponentViewConfig); + ReactNativeViewConfigRegistry.register( 'StringPropComponent', () => StringPropComponentViewConfig, @@ -660,6 +699,7 @@ Map { const ReactNativeViewConfigRegistry = require('ReactNativeViewConfigRegistry'); const ReactNativeViewViewConfig = require('ReactNativeViewViewConfig'); +const verifyComponentAttributeEquivalence = require('verifyComponentAttributeEquivalence'); const MultiFile1NativeComponentViewConfig = { uiViewClassName: 'MultiFile1NativeComponent', @@ -679,6 +719,8 @@ const MultiFile1NativeComponentViewConfig = { } }; +verifyComponentAttributeEquivalence('MultiFile1NativeComponent', MultiFile1NativeComponentViewConfig); + ReactNativeViewConfigRegistry.register( 'MultiFile1NativeComponent', () => MultiFile1NativeComponentViewConfig, @@ -704,6 +746,8 @@ const MultiFile2NativeComponentViewConfig = { } }; +verifyComponentAttributeEquivalence('MultiFile2NativeComponent', MultiFile2NativeComponentViewConfig); + ReactNativeViewConfigRegistry.register( 'MultiFile2NativeComponent', () => MultiFile2NativeComponentViewConfig, @@ -731,6 +775,7 @@ Map { const ReactNativeViewConfigRegistry = require('ReactNativeViewConfigRegistry'); const ReactNativeViewViewConfig = require('ReactNativeViewViewConfig'); +const verifyComponentAttributeEquivalence = require('verifyComponentAttributeEquivalence'); const MultiComponent1NativeComponentViewConfig = { uiViewClassName: 'MultiComponent1NativeComponent', @@ -750,6 +795,8 @@ const MultiComponent1NativeComponentViewConfig = { } }; +verifyComponentAttributeEquivalence('MultiComponent1NativeComponent', MultiComponent1NativeComponentViewConfig); + ReactNativeViewConfigRegistry.register( 'MultiComponent1NativeComponent', () => MultiComponent1NativeComponentViewConfig, @@ -775,6 +822,8 @@ const MultiComponent2NativeComponentViewConfig = { } }; +verifyComponentAttributeEquivalence('MultiComponent2NativeComponent', MultiComponent2NativeComponentViewConfig); + ReactNativeViewConfigRegistry.register( 'MultiComponent2NativeComponent', () => MultiComponent2NativeComponentViewConfig,