Skip to content

Commit 0ad0ae9

Browse files
authored
Merge pull request Expensify#84887 from software-mansion-labs/revert/revert/migrate-NewChatPage
Reapply "Merge pull request Expensify#81869 from software-mansion-labs/@zfurtak/migrate-NewChatPage"
2 parents 3cc8013 + f22a391 commit 0ad0ae9

16 files changed

Lines changed: 253 additions & 110 deletions

File tree

__mocks__/@react-navigation/native/index.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,20 @@ const {triggerTransitionEnd, addListener} = isJestEnv
1717
addListener: () => {},
1818
};
1919

20-
const realOrMockedUseNavigation = isJestEnv ? realReactNavigation.useNavigation : {};
21-
const useNavigation = () => ({
22-
...realOrMockedUseNavigation,
23-
navigate: isJestEnv ? jest.fn() : () => {},
24-
getState: () => ({
25-
routes: [],
26-
}),
27-
addListener,
28-
});
20+
const useNavigation = isJestEnv
21+
? realReactNavigation.useNavigation
22+
: {
23+
navigate: isJestEnv ? jest.fn() : () => {},
24+
getState: () => ({
25+
routes: [],
26+
}),
27+
addListener,
28+
};
2929

3030
type NativeNavigationMock = typeof ReactNavigation & {
3131
triggerTransitionEnd: () => void;
3232
};
3333

34-
export * from '@react-navigation/core';
3534
const Link = isJestEnv ? realReactNavigation.Link : () => null;
3635
const LinkingContext = isJestEnv ? realReactNavigation.LinkingContext : () => null;
3736
const NavigationContainer = isJestEnv ? realReactNavigation.NavigationContainer : () => null;
@@ -46,14 +45,16 @@ const useScrollToTop = isJestEnv ? realReactNavigation.useScrollToTop : () => nu
4645
const useRoute = isJestEnv ? realReactNavigation.useRoute : () => ({params: {}});
4746
const useFocusEffect = isJestEnv ? realReactNavigation.useFocusEffect : (callback: () => void) => callback();
4847
const usePreventRemove = isJestEnv ? jest.fn() : () => {};
48+
const useNavigationState = isJestEnv ? realReactNavigation.useNavigationState : () => {};
4949

