Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,7 @@
"lastiPhoneLogin",
"lastname",
"lefthook",
"legendapp",
"libc",
"Libc",
"libc's",
Expand Down
50 changes: 50 additions & 0 deletions jest/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,56 @@
setupMockImages();
mockFSLibrary();

// LegendList relies on native layout measurements that Jest does not produce. FlatList gives full-app tests
// a deterministic renderer while preserving the scroll callbacks used by the report list.
jest.mock('@legendapp/list/react-native', () => {
const ReactActual = jest.requireActual<typeof import('react')>('react');

Check failure on line 36 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / ESLint check

`import()` type annotations are forbidden
const {FlatList} = jest.requireActual<typeof import('react-native')>('react-native');

Check failure on line 37 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / ESLint check

`import()` type annotations are forbidden
const LegendListActual = jest.requireActual<typeof import('@legendapp/list/react-native')>('@legendapp/list/react-native');

Check failure on line 38 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / ESLint check

`import()` type annotations are forbidden

type MockLegendListProps = Omit<import('react-native').FlatListProps<unknown>, 'data' | 'initialScrollIndex' | 'maintainVisibleContentPosition' | 'onScroll'> & {

Check failure on line 40 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / ESLint check

`import()` type annotations are forbidden
alignItemsAtEnd?: boolean;
data?: ArrayLike<unknown>;
initialScrollAtEnd?: boolean;
initialScrollIndex?: number | {index: number};
maintainScrollAtEnd?: unknown;
maintainVisibleContentPosition?: unknown;
onScroll?: (event: import('react-native').NativeSyntheticEvent<import('react-native').NativeScrollEvent>) => void;

Check failure on line 47 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / ESLint check

`import()` type annotations are forbidden

Check failure on line 47 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / ESLint check

`import()` type annotations are forbidden
};

return {
...LegendListActual,
LegendList: ReactActual.forwardRef<import('react-native').FlatList<unknown>, MockLegendListProps>(

Check failure on line 52 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / ESLint check

`import()` type annotations are forbidden
(
{
alignItemsAtEnd,
data = [],
initialScrollAtEnd,
initialScrollIndex,
maintainScrollAtEnd,
maintainVisibleContentPosition,
onEndReached,
onEndReachedThreshold = 0,
onScroll,
...props
},
ref,
) => {
const handleScroll = (event: import('react-native').NativeSyntheticEvent<import('react-native').NativeScrollEvent>) => {

Check failure on line 68 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / ESLint check

`import()` type annotations are forbidden

Check failure on line 68 in jest/setup.ts

View workflow job for this annotation

GitHub Actions / ESLint check

`import()` type annotations are forbidden
onScroll?.(event);
const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent;
const distanceFromEnd = contentSize.height - layoutMeasurement.height - contentOffset.y;
if (distanceFromEnd <= layoutMeasurement.height * (onEndReachedThreshold ?? 0)) {
onEndReached?.({distanceFromEnd});
}
};

return ReactActual.createElement(FlatList<unknown>, {...props, data, initialNumToRender: data.length, onScroll: handleScroll, ref});
},
),
};
});

Object.assign(global, {TextDecoder, TextEncoder});

// This mock is required as per setup instructions for react-navigation testing
Expand Down
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"@fullstory/react-native": "^1.9.0",
"@gorhom/portal": "^1.0.14",
"@invertase/react-native-apple-authentication": "^2.5.0",
"@legendapp/list": "^3.3.5",
"@lottiefiles/dotlottie-react": "0.13.5",
"@onfido/react-native-sdk": "15.1.0",
"@pusher/pusher-websocket-react-native": "^1.3.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
diff --git a/node_modules/@fullstory/react-native/src/index.ts b/node_modules/@fullstory/react-native/src/index.ts
--- a/node_modules/@fullstory/react-native/src/index.ts
+++ b/node_modules/@fullstory/react-native/src/index.ts
@@ -118,6 +118,8 @@ type FSNativeElement = ComponentRef<FSComponentType> & {
currentProps?: Record<string, unknown>;
};

