From 191ddc1ec72be6641ebb8b9cb729cf0e142fff55 Mon Sep 17 00:00:00 2001 From: Umar Mohammad Date: Thu, 21 Aug 2025 11:33:52 -0700 Subject: [PATCH] Fix React Native Commands Export Validation in Coverage Mode (#53381) Summary: Changelog: [GENERAL] [FIXED] - Fixed babel plugin validation error when coverage instrumentation is enabled Pull Request resolved: https://github.com/facebook/react-native/pull/53381 ### Problem [Workplace post](https://fb.workplace.com/groups/235694244595999/permalink/1278937163605030/) React Native tests were failing **only when coverage collection was enabled** with the error: `'Commands' is a reserved export and may only be used to export the result of codegenNativeCommands.` ### Root Cause The React Native Babel plugin's `codegenNativeCommands` validation logic only handled direct `CallExpression` AST nodes. When coverage instrumentation was enabled, it transformed: **Normal code:** `export const Commands = codegenNativeCommands({...})` **With coverage:** `export const Commands = (cov_xxx().s[0]++, codegenNativeCommands({...}))` The plugin failed to recognize the valid `codegenNativeCommands` call wrapped in a `SequenceExpression` by coverage instrumentation. ### **Solution** Added `isCodegenNativeCommandsDeclaration` function to handle: 1. **Coverage instrumentation**: `SequenceExpression` nodes containing the function call 2. **Flow type casts**: `TypeCastExpression` and `AsExpression` 3. **TypeScript assertions**: `TSAsExpression` 4. **Direct calls**: Original `CallExpression` (backward compatibility) Reviewed By: andrewdacenko Differential Revision: D80572666 fbshipit-source-id: 465f4312a0229d8a92e495c685f46b607ce326e4 --- .../__test_fixtures__/failures.js | 137 ++++++++++++++++++ .../__test_fixtures__/fixtures.js | 91 ++++++++++++ .../__snapshots__/index-test.js.snap | 127 ++++++++++++++++ packages/babel-plugin-codegen/index.js | 64 +++++++- 4 files changed, 413 insertions(+), 6 deletions(-) diff --git a/packages/babel-plugin-codegen/__test_fixtures__/failures.js b/packages/babel-plugin-codegen/__test_fixtures__/failures.js index 908a83488e0..af4f40a3ec0 100644 --- a/packages/babel-plugin-codegen/__test_fixtures__/failures.js +++ b/packages/babel-plugin-codegen/__test_fixtures__/failures.js @@ -81,10 +81,147 @@ export {Commands}; export default (codegenNativeComponent('Module'): NativeType); `; +const COMMANDS_WITH_COVERAGE_INVALID = ` +// @flow + +const codegenNativeComponent = require('codegenNativeComponent'); +import type {NativeComponentType} from 'codegenNativeComponent'; + +import type {ViewProps} from 'ViewPropTypes'; + +type ModuleProps = $ReadOnly<{| + ...ViewProps, +|}>; + +type NativeType = NativeComponentType; + +// Coverage instrumentation of invalid Commands export - should still fail +export const Commands = (cov_1234567890().s[0]++, { + hotspotUpdate: () => {}, + scrollTo: () => {}, +}); + +export default (codegenNativeComponent('Module'): NativeType); +`; + +const COMMANDS_WITH_COVERAGE_WRONG_FUNCTION = ` +// @flow + +const codegenNativeComponent = require('codegenNativeComponent'); +import type {NativeComponentType} from 'codegenNativeComponent'; + +import type {ViewProps} from 'ViewPropTypes'; + +type ModuleProps = $ReadOnly<{| + ...ViewProps, +|}>; + +type NativeType = NativeComponentType; + +// Coverage instrumentation of wrong function call - should fail +export const Commands = (cov_abcdef123().s[0]++, someOtherFunction({ + supportedCommands: ['pause', 'play'], +})); + +export default (codegenNativeComponent('Module'): NativeType); +`; + +const COMMANDS_WITH_COMPLEX_COVERAGE_INVALID = ` +// @flow + +const codegenNativeComponent = require('codegenNativeComponent'); +import type {NativeComponentType} from 'codegenNativeComponent'; + +import type {ViewProps} from 'ViewPropTypes'; + +type ModuleProps = $ReadOnly<{| + ...ViewProps, +|}>; + +type NativeType = NativeComponentType; + +// Complex coverage instrumentation with invalid nested structure - should fail +export const Commands = ( + cov_xyz789().f[1]++, + cov_xyz789().s[2]++, + { + pause: (ref) => {}, + play: (ref) => {}, + } +); + +export default (codegenNativeComponent('Module'): NativeType); +`; + +const COMMANDS_WITH_COVERAGE_WRONG_NAME = ` +// @flow + +const codegenNativeCommands = require('codegenNativeCommands'); +const codegenNativeComponent = require('codegenNativeComponent'); +import type {NativeComponentType} from 'codegenNativeComponent'; + +import type {ViewProps} from 'ViewPropTypes'; + +type ModuleProps = $ReadOnly<{| + ...ViewProps, +|}>; + +type NativeType = NativeComponentType; + +interface NativeCommands { + +pause: (viewRef: React.ElementRef) => void; + +play: (viewRef: React.ElementRef) => void; +} + +// Coverage instrumentation with correct function but wrong export name - should fail +export const WrongName = (cov_wrong123().s[0]++, codegenNativeCommands({ + supportedCommands: ['pause', 'play'], +})); + +export default (codegenNativeComponent('Module'): NativeType); +`; + +const COMMANDS_WITH_COVERAGE_TYPE_CAST_INVALID = ` +// @flow + +const codegenNativeComponent = require('codegenNativeComponent'); +import type {NativeComponentType} from 'codegenNativeComponent'; + +import type {ViewProps} from 'ViewPropTypes'; + +type ModuleProps = $ReadOnly<{| + ...ViewProps, +|}>; + +type NativeType = NativeComponentType; + +interface NativeCommands { + +pause: (viewRef: React.ElementRef) => void; + +play: (viewRef: React.ElementRef) => void; +} + +// Coverage instrumentation with type cast but wrong function - should fail +export const Commands: NativeCommands = (cov_cast123().s[0]++, invalidFunction({ + supportedCommands: ['pause', 'play'], +})); + +export default (codegenNativeComponent('Module'): NativeType); +`; + module.exports = { 'CommandsExportedWithDifferentNameNativeComponent.js': COMMANDS_EXPORTED_WITH_DIFFERENT_NAME, 'CommandsExportedWithShorthandNativeComponent.js': COMMANDS_EXPORTED_WITH_SHORTHAND, 'OtherCommandsExportNativeComponent.js': OTHER_COMMANDS_EXPORT, + 'CommandsWithCoverageInvalidNativeComponent.js': + COMMANDS_WITH_COVERAGE_INVALID, + 'CommandsWithCoverageWrongFunctionNativeComponent.js': + COMMANDS_WITH_COVERAGE_WRONG_FUNCTION, + 'CommandsWithComplexCoverageInvalidNativeComponent.js': + COMMANDS_WITH_COMPLEX_COVERAGE_INVALID, + 'CommandsWithCoverageWrongNameNativeComponent.js': + COMMANDS_WITH_COVERAGE_WRONG_NAME, + 'CommandsWithCoverageTypeCastInvalidNativeComponent.js': + COMMANDS_WITH_COVERAGE_TYPE_CAST_INVALID, }; diff --git a/packages/babel-plugin-codegen/__test_fixtures__/fixtures.js b/packages/babel-plugin-codegen/__test_fixtures__/fixtures.js index 1aec6340d2f..6b813a3f07a 100644 --- a/packages/babel-plugin-codegen/__test_fixtures__/fixtures.js +++ b/packages/babel-plugin-codegen/__test_fixtures__/fixtures.js @@ -59,6 +59,92 @@ export default codegenNativeComponent('Module', { }); `; +// Coverage instrumentation test cases - should be recognized as valid +const COMMANDS_WITH_SIMPLE_COVERAGE = ` +// @flow + +const codegenNativeCommands = require('codegenNativeCommands'); +const codegenNativeComponent = require('codegenNativeComponent'); + +import type {ViewProps} from 'ViewPropTypes'; +import type {NativeComponentType} from 'codegenNativeComponent'; + +type ModuleProps = $ReadOnly<{| + ...ViewProps, +|}>; + +type NativeType = NativeComponentType; + +interface NativeCommands { + +pause: (viewRef: React.ElementRef) => void; + +play: (viewRef: React.ElementRef) => void; +} + +export const Commands = (cov_1234567890.s[0]++, codegenNativeCommands({ + supportedCommands: ['pause', 'play'], +})); + +export default codegenNativeComponent('Module'); +`; + +const COMMANDS_WITH_COMPLEX_COVERAGE = ` +// @flow + +const codegenNativeCommands = require('codegenNativeCommands'); +const codegenNativeComponent = require('codegenNativeComponent'); + +import type {ViewProps} from 'ViewPropTypes'; +import type {NativeComponentType} from 'codegenNativeComponent'; + +type ModuleProps = $ReadOnly<{| + ...ViewProps, +|}>; + +type NativeType = NativeComponentType; + +interface NativeCommands { + +seek: (viewRef: React.ElementRef, position: number) => void; + +stop: (viewRef: React.ElementRef) => void; +} + +export const Commands = ( + cov_abcdef123().f[2]++, + cov_abcdef123().s[5]++, + codegenNativeCommands({ + supportedCommands: ['seek', 'stop'], + }) +); + +export default codegenNativeComponent('Module'); +`; + +const COMMANDS_WITH_TYPE_CAST_COVERAGE = ` +// @flow + +const codegenNativeCommands = require('codegenNativeCommands'); +const codegenNativeComponent = require('codegenNativeComponent'); + +import type {ViewProps} from 'ViewPropTypes'; +import type {NativeComponentType} from 'codegenNativeComponent'; + +type ModuleProps = $ReadOnly<{| + ...ViewProps, +|}>; + +type NativeType = NativeComponentType; + +interface NativeCommands { + +mute: (viewRef: React.ElementRef) => void; + +unmute: (viewRef: React.ElementRef) => void; +} + +export const Commands: NativeCommands = (cov_xyz789().s[1]++, codegenNativeCommands({ + supportedCommands: ['mute', 'unmute'], +})); + +export default codegenNativeComponent('Module'); +`; + const FULL_NATIVE_COMPONENT_WITH_TYPE_EXPORT = ` // @flow @@ -107,4 +193,9 @@ module.exports = { 'NotANativeComponent.js': NOT_A_NATIVE_COMPONENT, 'FullNativeComponent.js': FULL_NATIVE_COMPONENT, 'FullTypedNativeComponent.js': FULL_NATIVE_COMPONENT_WITH_TYPE_EXPORT, + 'CommandsWithSimpleCoverageNativeComponent.js': COMMANDS_WITH_SIMPLE_COVERAGE, + 'CommandsWithComplexCoverageNativeComponent.js': + COMMANDS_WITH_COMPLEX_COVERAGE, + 'CommandsWithTypeCastCoverageNativeComponent.js': + COMMANDS_WITH_TYPE_CAST_COVERAGE, }; diff --git a/packages/babel-plugin-codegen/__tests__/__snapshots__/index-test.js.snap b/packages/babel-plugin-codegen/__tests__/__snapshots__/index-test.js.snap index 94e334ad3da..c038ee55988 100644 --- a/packages/babel-plugin-codegen/__tests__/__snapshots__/index-test.js.snap +++ b/packages/babel-plugin-codegen/__tests__/__snapshots__/index-test.js.snap @@ -1,5 +1,77 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`Babel plugin inline view configs can inline config for CommandsWithComplexCoverageNativeComponent.js 1`] = ` +"// @flow + +const codegenNativeCommands = require('codegenNativeCommands'); +const codegenNativeComponent = require('codegenNativeComponent'); +import type { ViewProps } from 'ViewPropTypes'; +import type { NativeComponentType } from 'codegenNativeComponent'; +type ModuleProps = $ReadOnly<{| + ...ViewProps +|}>; +type NativeType = NativeComponentType; +interface NativeCommands { + +seek: (viewRef: React.ElementRef, position: number) => void, + +stop: (viewRef: React.ElementRef) => void, +} +const NativeComponentRegistry = require('react-native/Libraries/NativeComponent/NativeComponentRegistry'); +let nativeComponentName = 'Module'; +export const __INTERNAL_VIEW_CONFIG = { + uiViewClassName: \\"Module\\", + validAttributes: {} +}; +export default NativeComponentRegistry.get(nativeComponentName, () => __INTERNAL_VIEW_CONFIG);" +`; + +exports[`Babel plugin inline view configs can inline config for CommandsWithSimpleCoverageNativeComponent.js 1`] = ` +"// @flow + +const codegenNativeCommands = require('codegenNativeCommands'); +const codegenNativeComponent = require('codegenNativeComponent'); +import type { ViewProps } from 'ViewPropTypes'; +import type { NativeComponentType } from 'codegenNativeComponent'; +type ModuleProps = $ReadOnly<{| + ...ViewProps +|}>; +type NativeType = NativeComponentType; +interface NativeCommands { + +pause: (viewRef: React.ElementRef) => void, + +play: (viewRef: React.ElementRef) => void, +} +const NativeComponentRegistry = require('react-native/Libraries/NativeComponent/NativeComponentRegistry'); +let nativeComponentName = 'Module'; +export const __INTERNAL_VIEW_CONFIG = { + uiViewClassName: \\"Module\\", + validAttributes: {} +}; +export default NativeComponentRegistry.get(nativeComponentName, () => __INTERNAL_VIEW_CONFIG);" +`; + +exports[`Babel plugin inline view configs can inline config for CommandsWithTypeCastCoverageNativeComponent.js 1`] = ` +"// @flow + +const codegenNativeCommands = require('codegenNativeCommands'); +const codegenNativeComponent = require('codegenNativeComponent'); +import type { ViewProps } from 'ViewPropTypes'; +import type { NativeComponentType } from 'codegenNativeComponent'; +type ModuleProps = $ReadOnly<{| + ...ViewProps +|}>; +type NativeType = NativeComponentType; +interface NativeCommands { + +mute: (viewRef: React.ElementRef) => void, + +unmute: (viewRef: React.ElementRef) => void, +} +const NativeComponentRegistry = require('react-native/Libraries/NativeComponent/NativeComponentRegistry'); +let nativeComponentName = 'Module'; +export const __INTERNAL_VIEW_CONFIG = { + uiViewClassName: \\"Module\\", + validAttributes: {} +}; +export default NativeComponentRegistry.get(nativeComponentName, () => __INTERNAL_VIEW_CONFIG);" +`; + exports[`Babel plugin inline view configs can inline config for FullNativeComponent.js 1`] = ` "// @flow @@ -153,6 +225,61 @@ exports[`Babel plugin inline view configs fails on inline config for CommandsExp 24 |" `; +exports[`Babel plugin inline view configs fails on inline config for CommandsWithComplexCoverageInvalidNativeComponent.js 1`] = ` +"/CommandsWithComplexCoverageInvalidNativeComponent.js: 'Commands' is a reserved export and may only be used to export the result of codegenNativeCommands. + 14 | + 15 | // Complex coverage instrumentation with invalid nested structure - should fail +> 16 | export const Commands = ( + | ^ + 17 | cov_xyz789().f[1]++, + 18 | cov_xyz789().s[2]++, + 19 | {" +`; + +exports[`Babel plugin inline view configs fails on inline config for CommandsWithCoverageInvalidNativeComponent.js 1`] = ` +"/CommandsWithCoverageInvalidNativeComponent.js: 'Commands' is a reserved export and may only be used to export the result of codegenNativeCommands. + 14 | + 15 | // Coverage instrumentation of invalid Commands export - should still fail +> 16 | export const Commands = (cov_1234567890().s[0]++, { + | ^ + 17 | hotspotUpdate: () => {}, + 18 | scrollTo: () => {}, + 19 | });" +`; + +exports[`Babel plugin inline view configs fails on inline config for CommandsWithCoverageTypeCastInvalidNativeComponent.js 1`] = ` +"/CommandsWithCoverageTypeCastInvalidNativeComponent.js: 'Commands' is a reserved export and may only be used to export the result of codegenNativeCommands. + 19 | + 20 | // Coverage instrumentation with type cast but wrong function - should fail +> 21 | export const Commands: NativeCommands = (cov_cast123().s[0]++, invalidFunction({ + | ^ + 22 | supportedCommands: ['pause', 'play'], + 23 | })); + 24 |" +`; + +exports[`Babel plugin inline view configs fails on inline config for CommandsWithCoverageWrongFunctionNativeComponent.js 1`] = ` +"/CommandsWithCoverageWrongFunctionNativeComponent.js: 'Commands' is a reserved export and may only be used to export the result of codegenNativeCommands. + 14 | + 15 | // Coverage instrumentation of wrong function call - should fail +> 16 | export const Commands = (cov_abcdef123().s[0]++, someOtherFunction({ + | ^ + 17 | supportedCommands: ['pause', 'play'], + 18 | })); + 19 |" +`; + +exports[`Babel plugin inline view configs fails on inline config for CommandsWithCoverageWrongNameNativeComponent.js 1`] = ` +"/CommandsWithCoverageWrongNameNativeComponent.js: Native commands must be exported with the name 'Commands' + 20 | + 21 | // Coverage instrumentation with correct function but wrong export name - should fail +> 22 | export const WrongName = (cov_wrong123().s[0]++, codegenNativeCommands({ + | ^ + 23 | supportedCommands: ['pause', 'play'], + 24 | })); + 25 |" +`; + exports[`Babel plugin inline view configs fails on inline config for OtherCommandsExportNativeComponent.js 1`] = ` "/OtherCommandsExportNativeComponent.js: 'Commands' is a reserved export and may only be used to export the result of codegenNativeCommands. 17 | } diff --git a/packages/babel-plugin-codegen/index.js b/packages/babel-plugin-codegen/index.js index 37302dfd050..06a4939ad22 100644 --- a/packages/babel-plugin-codegen/index.js +++ b/packages/babel-plugin-codegen/index.js @@ -102,6 +102,58 @@ function isCodegenDeclaration(declaration) { return false; } +function isCodegenNativeCommandsDeclaration(declaration) { + if (!declaration) { + return false; + } + + // Handle direct calls: codegenNativeCommands() + if ( + declaration.type === 'CallExpression' && + declaration.callee && + declaration.callee.type === 'Identifier' && + declaration.callee.name === 'codegenNativeCommands' + ) { + return true; + } + + // Handle coverage instrumentation: (cov_xxx().s[0]++, codegenNativeCommands()) + if (declaration.type === 'SequenceExpression' && declaration.expressions) { + // Get the last expression in the sequence (the actual function call) + const lastExpression = + declaration.expressions[declaration.expressions.length - 1]; + // Recursively check if the last expression is a valid codegenNativeCommands call + return isCodegenNativeCommandsDeclaration(lastExpression); + } + + // Handle Flow type casts: (codegenNativeCommands(): NativeCommands) + if ( + (declaration.type === 'TypeCastExpression' || + declaration.type === 'AsExpression') && + declaration.expression && + declaration.expression.type === 'CallExpression' && + declaration.expression.callee && + declaration.expression.callee.type === 'Identifier' && + declaration.expression.callee.name === 'codegenNativeCommands' + ) { + return true; + } + + // Handle TypeScript assertions: codegenNativeCommands() as NativeCommands + if ( + declaration.type === 'TSAsExpression' && + declaration.expression && + declaration.expression.type === 'CallExpression' && + declaration.expression.callee && + declaration.expression.callee.type === 'Identifier' && + declaration.expression.callee.name === 'codegenNativeCommands' + ) { + return true; + } + + return false; +} + module.exports = function ({parse, types: t}) { return { pre(state) { @@ -125,12 +177,12 @@ module.exports = function ({parse, types: t}) { const firstDeclaration = path.node.declaration.declarations[0]; if (firstDeclaration.type === 'VariableDeclarator') { - if ( - firstDeclaration.init && - firstDeclaration.init.type === 'CallExpression' && - firstDeclaration.init.callee.type === 'Identifier' && - firstDeclaration.init.callee.name === 'codegenNativeCommands' - ) { + // Check if this is a valid codegenNativeCommands call, handling type annotations + const isValidCommandsExport = isCodegenNativeCommandsDeclaration( + firstDeclaration.init, + ); + + if (isValidCommandsExport) { if ( firstDeclaration.id.type === 'Identifier' && firstDeclaration.id.name !== 'Commands'