50+
export * from '@react-navigation/core';
5051
export {
5152
// Overridden modules
5253
useIsFocused,
5354
useTheme,
5455
useNavigation,
56+
useNavigationState,
5557
useLocale,
56-
triggerTransitionEnd,
5758

5859
// Theme modules are left alone
5960
Link,
@@ -63,6 +64,7 @@ export {
6364
DarkTheme,
6465
DefaultTheme,
6566
ThemeProvider,
67+
triggerTransitionEnd,
6668
useLinkBuilder,
6769
useLinkProps,
6870
useLinkTo,
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
2+
index 8b75322..dd2d3bc 100644
3+
--- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
4+
+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
5+
@@ -74,6 +74,10 @@ const RecyclerViewComponent = (props, ref) => {
6+
if (internalViewRef.current && firstChildViewRef.current) {
7+
// Measure the outer container size and inner container layout
8+
const outerViewSize = measureParentSize(internalViewRef.current);
9+
+ if (outerViewSize.width === 0 && outerViewSize.height === 0) {
10+
+ containerViewSizeRef.current = outerViewSize;
11+
+ return;
12+
+ }
13+
const firstChildViewLayout = measureFirstChildLayout(firstChildViewRef.current, internalViewRef.current);
14+
containerViewSizeRef.current = outerViewSize;
15+
// firstChildViewLayout is already relative to the outer container,
16+
@@ -103,6 +107,10 @@ const RecyclerViewComponent = (props, ref) => {
17+
if (pendingChildIds.size > 0) {
18+
return;
19+
}
20+
+ if (((_a = containerViewSizeRef.current) === null || _a === void 0 ? void 0 : _a.width) === 0 &&
21+
+ ((_b = containerViewSizeRef.current) === null || _b === void 0 ? void 0 : _b.height) === 0) {
22+
+ return;
23+
+ }
24+
const layoutInfo = Array.from(refHolder, ([index, viewHolderRef]) => {
25+
const layout = measureItemLayout(viewHolderRef.current, recyclerViewManager.tryGetLayout(index));
26+
// comapre height with stored layout

patches/@shopify/flash-list/details.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,14 @@
99
- Upstream PR/issue: TBD
1010
- E/App issue: https://github.com/Expensify/App/issues/33725
1111
- PR introducing patch: https://github.com/Expensify/App/pull/81566
12+
13+
### [@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch](@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch)
14+
15+
- Reason: Prevents FlashList from losing its render state when a navigation stack hides the parent container with `display: none`. Two early-return guards added in `RecyclerView`:
16+
1. **First `useLayoutEffect`** (measures parent container): After calling `measureParentSize()`, if both width and height are 0, return early before calling `updateLayoutParams()` or updating `containerViewSizeRef`. This preserves the last known valid window size and prevents the layout manager from receiving zero dimensions.
17+
2. **Second `useLayoutEffect`** (measures individual items): If `containerViewSizeRef.current` is 0x0 (because the first effect bailed out), return early before calling `modifyChildrenLayout()`. This prevents item measurements taken under `display: none` (also 0) from corrupting stored layouts.
18+
When the container becomes visible again, `onLayout` fires (React Native Web uses ResizeObserver), triggering a re-render with correct dimensions so FlashList resumes normally without re-initialization.
19+
- Files changed: Both `src/recyclerview/RecyclerView.tsx` and `dist/recyclerview/RecyclerView.js`. The `src/` file contains the full explanatory comments describing the intent of each guard. The `dist/` file contains only the bare code without comments, since it is compiled output. If the `dist/` file changes in a future version, refer to the `src/` diff to understand the intent and re-apply the equivalent guards.
20+
- Upstream PR/issue: TBD
21+
- E/App issue: https://github.com/Expensify/App/issues/83976
22+
- PR introducing patch: https://github.com/Expensify/App/pull/84887

src/components/Form/FormProvider.tsx

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import {useFocusEffect} from '@react-navigation/native';
21
import {deepEqual} from 'fast-equals';
32
import type {ForwardedRef, ReactNode, RefObject} from 'react';
43
import React, {createRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
@@ -9,6 +8,7 @@ import {useInputBlurActions} from '@components/InputBlurContext';
98
import type {LocalizedTranslate} from '@components/LocaleContextProvider';
109
import {getIsRestoringKeyboardFocus} from '@components/TextInput';
1110
import useDebounceNonReactive from '@hooks/useDebounceNonReactive';
11+
import useIsFocusedRef from '@hooks/useIsFocusedRef';
1212
import useLocalize from '@hooks/useLocalize';
1313
import useOnyx from '@hooks/useOnyx';
1414
import {isSafari} from '@libs/Browser';
@@ -286,16 +286,7 @@ function FormProvider({
286286

287287
// Keep track of the focus state of the current screen.
288288
// This is used to prevent validating the form on blur before it has been interacted with.
289-
const isFocusedRef = useRef(true);
290-
291-
useFocusEffect(
292-
useCallback(() => {
293-
isFocusedRef.current = true;
294-
return () => {
295-
isFocusedRef.current = false;
296-
};
297-
}, []),
298-
);
289+
const isFocusedRef = useIsFocusedRef();
299290

300291
const resetForm = useCallback(
301292
(optionalValue: FormOnyxValues) => {
@@ -474,7 +465,21 @@ function FormProvider({
474465
},
475466
};
476467
},
477-
[draftValues, inputValues, formState?.errorFields, errors, submit, setTouchedInput, shouldValidateOnBlur, onValidate, hasServerError, setIsBlurred, formID, shouldValidateOnChange],
468+
[
469+
draftValues,
470+
inputValues,
471+
formState?.errorFields,
472+
errors,
473+
submit,
474+
setTouchedInput,
475+
shouldValidateOnBlur,
476+
onValidate,
477+
hasServerError,
478+
setIsBlurred,
479+
formID,
480+
shouldValidateOnChange,
481+
isFocusedRef,
482+
],
478483
);
479484
const value = useMemo(() => ({registerInput}), [registerInput]);
480485

src/components/SelectionList/ListItem/BaseListItem.tsx

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {getButtonRole} from '@components/Button/utils';
44
import Icon from '@components/Icon';
55
import OfflineWithFeedback from '@components/OfflineWithFeedback';
66
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
7+
import type {PressableWithFeedbackProps} from '@components/Pressable/PressableWithFeedback';
8+
import getAccessibilityLabel from '@components/SelectionList/utils/getAccessibilityLabel';
79
import useHover from '@hooks/useHover';
810
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
911
import {useMouseActions, useMouseState} from '@hooks/useMouseContext';
@@ -15,6 +17,46 @@ import variables from '@styles/variables';
1517
import CONST from '@src/CONST';
1618
import type {BaseListItemProps, ListItem} from './types';
1719

20+
type AccessibilityProps = Pick<PressableWithFeedbackProps, 'accessible' | 'role' | 'tabIndex'>;
21+
22+
type CalculatedAccessibilityProps = Pick<PressableWithFeedbackProps, 'role' | 'tabIndex' | 'accessibilityState'> & {
23+
accessibleAndAccessibilityLabel: Pick<PressableWithFeedbackProps, 'accessible' | 'accessibilityLabel'>;
24+
};
25+
26+
function getAccessibilityProps<TItem extends ListItem>({
27+
role,
28+
tabIndex,
29+
accessible,
30+
item,
31+
isFocused,
32+
canSelectMultiple,
33+
}: AccessibilityProps & Pick<BaseListItemProps<TItem>, 'item' | 'isFocused' | 'canSelectMultiple'>) {
34+
const accessibilityState = role === CONST.ROLE.CHECKBOX || role === CONST.ROLE.RADIO ? {checked: !!item.isSelected, selected: !!isFocused} : {selected: !!item.isSelected};
35+
36+
if (accessible === false) {
37+
return {
38+
role: CONST.ROLE.PRESENTATION,
39+
tabIndex: -1,
40+
accessibilityState,
41+
accessibleAndAccessibilityLabel: {accessible: false},
42+
} satisfies CalculatedAccessibilityProps;
43+
}
44+
45+
const accessibilityLabel = getAccessibilityLabel(item);
46+
47+
// For single-select lists, use role="option" with aria-selected so screen readers announce "selected"/"not selected".
48+
// For multi-select (checkbox/radio), keep existing role and state.
49+
const isSelectableOption = !canSelectMultiple && role !== CONST.ROLE.CHECKBOX && role !== CONST.ROLE.RADIO;
50+
const effectiveRole = isSelectableOption ? CONST.ROLE.OPTION : role;
51+
52+
return {
53+
role: effectiveRole,
54+
tabIndex,
55+
accessibilityState,
56+
accessibleAndAccessibilityLabel: {accessible: undefined, accessibilityLabel},
57+
} satisfies CalculatedAccessibilityProps;
58+
}
59+
1860
function BaseListItem<TItem extends ListItem>({
1961
item,
2062
pressableStyle,
@@ -84,12 +126,14 @@ function BaseListItem<TItem extends ListItem>({
84126

85127
const shouldShowHiddenCheckmark = shouldShowRBRIndicator && !shouldShowCheckmark && !!item.canShowSeveralIndicators;
86128

87-
// For single-select lists, use role="option" with aria-selected so screen readers announce "selected"/"not selected".
88-
// For multi-select (checkbox/radio), keep existing role and state.
89-
const isSelectableOption = !canSelectMultiple && accessibilityRole !== CONST.ROLE.CHECKBOX && accessibilityRole !== CONST.ROLE.RADIO;
90-
const effectiveRole = isSelectableOption ? CONST.ROLE.OPTION : accessibilityRole;
91-
const accessibilityState =
92-
accessibilityRole === CONST.ROLE.CHECKBOX || accessibilityRole === CONST.ROLE.RADIO ? {checked: !!item.isSelected, selected: !!isFocused} : {selected: !!item.isSelected};
129+
const {role, tabIndex, accessibilityState, accessibleAndAccessibilityLabel} = getAccessibilityProps({
130+
role: accessibilityRole,
131+
accessible,
132+
tabIndex: item.tabIndex,
133+
item,
134+
isFocused,
135+
canSelectMultiple,
136+
});
93137

94138
return (
95139
<OfflineWithFeedback
@@ -121,8 +165,6 @@ function BaseListItem<TItem extends ListItem>({
121165
}}
122166
disabled={isDisabled && !item.isSelected}
123167
interactive={item.isInteractive}
124-
accessibilityLabel={item.accessibilityLabel ?? [item.text, item.text !== item.alternateText ? item.alternateText : undefined].filter(Boolean).join(', ')}
125-
accessibilityState={accessibilityState}
126168
isNested
127169
hoverDimmingValue={1}
128170
pressDimmingValue={item.isInteractive === false ? 1 : variables.pressDimValue}
@@ -142,12 +184,14 @@ function BaseListItem<TItem extends ListItem>({
142184
StyleUtils.getItemBackgroundColorStyle(!!item.isSelected, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG),
143185
]}
144186
onFocus={onFocus}
187+
role={role}
188+
tabIndex={tabIndex}
189+
// eslint-disable-next-line react/jsx-props-no-spreading -- we can't pass those props here on their own because this Component expects a discriminated Union
190+
{...accessibleAndAccessibilityLabel}
191+
accessibilityState={accessibilityState}
145192
onMouseLeave={handleMouseLeave}
146-
tabIndex={accessible === false ? -1 : item.tabIndex}
147193
wrapperStyle={pressableWrapperStyle}
148194
testID={`${CONST.BASE_LIST_ITEM_TEST_ID}${item.keyForList}`}
149-
accessible={accessible}
150-
role={accessible === false ? CONST.ROLE.PRESENTATION : effectiveRole}
151195
>
152196
<View
153197
testID={testID}

src/components/SelectionList/ListItem/UserListItem.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import Icon from '@components/Icon';
66
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
77
import ReportActionAvatars from '@components/ReportActionAvatars';
88
import {ListItemFocusContext} from '@components/SelectionList/ListItemFocusContext';
9+
import getAccessibilityLabel from '@components/SelectionList/utils/getAccessibilityLabel';
910
import Text from '@components/Text';
1011
import TextWithTooltip from '@components/TextWithTooltip';
1112
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
@@ -60,8 +61,8 @@ function UserListItem<TItem extends ListItem>({
6061
}
6162
}, [item, onCheckboxPress, onSelectRow]);
6263

63-
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
64-
const [isReportInOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`, {
64+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- some utils that are used to get reportID return empty string "", which would make subscription to the whole collection with nullish coalescing operator, example of this could be found in NewChatPage.tsx where some hooks return reportID as empty strings
65+
const [isReportInOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${item.reportID || undefined}`, {
6566
selector: reportExistsSelector,
6667
});
6768

@@ -73,6 +74,11 @@ function UserListItem<TItem extends ListItem>({
7374
const shouldUseIconPolicyID = !item.reportID && !item.accountID && !item.policyID;
7475
const policyID = isThereOnlyWorkspaceIcon && shouldUseIconPolicyID ? String(item.icons?.at(0)?.id) : item.policyID;
7576

77+
// Disable accessible grouping when a right-side button is visible, so VoiceOver can focus it independently.
78+
const renderedRightComponent = typeof rightHandSideComponent === 'function' ? rightHandSideComponent(item, isFocused) : rightHandSideComponent;
79+
const shouldDisableAccessibleGrouping = !!renderedRightComponent && !canSelectMultiple;
80+
81+
const contactAccessibilityLabel = getAccessibilityLabel(item);
7682
return (
7783
<BaseListItem
7884
item={item}
@@ -96,13 +102,19 @@ function UserListItem<TItem extends ListItem>({
96102
keyForList={item.keyForList}
97103
onFocus={onFocus}
98104
shouldSyncFocus={shouldSyncFocus}
105+
accessible={shouldDisableAccessibleGrouping ? false : undefined}
99106
shouldDisableHoverStyle={shouldDisableHoverStyle}
100107
>
101108
{(hovered?: boolean) => {
102109
const isHovered = !!hovered && !shouldDisableHoverStyle;
103110

104111
return (
105-
<>
112+
<View
113+
accessible={shouldDisableAccessibleGrouping || undefined}
114+
accessibilityLabel={shouldDisableAccessibleGrouping ? contactAccessibilityLabel : undefined}
115+
role={shouldDisableAccessibleGrouping ? CONST.ROLE.BUTTON : undefined}
116+
style={[styles.flex1, styles.flexRow, styles.alignItemsCenter]}
117+
>
106118
{!shouldUseDefaultRightHandSideCheckmark && !!canSelectMultiple && (
107119
<PressableWithFeedback
108120
accessibilityLabel={item.text ?? ''}
@@ -194,7 +206,7 @@ function UserListItem<TItem extends ListItem>({
194206
</View>
195207
</PressableWithFeedback>
196208
)}
197-
</>
209+
</View>
198210
);
199211
}}
200212
</BaseListItem>

src/components/SelectionList/SelectionListWithSections/BaseSelectionListWithSections.tsx

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,10 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
191191
innerTextInputRef.current?.focus();
192192
};
193193

194+
const clearInputAfterSelect = () => {
195+
textInputOptions?.onChangeText?.('');
196+
};
197+
194198
const updateAndScrollToFocusedIndex = (index: number, shouldScroll = true) => {
195199
if (!shouldScroll) {
196200
suppressNextFocusScrollRef.current = true;
@@ -208,12 +212,18 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
208212
isTextInputFocusedRef.current = isTextInputFocused;
209213
};
210214

211-
useImperativeHandle(ref, () => ({
212-
focusTextInput,
213-
updateAndScrollToFocusedIndex,
214-
updateExternalTextInputFocus,
215-
getFocusedOption: getFocusedItem,
216-
}));
215+
useImperativeHandle(
216+
ref,
217+
() => ({
218+
focusTextInput,
219+
scrollToIndex,
220+
clearInputAfterSelect,
221+
updateAndScrollToFocusedIndex,
222+
updateExternalTextInputFocus,
223+
getFocusedOption: getFocusedItem,
224+
}),
225+
[focusTextInput, scrollToIndex, clearInputAfterSelect, updateAndScrollToFocusedIndex, updateExternalTextInputFocus, getFocusedItem],
226+
);
217227

218228
// Disable `Enter` shortcut if the active element is a button or checkbox
219229
const disableEnterShortcut = activeElementRole && [CONST.ROLE.BUTTON, CONST.ROLE.CHECKBOX].includes(activeElementRole as ButtonOrCheckBoxRoles);

src/components/SelectionList/SelectionListWithSections/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ type SelectionListWithSectionsProps<TItem extends ListItem> = BaseSelectionListP
6262

6363
type SelectionListWithSectionsHandle<TItem extends ListItem = ListItem> = {
6464
focusTextInput: () => void;
65+
scrollToIndex: (index: number) => void;
66+
clearInputAfterSelect: () => void;
6567
updateAndScrollToFocusedIndex: (index: number, shouldScroll?: boolean) => void;
6668
updateExternalTextInputFocus: (isTextInputFocused: boolean) => void;
6769
getFocusedOption: () => TItem | undefined;

src/components/SelectionList/components/TextInput.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,22 @@ function TextInput({
6666
}: TextInputProps) {
6767
const styles = useThemeStyles();
6868
const {translate} = useLocalize();
69-
const {label, value, onChangeText, errorText, headerMessage, hint, disableAutoFocus, placeholder, maxLength, inputMode, ref: optionsRef, style, disableAutoCorrect} = options ?? {};
69+
const {
70+
label,
71+
value,
72+
onChangeText,
73+
errorText,
74+
headerMessage,
75+
hint,
76+
disableAutoFocus,
77+
placeholder,
78+
maxLength,
79+
inputMode,
80+
ref: optionsRef,
81+
style,
82+
disableAutoCorrect,
83+
shouldInterceptSwipe,
84+
} = options ?? {};
7085
const noResultsFoundText = translate('common.noResultsFound');
7186
const isNoResultsFoundMessage = headerMessage === noResultsFoundText;
7287
const noData = dataLength === 0 && !shouldShowLoadingPlaceholder;
@@ -138,8 +153,8 @@ function TextInput({
138153
isLoading={isLoading}
139154
testID="selection-list-text-input"
140155
errorText={errorText}
141-
shouldInterceptSwipe={false}
142156
autoCorrect={!disableAutoCorrect}
157+
shouldInterceptSwipe={shouldInterceptSwipe ?? false}
143158
/>
144159
</View>
145160
{shouldShowHeaderMessage && (

0 commit comments

Comments
 (0)