Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TS migration] Improve ref types #40698

Merged
23 changes: 23 additions & 0 deletions contributingGuides/TS_STYLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
- [1.20 Hooks instead of HOCs](#hooks-instead-of-hocs)
- [1.21 `compose` usage](#compose-usage)
- [1.22 Type imports](#type-imports)
- [1.23 Ref types](#ref-types)
- [Exception to Rules](#exception-to-rules)
- [Communication Items](#communication-items)
- [Migration Guidelines](#migration-guidelines)
Expand Down Expand Up @@ -640,6 +641,28 @@ type Foo = {
export someVariable
```

- [1.23](#ref-types) **Ref types**: Avoid using HTML elements while declaring refs. Please use React Native components where possible. React Native Web handles the references on its own. It also extends React Native components [with Interaction API](https://necolas.github.io/react-native-web/docs/interactions/) which should be used to handle Pointers and Mouse events. Exception of this rule is when we explicitly need to use functions available only in DOM and not in React Native, e.g. `getBoundingClientRect`. Then please declare ref type as `union` of React Native component and HTML element. When passing it to React Native component cast it as soon as possible using utility methods declared in `src/types/utils`
jnowakow marked this conversation as resolved.
Show resolved Hide resolved

Normal usage:
```ts
jnowakow marked this conversation as resolved.
Show resolved Hide resolved
const ref = useRef<View>();

<View ref={ref} onPointerDown={e => {#DO SOMETHING}}>
```

Exceptional usage where DOM methods are necessary:
```ts
jnowakow marked this conversation as resolved.
Show resolved Hide resolved
import viewRef from '@src/types/utils/viewRef';

const ref = useRef<View | HTMLDivElement>();

if (ref.current && 'getBoundingClientRect' in ref.current ){
jnowakow marked this conversation as resolved.
Show resolved Hide resolved
ref.current.getBoundingClientRect();
}

<View ref={viewRef(ref)} onPointerDown={e => {#DO SOMETHING}}>
```

## Exception to Rules

Most of the rules are enforced in ESLint or checked by TypeScript. If you think your particular situation warrants an exception, post the context in the `#expensify-open-source` Slack channel with your message prefixed with `TS EXCEPTION:`. The internal engineer assigned to the PR should be the one that approves each exception, however all discussion regarding granting exceptions should happen in the public channel instead of the GitHub PR page so that the TS migration team can access them easily.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {FlashList} from '@shopify/flash-list';
import type {ForwardedRef, ReactElement} from 'react';
import React, {forwardRef, useCallback, useEffect, useMemo, useRef} from 'react';
import type {View} from 'react-native';
import type {ReactElement} from 'react';
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
// We take ScrollView from this package to properly handle the scrolling of AutoCompleteSuggestions in chats since one scroll is nested inside another
import {ScrollView} from 'react-native-gesture-handler';
import Animated, {Easing, FadeOutDown, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
Expand All @@ -10,9 +9,9 @@ import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import viewForwardedRef from '@src/types/utils/viewForwardedRef';
import type {AutoCompleteSuggestionsProps, RenderSuggestionMenuItemProps} from './types';

const measureHeightOfSuggestionRows = (numRows: number, isSuggestionPickerLarge: boolean): number => {
Expand All @@ -30,18 +29,22 @@ const measureHeightOfSuggestionRows = (numRows: number, isSuggestionPickerLarge:
return numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT;
};

function BaseAutoCompleteSuggestions<TSuggestion>(
{
highlightedSuggestionIndex,
onSelect,
accessibilityLabelExtractor,
renderSuggestionMenuItem,
suggestions,
isSuggestionPickerLarge,
keyExtractor,
}: AutoCompleteSuggestionsProps<TSuggestion>,
ref: ForwardedRef<View | HTMLDivElement>,
) {
/**
* On the mobile-web platform, when long-pressing on auto-complete suggestions,
* we need to prevent focus shifting to avoid blurring the main input (which makes the suggestions picker close and fires the onSelect callback).
* The desired pattern for all platforms is to do nothing on long-press.
* On the native platform, tapping on auto-complete suggestions will not blur the main input.
*/

function BaseAutoCompleteSuggestions<TSuggestion>({
highlightedSuggestionIndex,
onSelect,
accessibilityLabelExtractor,
renderSuggestionMenuItem,
suggestions,
isSuggestionPickerLarge,
keyExtractor,
}: AutoCompleteSuggestionsProps<TSuggestion>) {
const {windowWidth, isLargeScreenWidth} = useWindowDimensions();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
Expand Down Expand Up @@ -92,9 +95,14 @@ function BaseAutoCompleteSuggestions<TSuggestion>(

return (
<Animated.View
ref={viewForwardedRef(ref)}
style={[styles.autoCompleteSuggestionsContainer, animatedStyles]}
exiting={FadeOutDown.duration(100).easing(Easing.inOut(Easing.ease))}
onPointerDown={(e) => {
if (DeviceCapabilities.hasHoverSupport()) {
return;
}
e.preventDefault();
}}
>
<ColorSchemeWrapper>
<FlashList
Expand All @@ -117,4 +125,4 @@ function BaseAutoCompleteSuggestions<TSuggestion>(

BaseAutoCompleteSuggestions.displayName = 'BaseAutoCompleteSuggestions';

export default forwardRef(BaseAutoCompleteSuggestions);
export default BaseAutoCompleteSuggestions;
23 changes: 0 additions & 23 deletions src/components/AutoCompleteSuggestions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,17 @@ import ReactDOM from 'react-dom';
import {View} from 'react-native';
import useStyleUtils from '@hooks/useStyleUtils';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions';
import type {AutoCompleteSuggestionsProps} from './types';

/**
* On the mobile-web platform, when long-pressing on auto-complete suggestions,
* we need to prevent focus shifting to avoid blurring the main input (which makes the suggestions picker close and fires the onSelect callback).
* The desired pattern for all platforms is to do nothing on long-press.
* On the native platform, tapping on auto-complete suggestions will not blur the main input.
*/

function AutoCompleteSuggestions<TSuggestion>({measureParentContainer = () => {}, ...props}: AutoCompleteSuggestionsProps<TSuggestion>) {
const StyleUtils = useStyleUtils();
const containerRef = React.useRef<HTMLDivElement>(null);
const {windowHeight, windowWidth} = useWindowDimensions();
const [{width, left, bottom}, setContainerState] = React.useState({
width: 0,
left: 0,
bottom: 0,
});
React.useEffect(() => {
const container = containerRef.current;
if (!container) {
return () => {};
}
container.onpointerdown = (e) => {
if (DeviceCapabilities.hasHoverSupport()) {
return;
}
e.preventDefault();
};
return () => (container.onpointerdown = null);
}, []);

React.useEffect(() => {
if (!measureParentContainer) {
Expand All @@ -48,7 +26,6 @@ function AutoCompleteSuggestions<TSuggestion>({measureParentContainer = () => {}
<BaseAutoCompleteSuggestions<TSuggestion>
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
ref={containerRef}
/>
);

Expand Down
2 changes: 1 addition & 1 deletion src/components/Button/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ function Button(
accessibilityLabel = '',
...rest
}: ButtonProps,
ref: ForwardedRef<View | HTMLDivElement>,
ref: ForwardedRef<View>,
) {
const theme = useTheme();
const styles = useThemeStyles();
Expand Down
2 changes: 1 addition & 1 deletion src/components/ButtonWithDropdownMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function ButtonWithDropdownMenu<IValueType>({
const [isMenuVisible, setIsMenuVisible] = useState(false);
const [popoverAnchorPosition, setPopoverAnchorPosition] = useState<AnchorPosition | null>(null);
const {windowWidth, windowHeight} = useWindowDimensions();
const caretButton = useRef<View & HTMLDivElement>(null);
const caretButton = useRef<View>(null);
const selectedItem = options[selectedItemIndex] || options[0];
const innerStyleDropButton = StyleUtils.getDropDownButtonHeight(buttonSize);
const isButtonSizeLarge = buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE;
Expand Down
8 changes: 5 additions & 3 deletions src/components/DragAndDrop/NoDropZone/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,22 @@ import React, {useRef} from 'react';
import {View} from 'react-native';
import useDragAndDrop from '@hooks/useDragAndDrop';
import useThemeStyles from '@hooks/useThemeStyles';
import htmlDivElementRef from '@src/types/utils/htmlDivElementRef';
import viewRef from '@src/types/utils/viewRef';
import type NoDropZoneProps from './types';

function NoDropZone({children}: NoDropZoneProps) {
const styles = useThemeStyles();
const noDropZone = useRef<View>(null);
const noDropZone = useRef<View | HTMLDivElement>(null);

useDragAndDrop({
dropZone: noDropZone,
dropZone: htmlDivElementRef(noDropZone),
shouldAllowDrop: false,
});

return (
<View
ref={noDropZone}
ref={viewRef(noDropZone)}
style={[styles.fullScreen]}
>
{children}
Expand Down
8 changes: 5 additions & 3 deletions src/components/DragAndDrop/Provider/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import {View} from 'react-native';
import useDragAndDrop from '@hooks/useDragAndDrop';
import useThemeStyles from '@hooks/useThemeStyles';
import htmlDivElementRef from '@src/types/utils/htmlDivElementRef';
import viewRef from '@src/types/utils/viewRef';
import type {DragAndDropContextParams, DragAndDropProviderProps, SetOnDropHandlerCallback} from './types';

const DragAndDropContext = React.createContext<DragAndDropContextParams>({});
Expand All @@ -14,7 +16,7 @@ function shouldAcceptDrop(event: DragEvent): boolean {

function DragAndDropProvider({children, isDisabled = false, setIsDraggingOver = () => {}}: DragAndDropProviderProps) {
const styles = useThemeStyles();
const dropZone = useRef<View>(null);
const dropZone = useRef<HTMLDivElement | View>(null);
const dropZoneID = useRef(Str.guid('drag-n-drop'));

const onDropHandler = useRef<SetOnDropHandlerCallback>(() => {});
Expand All @@ -23,7 +25,7 @@ function DragAndDropProvider({children, isDisabled = false, setIsDraggingOver =
}, []);

const {isDraggingOver} = useDragAndDrop({
dropZone,
dropZone: htmlDivElementRef(dropZone),
onDrop: onDropHandler.current,
shouldAcceptDrop,
isDisabled,
Expand All @@ -38,7 +40,7 @@ function DragAndDropProvider({children, isDisabled = false, setIsDraggingOver =
return (
<DragAndDropContext.Provider value={contextValue}>
<View
ref={dropZone}
ref={viewRef(dropZone)}
style={[styles.flex1, styles.w100, styles.h100]}
>
{isDraggingOver && (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, {useEffect, useRef} from 'react';
import type {View} from 'react-native';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import Text from '@components/Text';
import useStyleUtils from '@hooks/useStyleUtils';
Expand All @@ -18,7 +19,7 @@ function EmojiPickerMenuItem({
isHighlighted = false,
isUsingKeyboardMovement = false,
}: EmojiPickerMenuItemProps) {
const ref = useRef<HTMLDivElement | null>(null);
const ref = useRef<View>(null);
const StyleUtils = useStyleUtils();
const themeStyles = useThemeStyles();

Expand Down
2 changes: 1 addition & 1 deletion src/components/FocusableMenuItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {MenuItemProps} from './MenuItem';
import MenuItem from './MenuItem';

function FocusableMenuItem(props: MenuItemProps) {
const ref = useRef<HTMLDivElement | View>(null);
const ref = useRef<View>(null);

// Sync focus on an item
useSyncFocus(ref, Boolean(props.focused));
Expand Down
2 changes: 1 addition & 1 deletion src/components/KYCWall/BaseKYCWall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ function KYCWall({
walletTerms,
shouldShowPersonalBankAccountOption = false,
}: BaseKYCWallProps) {
const anchorRef = useRef<HTMLDivElement | View | null>(null);
const anchorRef = useRef<HTMLDivElement | View>(null);
const transferBalanceButtonRef = useRef<HTMLDivElement | View | null>(null);

const [shouldShowAddPaymentMenu, setShouldShowAddPaymentMenu] = useState(false);
Expand Down
2 changes: 1 addition & 1 deletion src/components/SelectionList/BaseListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function BaseListItem<TItem extends ListItem>({
const styles = useThemeStyles();
const {hovered, bind} = useHover();

const pressableRef = useRef<View | HTMLDivElement>(null);
const pressableRef = useRef<View>(null);

// Sync focus on an item
useSyncFocus(pressableRef, Boolean(isFocused), shouldSyncFocus);
Expand Down
7 changes: 4 additions & 3 deletions src/components/TextInput/TextInputLabel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import type {Text} from 'react-native';
import {Animated} from 'react-native';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
import textRef from '@src/types/utils/textRef';
import type TextInputLabelProps from './types';

function TextInputLabel({for: inputId = '', label, labelTranslateY, labelScale}: TextInputLabelProps) {
const styles = useThemeStyles();
const labelRef = useRef<Text & HTMLFormElement>(null);
const labelRef = useRef<Text | HTMLFormElement>(null);

useEffect(() => {
if (!inputId || !labelRef.current) {
if (!inputId || !labelRef.current || !('setAttribute' in labelRef.current)) {
return;
}
labelRef.current.setAttribute('for', inputId);
Expand All @@ -20,7 +21,7 @@ function TextInputLabel({for: inputId = '', label, labelTranslateY, labelScale}:

return (
<Animated.Text
ref={labelRef}
ref={textRef(labelRef)}
role={CONST.ROLE.PRESENTATION}
style={[styles.textInputLabel, styles.textInputLabelDesktop, styles.textInputLabelTransformation(labelTranslateY, 0, labelScale), styles.pointerEventsNone]}
>
Expand Down
2 changes: 1 addition & 1 deletion src/components/ThreeDotsMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function ThreeDotsMenu({
const theme = useTheme();
const styles = useThemeStyles();
const [isPopupMenuVisible, setPopupMenuVisible] = useState(false);
const buttonRef = useRef<HTMLDivElement | null>(null);
const buttonRef = useRef<View>(null);
const {translate} = useLocalize();
const isBehindModal = modal?.willAlertModalBecomeVisible && !modal?.isPopover && !shouldOverlay;

Expand Down
2 changes: 1 addition & 1 deletion src/components/WorkspaceSwitcherButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function WorkspaceSwitcherButton({policy}: WorkspaceSwitcherButtonProps) {
const {translate} = useLocalize();
const theme = useTheme();

const pressableRef = useRef<HTMLDivElement | View | null>(null);
const pressableRef = useRef<View>(null);

const {source, name, type} = useMemo(() => {
if (!policy) {
Expand Down
5 changes: 5 additions & 0 deletions src/hooks/useDragAndDrop/index.native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type UseDragAndDrop from './types';

const useDragAndDrop: UseDragAndDrop = () => ({isDraggingOver: false});

export default useDragAndDrop;
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import {useIsFocused} from '@react-navigation/native';
import type React from 'react';
import {useCallback, useContext, useEffect, useRef, useState} from 'react';
import type {View} from 'react-native';
import {PopoverContext} from '@components/PopoverProvider';
import type UseDragAndDrop from './types';

const COPY_DROP_EFFECT = 'copy';
const NONE_DROP_EFFECT = 'none';
Expand All @@ -11,22 +10,10 @@ const DRAG_OVER_EVENT = 'dragover';
const DRAG_LEAVE_EVENT = 'dragleave';
const DROP_EVENT = 'drop';

type DragAndDropParams = {
dropZone: React.MutableRefObject<HTMLDivElement | View | null>;
onDrop?: (event: DragEvent) => void;
shouldAllowDrop?: boolean;
isDisabled?: boolean;
shouldAcceptDrop?: (event: DragEvent) => boolean;
};

type DragAndDropOptions = {
isDraggingOver: boolean;
};

/**
* @param dropZone – ref to the dropZone component
*/
export default function useDragAndDrop({dropZone, onDrop = () => {}, shouldAllowDrop = true, isDisabled = false, shouldAcceptDrop = () => true}: DragAndDropParams): DragAndDropOptions {
const useDragAndDrop: UseDragAndDrop = ({dropZone, onDrop = () => {}, shouldAllowDrop = true, isDisabled = false, shouldAcceptDrop = () => true}) => {
const isFocused = useIsFocused();
const [isDraggingOver, setIsDraggingOver] = useState(false);
const {close: closePopover} = useContext(PopoverContext);
Expand Down Expand Up @@ -111,7 +98,7 @@ export default function useDragAndDrop({dropZone, onDrop = () => {}, shouldAllow
return;
}

const dropZoneRef = dropZone.current as HTMLDivElement;
const dropZoneRef = dropZone.current;

// Note that the dragover event needs to be called with `event.preventDefault` in order for the drop event to be fired:
// https://stackoverflow.com/questions/21339924/drop-event-not-firing-in-chrome
Expand All @@ -133,4 +120,6 @@ export default function useDragAndDrop({dropZone, onDrop = () => {}, shouldAllow
}, [dropZone, dropZoneDragHandler]);

return {isDraggingOver};
}
};

export default useDragAndDrop;
Loading
Loading