+const staticRefWrappers = new WeakMap<object, MaybeFSForwardedRef<FSNativeElement>>();
+
// Shared wrapper for components without refs (most common case)
function sharedRefWrapper(element: FSNativeElement | null) {
if (element && isTurboModuleEnabled && Platform.OS === 'ios' && !Platform.isTV) {
@@ -185,6 +187,13 @@ export function applyFSPropertiesWithRef(
return existingRef;
}

+ if (existingRef && !hasDynamicAttributes) {
+ const staticRefWrapper = staticRefWrappers.get(existingRef);
+ if (staticRefWrapper) {
+ return staticRefWrapper;
+ }
+ }
+
// Use shared wrapper for null/undefined refs or static attributes
if (!existingRef && !hasDynamicAttributes) {
return sharedRefWrapper;
@@ -207,6 +216,10 @@ export function applyFSPropertiesWithRef(
configurable: false,
});

+ if (existingRef && !hasDynamicAttributes) {
+ staticRefWrappers.set(existingRef, refWrapper);
+ }
+
return refWrapper;
}

diff --git a/node_modules/@fullstory/react-native/lib/commonjs/index.js b/node_modules/@fullstory/react-native/lib/commonjs/index.js
--- a/node_modules/@fullstory/react-native/lib/commonjs/index.js
+++ b/node_modules/@fullstory/react-native/lib/commonjs/index.js
@@ -83,6 +83,7 @@ try {
getInternalInstanceHandleFromPublicInstance = require('react-native/Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance').getInternalInstanceHandleFromPublicInstance;
} catch (e) {}
const FS_REF_SYMBOL = exports.FS_REF_SYMBOL = Symbol('fullstory.ref');
+const staticRefWrappers = new WeakMap();
// Shared wrapper for components without refs (most common case)
function sharedRefWrapper(element) {
if (element && _fullstoryInterface.isTurboModuleEnabled && _reactNative.Platform.OS === 'ios' && !_reactNative.Platform.isTV) {
@@ -136,5 +137,11 @@ function applyFSPropertiesWithRef(existingRef, hasDynamicAttributes = true) {
return existingRef;
}
+ if (existingRef && !hasDynamicAttributes) {
+ const staticRefWrapper = staticRefWrappers.get(existingRef);
+ if (staticRefWrapper) {
+ return staticRefWrapper;
+ }
+ }

// Use shared wrapper for null/undefined refs or static attributes
if (!existingRef && !hasDynamicAttributes) {
@@ -156,6 +163,9 @@ function applyFSPropertiesWithRef(existingRef, hasDynamicAttributes = true) {
writable: false,
configurable: false
});
+ if (existingRef && !hasDynamicAttributes) {
+ staticRefWrappers.set(existingRef, refWrapper);
+ }
return refWrapper;
}
const FullstoryAPI = {
diff --git a/node_modules/@fullstory/react-native/lib/module/index.js b/node_modules/@fullstory/react-native/lib/module/index.js
--- a/node_modules/@fullstory/react-native/lib/module/index.js
+++ b/node_modules/@fullstory/react-native/lib/module/index.js
@@ -67,6 +67,7 @@ try {
getInternalInstanceHandleFromPublicInstance = require('react-native/Libraries/ReactNative/ReactFabricPublicInstance/ReactFabricPublicInstance').getInternalInstanceHandleFromPublicInstance;
} catch (e) {}
export const FS_REF_SYMBOL = Symbol('fullstory.ref');
+const staticRefWrappers = new WeakMap();
// Shared wrapper for components without refs (most common case)
function sharedRefWrapper(element) {
if (element && isTurboModuleEnabled && Platform.OS === 'ios' && !Platform.isTV) {
@@ -120,5 +121,11 @@ export function applyFSPropertiesWithRef(existingRef, hasDynamicAttributes = tru
return existingRef;
}
+ if (existingRef && !hasDynamicAttributes) {
+ const staticRefWrapper = staticRefWrappers.get(existingRef);
+ if (staticRefWrapper) {
+ return staticRefWrapper;
+ }
+ }

// Use shared wrapper for null/undefined refs or static attributes
if (!existingRef && !hasDynamicAttributes) {
@@ -140,6 +147,9 @@ export function applyFSPropertiesWithRef(existingRef, hasDynamicAttributes = tru
writable: false,
configurable: false
});
+ if (existingRef && !hasDynamicAttributes) {
+ staticRefWrappers.set(existingRef, refWrapper);
+ }
return refWrapper;
}
const FullstoryAPI = {
10 changes: 10 additions & 0 deletions patches/@fullstory/react-native/details.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,13 @@
- Upstream PR/issue: 🛑 TODO
- E/App issue: https://github.com/Expensify/App/issues/91225
- PR introducing patch: 🛑 TODO

### [@fullstory+react-native+1.9.0+002+stable-static-ref-wrapper.patch](@fullstory+react-native+1.9.0+002+stable-static-ref-wrapper.patch)

- Reason:

FullStory creates a new callback wrapper every time `applyFSPropertiesWithRef()` receives an existing ref, including when annotations are static. Components that update state from their ref callback can then enter a detach/render/attach loop because the wrapper identity changes on every render. Cache wrappers for stable refs when annotations are static, while continuing to recreate wrappers for dynamic FullStory attributes.

- Upstream PR/issue: -
- E/App issue: -
- PR introducing patch: -

This file was deleted.

31 changes: 0 additions & 31 deletions src/components/FlashList/InvertedFlashList/index.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion src/components/FlashList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {FlashList as ShopifyFlashList} from '@shopify/flash-list';
import React from 'react';

function FlashList<T>({onScroll: onScrollProp, inverted, ...restProps}: FlashListProps<T>) {
const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted});
const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!inverted});

const handleScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
onScrollProp?.(e);
Expand Down
28 changes: 26 additions & 2 deletions src/components/FlashList/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,31 @@
import type {RefObject} from 'react';
import type {FlatList} from 'react-native';

type ScrollToIndexParams = {
animated?: boolean;
index: number;
viewOffset?: number;
viewPosition?: number;
};

type ScrollToOffsetParams = {
animated?: boolean;
offset: number;
};

type ScrollToEndParams = {
animated?: boolean;
};

/** Common imperative API used by the report scroll manager across FlatList, FlashList, and LegendList. */
type ActionListRef = {
scrollToIndex: (params: ScrollToIndexParams) => void;
scrollToOffset: (params: ScrollToOffsetParams) => void;
scrollToEnd: (params?: ScrollToEndParams) => void;
getNativeScrollRef?: () => unknown;
};

/** Ref to the underlying list instance attached via `ref={}`. */
type FlatListRefType = RefObject<FlatList<unknown> | null> | null;
type FlatListRefType = RefObject<ActionListRef | null> | null;

export default FlatListRefType;
export type {ActionListRef};
2 changes: 1 addition & 1 deletion src/components/FlatList/FlatList/index.ios.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function CustomFlatList<T>({
[onMomentumScrollEnd],
);

const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal, inverted: restProps.inverted});
const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal && !!restProps.inverted});
const handleScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
onScrollProp?.(e);
Expand Down
2 changes: 1 addition & 1 deletion src/components/FlatList/FlatList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ function MVCPFlatList<T>({
};
}, []);

