From 5bd673b210631bef91d4af594b228e2cb920c0dd Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Thu, 14 May 2026 08:29:33 -0700 Subject: [PATCH] Convert select component types to interface in TS typegen (#56809) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: #### Motivation Adds (preserves) compatibility of Nativewind and Expo Web type augmentations with the Strict TypeScript API. - These libraries rely on key React Native component types being defined as `interface`, not `type`, since TS module augmentation only supports `interface` declarations. - Previously, our opt-in generated TypeScript types (Strict TypeScript API) emitted all props types as `type Foo = Readonly<...>` ), matching Flow source. However, this API change vs our manual types (which predominantly used `interface` on props) breaks library compatibility in these cases. - This is very awkward to otherwise solve in user space — so opt for backwards compatibility in our new types. #### Primer TypeScript module augmentation lets a library extend an existing module's types without modifying the source. e.g. In Nativewind ([source](https://www.nativewind.dev/docs/guides/third-party-components)): ```ts declare module 'react-native' { interface ScrollViewProps extends ViewProps, ScrollViewPropsIOS, ScrollViewPropsAndroid, Touchable { contentContainerClassName?: string; indicatorClassName?: string; } interface FlatListProps extends VirtualizedListProps { columnWrapperClassName?: string; } interface ViewProps { className?: string; } } ``` Expo's `react-native-web.d.ts` ([source](https://unpkg.com/expo@54.0.31/types/react-native-web.d.ts)) also follows the same pattern across several component props types. #### Changes Add new `build-types` transform, implementing an opt-in `/** build-types emit-as-interface */` annotation, converting eligible `type` aliases to `interface` declarations. - Changing the Flow source files directly isn't viable — Flow doesn't support `interface extends Omit<>`, and several of these types use `Omit<>` in their composition. The post-transform operates on the TypeScript output where `interface extends Readonly>` is valid. The following emitted types are updated: | Type | Augmented by | Source | |---|---|---| | `ViewProps` | `className?` | [Nativewind](https://www.nativewind.dev/docs/guides/third-party-components), [Expo](https://unpkg.com/expo@54.0.31/types/react-native-web.d.ts) | | `ScrollViewProps` | `contentContainerClassName?`, `indicatorClassName?` | [Nativewind](https://www.nativewind.dev/docs/guides/third-party-components) | | `ImagePropsBase` | `className?` | [Expo](https://unpkg.com/expo@54.0.31/types/react-native-web.d.ts) | | `SwitchProps` | `className?` | [Expo](https://unpkg.com/expo@54.0.31/types/react-native-web.d.ts) | | `TouchableWithoutFeedbackProps` | `className?` | [Expo](https://unpkg.com/expo@54.0.31/types/react-native-web.d.ts) | | `InputAccessoryViewProps` | `className?` | [Expo](https://unpkg.com/expo@54.0.31/types/react-native-web.d.ts) | `FlatListProps` is not included in this PR: its bare intersection (no `Readonly<>` wrapper) has a conflicting `fadingEdgeLength` property across constituent types that cannot be expressed as a single `extends` clause without hitting TS2320. This will be addressed in a follow-up. `VirtualizedListWithoutRenderItemProps` in `react-native/virtualized-lists` is separately owned and not changed here. Changelog: [General][Changed] - **Strict TypeScript API**: Select component props types are now `interface` declarations, enabling module augmentation by libraries like NativeWind and Expo (preserve compatibility) Differential Revision: D104808984 --- .../Components/ScrollView/ScrollView.js | 1 + .../Libraries/Components/Switch/Switch.js | 1 + .../TextInput/InputAccessoryView.js | 1 + .../Touchable/TouchableWithoutFeedback.js | 1 + .../Components/View/ViewPropTypes.js | 1 + .../Libraries/Image/ImageProps.js | 1 + .../react-native/Libraries/Lists/FlatList.js | 5 +- packages/react-native/ReactNativeApi.d.ts | 319 +++++++++--------- .../convertTypeAliasesToInterfaces-test.js | 158 +++++++++ .../convertTypeAliasesToInterfaces.js | 193 +++++++++++ .../js-api/build-types/translateSourceFile.js | 1 + 11 files changed, 527 insertions(+), 155 deletions(-) create mode 100644 scripts/js-api/build-types/transforms/typescript/__tests__/convertTypeAliasesToInterfaces-test.js create mode 100644 scripts/js-api/build-types/transforms/typescript/convertTypeAliasesToInterfaces.js diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js index 53da6ce07e68..d19eb049ee1f 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js @@ -680,6 +680,7 @@ type ScrollViewBaseProps = Readonly<{ scrollViewRef?: React.RefSetter, }>; +/** @build-types emit-as-interface Nativewind compatibility */ export type ScrollViewProps = Readonly<{ ...Omit, ...ScrollViewPropsIOS, diff --git a/packages/react-native/Libraries/Components/Switch/Switch.js b/packages/react-native/Libraries/Components/Switch/Switch.js index d441435a6c6d..790cf7716ff9 100644 --- a/packages/react-native/Libraries/Components/Switch/Switch.js +++ b/packages/react-native/Libraries/Components/Switch/Switch.js @@ -109,6 +109,7 @@ type SwitchPropsBase = { onValueChange?: ?(value: boolean) => Promise | void, }; +/** @build-types emit-as-interface Expo compatibility */ export type SwitchProps = Readonly<{ ...ViewProps, ...SwitchPropsIOS, diff --git a/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js b/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js index 20ec3003d5c5..cf487dd02471 100644 --- a/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js +++ b/packages/react-native/Libraries/Components/TextInput/InputAccessoryView.js @@ -76,6 +76,7 @@ import * as React from 'react'; * For an example, look at InputAccessoryViewExample.js in RNTester. */ +/** @build-types emit-as-interface Expo compatibility */ export type InputAccessoryViewProps = Readonly<{ +children: React.Node, /** diff --git a/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js b/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js index adb099c27213..c66b542325c5 100755 --- a/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js +++ b/packages/react-native/Libraries/Components/Touchable/TouchableWithoutFeedback.js @@ -36,6 +36,7 @@ export type TouchableWithoutFeedbackPropsAndroid = { touchSoundDisabled?: ?boolean, }; +/** @build-types emit-as-interface Expo compatibility */ export type TouchableWithoutFeedbackProps = Readonly< { children?: ?React.Node, diff --git a/packages/react-native/Libraries/Components/View/ViewPropTypes.js b/packages/react-native/Libraries/Components/View/ViewPropTypes.js index a20d8f246ea5..c2be0823fd34 100644 --- a/packages/react-native/Libraries/Components/View/ViewPropTypes.js +++ b/packages/react-native/Libraries/Components/View/ViewPropTypes.js @@ -508,6 +508,7 @@ type ViewBaseProps = Readonly<{ experimental_accessibilityOrder?: ?Array, }>; +/** @build-types emit-as-interface Nativewind, Expo compatibility */ export type ViewProps = Readonly<{ ...DirectEventProps, ...GestureResponderHandlers, diff --git a/packages/react-native/Libraries/Image/ImageProps.js b/packages/react-native/Libraries/Image/ImageProps.js index 2acab590b8d6..11f8abcc7680 100644 --- a/packages/react-native/Libraries/Image/ImageProps.js +++ b/packages/react-native/Libraries/Image/ImageProps.js @@ -125,6 +125,7 @@ export type ImagePropsAndroid = Readonly<{ resizeMultiplier?: ?number, }>; +/** @build-types emit-as-interface Expo compatibility */ export type ImagePropsBase = Readonly<{ ...Omit, /** diff --git a/packages/react-native/Libraries/Lists/FlatList.js b/packages/react-native/Libraries/Lists/FlatList.js index 3feea7d892d5..43256b3f79db 100644 --- a/packages/react-native/Libraries/Lists/FlatList.js +++ b/packages/react-native/Libraries/Lists/FlatList.js @@ -182,7 +182,8 @@ type FlatListBaseProps = { ...OptionalFlatListProps, }; -export type FlatListProps = { +/** @build-types emit-as-interface Nativewind compatibility */ +export type FlatListProps = Readonly<{ ...Omit< VirtualizedListProps, | 'data' @@ -194,7 +195,7 @@ export type FlatListProps = { >, ...FlatListBaseProps, ... -}; +}>; /** * A performant interface for rendering simple, flat lists, supporting the most handy features: diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 8b629454db4c..1b73dfad14c9 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -2361,16 +2361,19 @@ declare class FlatList extends React.PureComponent< } declare type FlatListBaseProps = RequiredFlatListProps & OptionalFlatListProps -declare type FlatListProps = Omit< - VirtualizedListProps, - | "data" - | "getItem" - | "getItemCount" - | "getItemLayout" - | "keyExtractor" - | "renderItem" -> & - FlatListBaseProps +declare interface FlatListProps + extends Readonly< + Omit< + VirtualizedListProps, + | "data" + | "getItem" + | "getItemCount" + | "getItemLayout" + | "keyExtractor" + | "renderItem" + > & + FlatListBaseProps + > {} declare type flatten = typeof flatten declare type FlattenDepthLimiter = [void, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9] declare function flattenStyle_default< @@ -2609,7 +2612,7 @@ declare type ImageProgressEventIOS = NativeSyntheticEvent< declare type ImageProps = Readonly< ImagePropsIOS & ImagePropsAndroid & - ImagePropsBase & { + Omit & { style?: ImageStyleProp } > @@ -2620,51 +2623,52 @@ declare type ImagePropsAndroid = { readonly resizeMethod?: "auto" | "none" | "resize" | "scale" readonly resizeMultiplier?: number } -declare type ImagePropsBase = Readonly< - Omit< - Omit, - | "accessibilityLabel" - | "accessible" - | "aria-label" - | "aria-labelledby" - | "children" - | "onLayout" - | "testID" - > & { - accessibilityLabel?: string - accessible?: boolean - alt?: string - "aria-label"?: string - "aria-labelledby"?: string - blurRadius?: number - capInsets?: EdgeInsetsProp - children?: never - crossOrigin?: "anonymous" | "use-credentials" - height?: number - internal_analyticTag?: string - onError?: (event: ImageErrorEvent) => void - onLayout?: (event: LayoutChangeEvent) => unknown - onLoad?: (event: ImageLoadEvent) => void - onLoadEnd?: () => void - onLoadStart?: () => void - referrerPolicy?: - | "no-referrer-when-downgrade" - | "no-referrer" - | "origin-when-cross-origin" - | "origin" - | "same-origin" - | "strict-origin-when-cross-origin" - | "strict-origin" - | "unsafe-url" - resizeMode?: ImageResizeMode - source?: ImageSource - src?: string - srcSet?: string - testID?: string - tintColor?: ColorValue - width?: number - } -> +declare interface ImagePropsBase + extends Readonly< + Omit< + Omit, + | "accessibilityLabel" + | "accessible" + | "aria-label" + | "aria-labelledby" + | "children" + | "onLayout" + | "testID" + > + > { + readonly accessibilityLabel?: string + readonly accessible?: boolean + readonly alt?: string + readonly "aria-label"?: string + readonly "aria-labelledby"?: string + readonly blurRadius?: number + readonly capInsets?: EdgeInsetsProp + readonly children?: never + readonly crossOrigin?: "anonymous" | "use-credentials" + readonly height?: number + readonly internal_analyticTag?: string + readonly onError?: (event: ImageErrorEvent) => void + readonly onLayout?: (event: LayoutChangeEvent) => unknown + readonly onLoad?: (event: ImageLoadEvent) => void + readonly onLoadEnd?: () => void + readonly onLoadStart?: () => void + readonly referrerPolicy?: + | "no-referrer-when-downgrade" + | "no-referrer" + | "origin-when-cross-origin" + | "origin" + | "same-origin" + | "strict-origin-when-cross-origin" + | "strict-origin" + | "unsafe-url" + readonly resizeMode?: ImageResizeMode + readonly source?: ImageSource + readonly src?: string + readonly srcSet?: string + readonly testID?: string + readonly tintColor?: ColorValue + readonly width?: number +} declare type ImagePropsIOS = { readonly defaultSource?: ImageSource readonly onPartialLoad?: () => void @@ -2730,7 +2734,7 @@ declare class Info { } declare type InnerViewInstance = React.ComponentRef declare type InputAccessoryView = typeof InputAccessoryView -declare type InputAccessoryViewProps = { +declare interface InputAccessoryViewProps { readonly backgroundColor?: ColorValue readonly children: React.ReactNode readonly nativeID?: string @@ -4500,9 +4504,13 @@ declare interface ScrollViewImperativeMethods { options?: ScrollViewScrollToOptions | undefined, ) => void } -declare type ScrollViewProps = Readonly< - ViewProps & ScrollViewPropsIOS & ScrollViewPropsAndroid & ScrollViewBaseProps -> +declare interface ScrollViewProps + extends Readonly< + ViewProps & + ScrollViewPropsIOS & + ScrollViewPropsAndroid & + ScrollViewBaseProps + > {} declare type ScrollViewPropsAndroid = { readonly endFillColor?: ColorValue readonly fadingEdgeLength?: @@ -5003,9 +5011,8 @@ declare type SwitchNativeProps = Readonly< value?: WithDefault } > -declare type SwitchProps = Readonly< - ViewProps & SwitchPropsIOS & SwitchPropsBase -> +declare interface SwitchProps + extends Readonly {} declare type SwitchPropsBase = { disabled?: boolean ios_backgroundColor?: ColorValue @@ -5569,34 +5576,39 @@ declare type TouchableState = declare function TouchableWithoutFeedback( props: TouchableWithoutFeedbackProps, ): React.ReactNode -declare type TouchableWithoutFeedbackProps = Readonly< - TouchableWithoutFeedbackPropsAndroid & - TouchableWithoutFeedbackPropsIOS & - AccessibilityProps & { - children?: React.ReactNode - delayLongPress?: number - delayPressIn?: number - delayPressOut?: number - disabled?: boolean - focusable?: boolean - hitSlop?: EdgeInsetsOrSizeProp - id?: string - importantForAccessibility?: "auto" | "no-hide-descendants" | "no" | "yes" - nativeID?: string - onAccessibilityAction?: (event: AccessibilityActionEvent) => unknown - onBlur?: (event: BlurEvent) => unknown - onFocus?: (event: FocusEvent) => unknown - onLayout?: (event: LayoutChangeEvent) => unknown - onLongPress?: (event: GestureResponderEvent) => unknown - onPress?: (event: GestureResponderEvent) => unknown - onPressIn?: (event: GestureResponderEvent) => unknown - onPressOut?: (event: GestureResponderEvent) => unknown - pressRetentionOffset?: EdgeInsetsOrSizeProp - rejectResponderTermination?: boolean - style?: ViewStyleProp - testID?: string - } -> +declare interface TouchableWithoutFeedbackProps + extends Readonly< + TouchableWithoutFeedbackPropsAndroid & + TouchableWithoutFeedbackPropsIOS & + AccessibilityProps + > { + readonly children?: React.ReactNode + readonly delayLongPress?: number + readonly delayPressIn?: number + readonly delayPressOut?: number + readonly disabled?: boolean + readonly focusable?: boolean + readonly hitSlop?: EdgeInsetsOrSizeProp + readonly id?: string + readonly importantForAccessibility?: + | "auto" + | "no-hide-descendants" + | "no" + | "yes" + readonly nativeID?: string + readonly onAccessibilityAction?: (event: AccessibilityActionEvent) => unknown + readonly onBlur?: (event: BlurEvent) => unknown + readonly onFocus?: (event: FocusEvent) => unknown + readonly onLayout?: (event: LayoutChangeEvent) => unknown + readonly onLongPress?: (event: GestureResponderEvent) => unknown + readonly onPress?: (event: GestureResponderEvent) => unknown + readonly onPressIn?: (event: GestureResponderEvent) => unknown + readonly onPressOut?: (event: GestureResponderEvent) => unknown + readonly pressRetentionOffset?: EdgeInsetsOrSizeProp + readonly rejectResponderTermination?: boolean + readonly style?: ViewStyleProp + readonly testID?: string +} declare type TouchableWithoutFeedbackPropsAndroid = { touchSoundDisabled?: boolean } @@ -5771,19 +5783,20 @@ declare type ViewConfig = { readonly uiViewClassName: string readonly validAttributes: AttributeConfiguration } -declare type ViewProps = Readonly< - DirectEventProps & - GestureResponderHandlers & - MouseEventProps & - PointerEventProps & - FocusEventProps & - KeyEventProps & - TouchEventProps & - ViewPropsAndroid & - ViewPropsIOS & - AccessibilityProps & - ViewBaseProps -> +declare interface ViewProps + extends Readonly< + DirectEventProps & + GestureResponderHandlers & + MouseEventProps & + PointerEventProps & + FocusEventProps & + KeyEventProps & + TouchEventProps & + ViewPropsAndroid & + ViewPropsIOS & + AccessibilityProps & + ViewBaseProps + > {} declare type ViewPropsAndroid = { readonly focusable?: boolean readonly hasTVPreferredFocus?: boolean @@ -5975,15 +5988,15 @@ export { AccessibilityValue, // cf8bcb74 ActionSheetIOS, // b558559e ActionSheetIOSOptions, // 1756eb5a - ActivityIndicator, // 9b127a18 - ActivityIndicatorProps, // 40142bf3 + ActivityIndicator, // ad37cba0 + ActivityIndicatorProps, // 94db67c0 Alert, // 5bf12165 AlertButton, // bf1a3b60 AlertButtonStyle, // ec9fb242 AlertOptions, // a0cdac0f AlertType, // 5ab91217 AndroidKeyboardEvent, // e03becc8 - Animated, // 29ce3240 + Animated, // 9d76d6c7 AppConfig, // ce4209a7 AppRegistry, // 5edf0524 AppState, // 12012be5 @@ -5995,7 +6008,7 @@ export { BackPressEventName, // 4620fb76 BlurEvent, // e6151a1f BoxShadowValue, // b679703f - Button, // 53167a86 + Button, // 1b481757 ButtonProps, // 0df9cb59 Clipboard, // 41addb89 CodegenTypes, // 0b8108a8 @@ -6014,8 +6027,8 @@ export { DimensionsPayload, // 653bc26c DisplayMetrics, // 1dc35cef DisplayMetricsAndroid, // 872e62eb - DrawerLayoutAndroid, // c0fe33a6 - DrawerLayoutAndroidProps, // 6ce7fb3d + DrawerLayoutAndroid, // 4b041970 + DrawerLayoutAndroidProps, // ffc88788 DrawerSlideEvent, // 0256d35a DropShadowValue, // e9df2606 DynamicColorIOS, // d96c228c @@ -6030,8 +6043,8 @@ export { EventSubscription, // b8d084aa ExtendedExceptionData, // 5a6ccf5a FilterFunction, // bf24c0e3 - FlatList, // 869c5a5f - FlatListProps, // 2a81c428 + FlatList, // 7c3d9ffb + FlatListProps, // cf9bfbb8 FocusEvent, // 62fc1eb8 FontVariant, // 7c7558bb GestureResponderEvent, // f693e9a5 @@ -6043,15 +6056,15 @@ export { IEventEmitter, // fbef6131 IOSKeyboardEvent, // e67bfe3a IgnorePattern, // ec6f6ece - Image, // a2358f8c - ImageBackground, // 414c60ba - ImageBackgroundProps, // 65c127f9 + Image, // 0c1f0f7e + ImageBackground, // 5a23ae3f + ImageBackgroundProps, // 13f111f0 ImageErrorEvent, // d3ee606e ImageLoadEvent, // 6b547ea5 ImageProgressEventIOS, // 4c866a82 - ImageProps, // d917cbd6 + ImageProps, // 4c11aa04 ImagePropsAndroid, // 9fd9bcbb - ImagePropsBase, // c9521ea0 + ImagePropsBase, // 1c26f36a ImagePropsIOS, // c4ae0c06 ImageRequireSource, // 681d683b ImageResolvedAssetSource, // f3060931 @@ -6060,8 +6073,8 @@ export { ImageSourcePropType, // bfb5e5c6 ImageStyle, // ad6a6dee ImageURISource, // 016eb083 - InputAccessoryView, // 2a113ad4 - InputAccessoryViewProps, // 273c1565 + InputAccessoryView, // d664987a + InputAccessoryViewProps, // ac36060b InputModeOptions, // 4e8581b9 Insets, // e7fe432a InteractionManager, // c324d6e3 @@ -6069,8 +6082,8 @@ export { KeyEvent, // 20fa4267 KeyUpEvent, // bc6bd87b Keyboard, // 49414c97 - KeyboardAvoidingView, // 79591758 - KeyboardAvoidingViewProps, // 7cd981a2 + KeyboardAvoidingView, // 0b248a6b + KeyboardAvoidingViewProps, // 199d3c1b KeyboardEvent, // c3f895d4 KeyboardEventEasing, // af4091c8 KeyboardEventName, // 59299ad6 @@ -6095,9 +6108,9 @@ export { MeasureInWindowOnSuccessCallback, // a285f598 MeasureLayoutOnSuccessCallback, // 3592502a MeasureOnSuccessCallback, // 82824e59 - Modal, // dad0b1ce + Modal, // 9d28bb31 ModalBaseProps, // cbd3c10d - ModalProps, // 8e1508c6 + ModalProps, // 7aae1cd8 ModalPropsAndroid, // 515fb173 ModalPropsIOS, // 144bbc95 ModeChangeEvent, // a5e9864f @@ -6135,13 +6148,13 @@ export { PointerEvent, // fe3989a1 PressabilityConfig, // 6dedcb61 PressabilityEventHandlers, // 3e6c0f56 - Pressable, // aef6bb57 + Pressable, // f75f2b8f PressableAndroidRippleConfig, // 42bc9727 - PressableProps, // 3912691c + PressableProps, // d26f4e48 PressableStateCallbackType, // 9af36561 ProcessedColorValue, // 33f74304 - ProgressBarAndroid, // 36757db1 - ProgressBarAndroidProps, // 8bf4dfa6 + ProgressBarAndroid, // 3cb244f9 + ProgressBarAndroidProps, // 304ef6a4 PromiseTask, // 5102c862 PublicRootInstance, // 8040afd7 PublicTextInstance, // cd0d8f8d @@ -6150,8 +6163,8 @@ export { PushNotificationPermissions, // c2e7ae4f Rationale, // 5df1b1c1 ReactNativeVersion, // abd76827 - RefreshControl, // b8659b1f - RefreshControlProps, // e747ed5d + RefreshControl, // f50a1a99 + RefreshControlProps, // ef4eeaff RefreshControlPropsAndroid, // 99f64c97 RefreshControlPropsIOS, // 72a36381 Registry, // 6c39216d @@ -6163,21 +6176,21 @@ export { RootViewStyleProvider, // d4818465 Runnable, // 594dd93a Runnables, // 4367c557 - SafeAreaView, // 9589fa67 + SafeAreaView, // 69d0a921 ScaledSize, // 07e417c7 ScrollEvent, // 5d529218 - ScrollResponderType, // 114c7cc8 + ScrollResponderType, // c6354833 ScrollToLocationParamsType, // d7ecdad1 - ScrollView, // 7180dc9b - ScrollViewImperativeMethods, // 3f95ab77 - ScrollViewProps, // 57e1167c + ScrollView, // ff63e3a5 + ScrollViewImperativeMethods, // 121e0600 + ScrollViewProps, // c04015ea ScrollViewPropsAndroid, // 44210553 ScrollViewPropsIOS, // b34b696c ScrollViewScrollToOptions, // 3313411e SectionBase, // b376bddc - SectionList, // 8ef5401d + SectionList, // 408961c9 SectionListData, // 119baf83 - SectionListProps, // d88cd6f6 + SectionListProps, // 1b4a1372 SectionListRenderItem, // 1fad0435 SectionListRenderItemInfo, // 745e1992 Separators, // 6a45f7e3 @@ -6196,16 +6209,16 @@ export { StyleProp, // fa0e9b4a StyleSheet, // e77dd046 SubmitBehavior, // c4ddf490 - Switch, // 3434138b + Switch, // afcc80b4 SwitchChangeEvent, // 63e9c50b - SwitchProps, // 083b753d + SwitchProps, // 9c7f47a4 Systrace, // b5aa21fc TVViewPropsIOS, // 330ce7b5 TargetedEvent, // 16e98910 TaskProvider, // 266dedf2 Text, // 717d25fe TextContentType, // 239b3ecc - TextInput, // ed3a8375 + TextInput, // e6cad459 TextInputAndroidProps, // 3f09ce49 TextInputChangeEvent, // 3ab11bb4 TextInputContentSizeChangeEvent, // f71f8571 @@ -6213,7 +6226,7 @@ export { TextInputFocusEvent, // 020507e6 TextInputIOSProps, // 0d05a855 TextInputKeyPressEvent, // 3924ad9b - TextInputProps, // 9b370db2 + TextInputProps, // 7b2f6393 TextInputSelectionChangeEvent, // d4d10630 TextInputSubmitEditingEvent, // 22885c31 TextLayoutEvent, // 73ab173e @@ -6221,30 +6234,30 @@ export { TextStyle, // bb9b7a58 ToastAndroid, // 88a8969a Touchable, // da3239ee - TouchableHighlight, // 9d67503a - TouchableHighlightProps, // b2aa6f4b - TouchableNativeFeedback, // 2ed83cf4 - TouchableNativeFeedbackProps, // 1209959b - TouchableOpacity, // fcbaef78 - TouchableOpacityProps, // 13fbd043 - TouchableWithoutFeedback, // 39070327 - TouchableWithoutFeedbackProps, // b847de29 + TouchableHighlight, // bce323c0 + TouchableHighlightProps, // 1d48bf60 + TouchableNativeFeedback, // ae43168e + TouchableNativeFeedbackProps, // 5ac358c3 + TouchableOpacity, // c9c18d36 + TouchableOpacityProps, // 996c242f + TouchableWithoutFeedback, // 71d446ec + TouchableWithoutFeedbackProps, // e0c1c566 TransformsStyle, // 65e70f18 TurboModule, // dfe29706 TurboModuleRegistry, // 4ace6db2 UIManager, // a1a7cc01 UTFSequence, // ad625158 Vibration, // 31e4bbf8 - View, // 02678ca8 - ViewProps, // 15e5f6b9 + View, // c8649954 + ViewProps, // c4a93546 ViewPropsAndroid, // bdfc84a1 ViewPropsIOS, // 58ee19bf ViewStyle, // 00a0f8fb VirtualViewMode, // 6be59722 VirtualizedList, // 68c7345e - VirtualizedListProps, // d414f5ca + VirtualizedListProps, // f9caf7dc VirtualizedSectionList, // 9fd9cd61 - VirtualizedSectionListProps, // 01460821 + VirtualizedSectionListProps, // 3b35070b WrapperComponentProvider, // 9cf3844c codegenNativeCommands, // 628a7c0a codegenNativeComponent, // 2baac257 diff --git a/scripts/js-api/build-types/transforms/typescript/__tests__/convertTypeAliasesToInterfaces-test.js b/scripts/js-api/build-types/transforms/typescript/__tests__/convertTypeAliasesToInterfaces-test.js new file mode 100644 index 000000000000..fe63bb35aa10 --- /dev/null +++ b/scripts/js-api/build-types/transforms/typescript/__tests__/convertTypeAliasesToInterfaces-test.js @@ -0,0 +1,158 @@ +/** + * 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-local + * @format + */ + +const convertTypeAliasesToInterfaces = require('../convertTypeAliasesToInterfaces'); +const babel = require('@babel/core'); + +async function transform(code: string): Promise { + const result = await babel.transformAsync(code, { + plugins: [ + '@babel/plugin-syntax-typescript', + convertTypeAliasesToInterfaces, + ], + }); + + return result?.code ?? ''; +} + +describe('convertTypeAliasesToInterfaces', () => { + test('should not convert type without annotation', async () => { + const result = await transform( + 'declare type ViewProps = Readonly', + ); + expect(result).toBe('declare type ViewProps = Readonly;'); + }); + + test('should convert Readonly intersection of type references', async () => { + const result = await transform( + `/** @build-types emit-as-interface */ +declare type ViewProps = Readonly`, + ); + expect(result).toMatchInlineSnapshot( + `"declare interface ViewProps extends Readonly {}"`, + ); + }); + + test('should convert Readonly intersection with inline object literal', async () => { + const result = await transform( + `/** @build-types emit-as-interface */ +declare type Props = Readonly`, + ); + expect(result).toMatchInlineSnapshot(` +"declare interface Props extends Readonly { + readonly x?: string; + readonly y: number; +}" +`); + }); + + test('should convert Readonly wrapping pure object literal', async () => { + const result = await transform( + `/** @build-types emit-as-interface */ +declare type Props = Readonly<{ a: string; b?: number }>`, + ); + expect(result).toMatchInlineSnapshot(` +"declare interface Props { + readonly a: string; + readonly b?: number; +}" +`); + }); + + test('should convert plain object literal without Readonly', async () => { + const result = await transform( + `/** @build-types emit-as-interface */ +declare type Props = { readonly a: string; readonly b?: number }`, + ); + expect(result).toMatchInlineSnapshot(` +"declare interface Props { + readonly a: string; + readonly b?: number; +}" +`); + }); + + test('should convert intersection without Readonly', async () => { + const result = await transform( + `/** @build-types emit-as-interface */ +declare type Props = A & B`, + ); + expect(result).toMatchInlineSnapshot( + `"declare interface Props extends Readonly {}"`, + ); + }); + + test('should preserve generic type parameters', async () => { + const result = await transform( + `/** @build-types emit-as-interface */ +declare type FlatListProps = Omit & BaseProps`, + ); + expect(result).toMatchInlineSnapshot( + `"declare interface FlatListProps extends Readonly & BaseProps> {}"`, + ); + }); + + test('should handle Readonly wrapping Omit in intersection', async () => { + const result = await transform( + `/** @build-types emit-as-interface */ +declare type Props = Readonly & { alt?: string }>`, + ); + expect(result).toMatchInlineSnapshot(` +"declare interface Props extends Readonly> { + readonly alt?: string; +}" +`); + }); + + test('should preserve surrounding declarations', async () => { + const result = await transform( + `declare type Foo = string; +/** @build-types emit-as-interface */ +declare type Bar = Readonly; +declare type Baz = number;`, + ); + expect(result).toMatchInlineSnapshot(` +"declare type Foo = string; +declare interface Bar extends Readonly {} +declare type Baz = number;" +`); + }); + + test('should convert exported type with annotation on export', async () => { + const result = await transform( + `/** @build-types emit-as-interface */ +export type Props = Readonly`, + ); + expect(result).toMatchInlineSnapshot( + `"export interface Props extends Readonly {}"`, + ); + }); + + test('should throw on unsupported type structure', async () => { + await expect( + transform( + `/** @build-types emit-as-interface */ +declare type Props = string | number`, + ), + ).rejects.toThrow( + "Unsupported type structure for @build-types emit-as-interface on 'Props'", + ); + }); + + test('should handle single type reference', async () => { + const result = await transform( + `/** @build-types emit-as-interface */ +declare type Props = Readonly`, + ); + expect(result).toMatchInlineSnapshot( + `"declare interface Props extends Readonly {}"`, + ); + }); +}); diff --git a/scripts/js-api/build-types/transforms/typescript/convertTypeAliasesToInterfaces.js b/scripts/js-api/build-types/transforms/typescript/convertTypeAliasesToInterfaces.js new file mode 100644 index 000000000000..a9eb7c2375e9 --- /dev/null +++ b/scripts/js-api/build-types/transforms/typescript/convertTypeAliasesToInterfaces.js @@ -0,0 +1,193 @@ +/** + * 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-local + * @format + * @oncall react_native + */ + +import type {PluginObj} from '@babel/core'; + +import * as t from '@babel/types'; + +const ANNOTATION_PATTERN = /@build-types\s+emit-as-interface\b/; + +/** + * Convert `type` aliases annotated with `@build-types emit-as-interface` to + * an `interface` declaration. + * + * Nativewind and Expo/react-native-web rely on TypeScript module augmentation + * to extend props like `className` on React Native component types. This is + * only possible on `interface` declarations (open), not `type` (closed). + */ +function convertToInterface(path: $FlowFixMe): void { + stripAnnotationComments(path); + + const {typeAnnotation} = path.node; + let innerType = typeAnnotation; + let isReadonly = false; + + if ( + t.isTSTypeReference(typeAnnotation) && + t.isIdentifier(typeAnnotation.typeName, {name: 'Readonly'}) && + typeAnnotation.typeParameters?.params.length === 1 + ) { + isReadonly = true; + innerType = typeAnnotation.typeParameters.params[0]; + } + + const extendsClauses: Array = []; + const bodyMembers: Array = []; + + if (t.isTSIntersectionType(innerType)) { + const refMembers: Array = []; + for (const member of innerType.types) { + if (t.isTSTypeLiteral(member)) { + const clonedMembers = t.cloneDeep(member).members; + if (isReadonly) { + makePropertiesReadonly(clonedMembers); + } + bodyMembers.push(...clonedMembers); + } else { + refMembers.push(t.cloneDeep(member)); + } + } + if (refMembers.length > 0) { + const refsType = + refMembers.length === 1 + ? refMembers[0] + : t.tsIntersectionType(refMembers); + if (isReadonly) { + extendsClauses.push( + t.tsExpressionWithTypeArguments( + t.identifier('Readonly'), + t.tsTypeParameterInstantiation([refsType]), + ), + ); + } else { + extendsClauses.push(typeToExtendsClause(refsType, false)); + } + } + } else if (t.isTSTypeLiteral(innerType)) { + const clonedMembers = t.cloneDeep(innerType).members; + if (isReadonly) { + makePropertiesReadonly(clonedMembers); + } + bodyMembers.push(...clonedMembers); + } else if (t.isTSTypeReference(innerType)) { + extendsClauses.push(typeToExtendsClause(innerType, isReadonly)); + } else { + throw new Error( + `Unsupported type structure for @build-types emit-as-interface on '${path.node.id.name}'. Only object literals, type references, and intersections of these are supported.`, + ); + } + + const interfaceNode = t.tsInterfaceDeclaration( + t.cloneDeep(path.node.id), + path.node.typeParameters + ? t.cloneDeep(path.node.typeParameters) + : undefined, + extendsClauses.length > 0 ? extendsClauses : undefined, + t.tsInterfaceBody(bodyMembers), + ); + interfaceNode.declare = path.node.declare ?? false; + + path.replaceWith(interfaceNode); +} + +function hasAnnotationInComments( + comments: ?ReadonlyArray<{type: string, value: string}>, +): boolean { + return ( + Array.isArray(comments) && + comments.some( + comment => + comment.type === 'CommentBlock' && + ANNOTATION_PATTERN.test(comment.value), + ) + ); +} + +function hasEmitAsInterfaceAnnotation(path: $FlowFixMe): boolean { + if (hasAnnotationInComments(path.node.leadingComments)) { + return true; + } + if ( + path.parentPath?.isExportNamedDeclaration() && + hasAnnotationInComments(path.parentPath.node.leadingComments) + ) { + return true; + } + return false; +} + +function typeToExtendsClause( + tsType: t.TSType, + wrapInReadonly: boolean, +): t.TSExpressionWithTypeArguments { + if (wrapInReadonly) { + return t.tsExpressionWithTypeArguments( + t.identifier('Readonly'), + t.tsTypeParameterInstantiation([t.cloneDeep(tsType)]), + ); + } + + if (t.isTSTypeReference(tsType) && t.isIdentifier(tsType.typeName)) { + return t.tsExpressionWithTypeArguments( + t.cloneDeep(tsType.typeName), + tsType.typeParameters ? t.cloneDeep(tsType.typeParameters) : undefined, + ); + } + + return t.tsExpressionWithTypeArguments( + t.identifier('Readonly'), + t.tsTypeParameterInstantiation([t.cloneDeep(tsType)]), + ); +} + +function makePropertiesReadonly(members: Array): void { + for (const member of members) { + if (t.isTSPropertySignature(member)) { + member.readonly = true; + } + } +} + +function stripAnnotationComments(path: $FlowFixMe): void { + const filter = (comments: $FlowFixMe) => + comments?.filter( + (c: $FlowFixMe) => + !(c.type === 'CommentBlock' && ANNOTATION_PATTERN.test(c.value)), + ) ?? []; + path.node.leadingComments = filter(path.node.leadingComments); + if (path.parentPath?.isExportNamedDeclaration()) { + path.parentPath.node.leadingComments = filter( + path.parentPath.node.leadingComments, + ); + } + const target = path.parentPath?.isExportNamedDeclaration() + ? path.parentPath + : path; + const prevSibling = target.getPrevSibling(); + if (prevSibling?.node) { + prevSibling.node.trailingComments = filter( + prevSibling.node.trailingComments, + ); + } +} + +const visitor: PluginObj = { + visitor: { + TSTypeAliasDeclaration(path) { + if (!hasEmitAsInterfaceAnnotation(path)) { + return; + } + convertToInterface(path); + }, + }, +}; + +module.exports = visitor; diff --git a/scripts/js-api/build-types/translateSourceFile.js b/scripts/js-api/build-types/translateSourceFile.js index 1bdb5fc0d4f8..1987f84b00b7 100644 --- a/scripts/js-api/build-types/translateSourceFile.js +++ b/scripts/js-api/build-types/translateSourceFile.js @@ -29,6 +29,7 @@ const preTransforms: Array = [ require('./transforms/flow/ensureNoUnprefixedProps'), ]; const postTransforms = (filePath: string): Array> => [ + require('./transforms/typescript/convertTypeAliasesToInterfaces'), require('./transforms/typescript/replaceDefaultExportName')(filePath), ]; const prettierOptions = {parser: 'babel'};