const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted: restProps.inverted});
const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!restProps.inverted});
const handleScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
onScrollProp?.(e);
Expand Down
2 changes: 1 addition & 1 deletion src/components/KeyboardDismissibleFlatList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {useKeyboardDismissibleFlatListActions} from './KeyboardDismissibleFlatLi
function KeyboardDismissibleFlatList<T>({onScroll: onScrollProp, inverted, ref, ...restProps}: AnimatedFlatListWithCellRendererProps<T>) {
const {onScroll: onScrollHandleKeyboard} = useKeyboardDismissibleFlatListActions();

const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted});
const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!inverted});

const additionalOnScroll = useAnimatedScrollHandler({
onScroll: emitComposerScrollEvents,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -634,7 +634,7 @@
const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID;

return (
<ReportActionIndexContext.Provider value={indexWithinReportActions}>
<ReportActionIndexContext.Provider value={{index: indexWithinReportActions, isNewest: indexWithinReportActions === visibleReportActions.length - 1}}>

Check failure on line 637 in src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx

View workflow job for this annotation

GitHub Actions / ESLint check

The object passed as the value prop to the Context provider (at line 637) changes every render. To fix this consider wrapping it in a useMemo hook
<ReportActionsListItemRenderer
reportAction={reportAction}
parentReportAction={parentReportAction}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import PDFThumbnailError from '@components/PDFThumbnail/PDFThumbnailError';

import useThemeStyles from '@hooks/useThemeStyles';

import {useReportActionItemState} from '@pages/inbox/report/ReportActionIndexContext';

import CONST from '@src/CONST';

import React, {useState} from 'react';
import {View} from 'react-native';

import type LocalPDFReceiptPreviewProps from './types';
Expand All @@ -18,9 +19,9 @@ const DOCUMENT_OPTIONS = {cMapUrl: '/cmaps/', cMapPacked: true};

function LocalPDFReceiptPreview({sourceURL, shouldUseFullHeight, onLoadFailure, onLoadSuccess}: LocalPDFReceiptPreviewProps) {
const styles = useThemeStyles();
const [failedToLoad, setFailedToLoad] = useState(false);
const [containerSize, setContainerSize] = useState<{width: number; height: number} | undefined>(undefined);
const [pageAspectRatio, setPageAspectRatio] = useState<number | undefined>(undefined);
const [failedToLoad, setFailedToLoad] = useReportActionItemState(false);
const [containerSize, setContainerSize] = useReportActionItemState<{width: number; height: number} | undefined>(undefined);
const [pageAspectRatio, setPageAspectRatio] = useReportActionItemState<number | undefined>(undefined);

const handleDocumentLoadSuccess = (pdf: PDFDocumentProxy) => {
pdf.getPage(1)
Expand Down
Loading
Loading