From b0ade4241fb939ec8058a13a8e343ab0231ca952 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 9 Nov 2026 12:20:36 +0100 Subject: [PATCH] feat: combine current chronological chat LegendList migration Combine the remaining patch refresh, chronological report-actions migration, and pagination loading on current main. Keep explicit end anchoring and support immediate Search pagination transitions. Co-Authored-By: Claude Opus 5.5 Co-authored-by: Codex --- jest/setupMockLegendList.ts | 5 + ...+001+apply-pending-anchor-correction.patch | 60 +- ...p+list+3.7.2+002+add-mvcp-end-anchor.patch | 482 +++++ ...d-aligned-lists-to-last-visible-item.patch | 108 - ...chor-data-changes-after-scroll-jumps.patch | 44 +- ...+land-initial-mount-adjustments-once.patch | 472 +++++ ...ist-after-initial-scroll-adjustments.patch | 240 --- ...05+keep-scroll-adjust-anchor-precise.patch | 12 +- ...finish-silent-initial-scroll-retries.patch | 282 +++ ...keep-native-initial-scroll-on-target.patch | 228 --- ...ve-content-in-place-on-header-resize.patch | 316 +++ patches/@legendapp/list/details.md | 45 +- src/CONST/index.ts | 1 + .../CellRendererComponent.tsx | 30 - .../FlashList/InvertedFlashList/index.tsx | 28 - src/components/FlashList/types.ts | 3 - .../LegendList/allowLegendListItemOverflow.ts | 19 + .../MoneyRequestReportActionsList.tsx | 20 +- .../useMoneyRequestReportScroll.ts | 4 +- src/hooks/useLinkedReportActionScroll.ts | 174 ++ src/hooks/useLoadReportActions.ts | 23 +- src/hooks/useReportActionsListModel.ts | 24 +- src/hooks/useReportActionsPaginationScroll.ts | 295 +++ src/hooks/useReportActionsScroll.ts | 289 ++- .../useReportScrollManager/index.native.ts | 26 +- src/hooks/useReportScrollManager/index.ts | 19 +- src/hooks/useReportScrollManager/types.ts | 1 - src/libs/ReportUtils.ts | 3 +- src/pages/inbox/ActionListContext.tsx | 32 +- src/pages/inbox/ActionListTypes.ts | 12 +- src/pages/inbox/ChatReportActions.tsx | 5 +- .../inbox/report/AncestorReportActionItem.tsx | 3 - .../inbox/report/AskConciergeWelcome.tsx | 11 +- .../ComposerWithSuggestions.tsx | 6 +- .../useShouldSkipComposerAutoFocus.ts | 10 +- .../ReportActionsListContent.tsx | 176 +- .../ReportActionsListRow.tsx | 7 +- .../SkeletonBelowLinkedAction.tsx | 69 + .../inbox/report/ReportActionsList/index.tsx | 6 +- .../reportActionListUtils.ts | 66 + .../inbox/report/ReportActionsList/types.ts | 34 +- .../useReportActionsListData.ts | 38 +- .../useReportActionsListRenderItem.tsx | 67 +- .../useReportActionsListScroll.ts | 398 +++- .../useReportActionsListTelemetry.ts | 29 +- .../inbox/report/ReportActionsListContext.tsx | 16 +- ...eportActionsPaginationLoadingIndicator.tsx | 39 + .../report/shouldFollowActionBadgeTarget.ts | 12 +- .../report/useFollowActionBadgeTarget.ts | 4 +- .../useReportActionsNewActionLiveTail.ts | 105 +- .../useReportUnreadMessageScrollTracking.ts | 11 +- src/selectors/ReportMetaData.ts | 14 +- src/styles/utils/positioning.ts | 3 + src/styles/utils/spacing.ts | 4 + .../perf-test/ReportActionsList.perf-test.tsx | 57 +- .../ReportActionsListRowWrites.perf-test.tsx | 32 +- tests/tooling/webProxy.test.ts | 94 + tests/ui/ComposerImperativeRefTest.tsx | 32 +- tests/ui/PaginationTest.tsx | 84 +- tests/ui/ReportActionsListRowTest.tsx | 1 + tests/ui/ReportActionsListTest.tsx | 1774 ++++++++++++++++- tests/ui/SkeletonBelowLinkedActionTest.tsx | 115 ++ tests/unit/ActionListContextProviderTest.tsx | 29 +- tests/unit/LegendListItemOverflowTest.ts | 35 + tests/unit/ReportActionsListContextTest.ts | 69 + tests/unit/ReportActionsListThresholdTest.tsx | 87 +- ...tActionsPaginationLoadingIndicatorTest.tsx | 66 + tests/unit/ReportUtilsTest.ts | 31 + .../useShouldSkipComposerAutoFocus.test.tsx | 13 + .../unit/shouldFollowActionBadgeTargetTest.ts | 8 +- tests/unit/useFollowActionBadgeTargetTest.ts | 13 +- .../unit/useMoneyRequestReportScrollTest.tsx | 17 +- .../useReportActionsNewActionLiveTailTest.ts | 226 ++- .../useReportActionsPaginationScrollTest.tsx | 287 +++ tests/unit/useReportActionsScrollTest.tsx | 759 +++++-- tests/unit/useReportScrollManagerTest.tsx | 25 +- ...seReportUnreadMessageScrollTrackingTest.ts | 50 + web/proxy.ts | 10 +- 78 files changed, 6769 insertions(+), 1575 deletions(-) create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+002+add-mvcp-end-anchor.patch delete mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+004+land-initial-mount-adjustments-once.patch delete mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+006+finish-silent-initial-scroll-retries.patch delete mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+007+keep-native-content-in-place-on-header-resize.patch delete mode 100644 src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx delete mode 100644 src/components/FlashList/InvertedFlashList/index.tsx delete mode 100644 src/components/FlashList/types.ts create mode 100644 src/components/LegendList/allowLegendListItemOverflow.ts create mode 100644 src/hooks/useLinkedReportActionScroll.ts create mode 100644 src/hooks/useReportActionsPaginationScroll.ts create mode 100644 src/pages/inbox/report/ReportActionsList/SkeletonBelowLinkedAction.tsx create mode 100644 src/pages/inbox/report/ReportActionsList/reportActionListUtils.ts create mode 100644 src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx create mode 100644 tests/tooling/webProxy.test.ts create mode 100644 tests/ui/SkeletonBelowLinkedActionTest.tsx create mode 100644 tests/unit/LegendListItemOverflowTest.ts create mode 100644 tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx create mode 100644 tests/unit/useReportActionsPaginationScrollTest.tsx diff --git a/jest/setupMockLegendList.ts b/jest/setupMockLegendList.ts index 489bed5d311e..39530f50e142 100644 --- a/jest/setupMockLegendList.ts +++ b/jest/setupMockLegendList.ts @@ -32,6 +32,7 @@ export default function setupMockLegendList() { onEndReached, onEndReachedThreshold = 0.5, onLoad, + onReady, onScroll, onStartReached, onStartReachedThreshold = 0.5, @@ -42,12 +43,15 @@ export default function setupMockLegendList() { ref, ) => { const onLoadRef = ReactActual.useRef(onLoad); + const onReadyRef = ReactActual.useRef(onReady); const listMetricsRef = ReactActual.useRef<{contentLength: number; scroll: number; scrollLength: number} | undefined>(undefined); const reachedEdgesRef = ReactActual.useRef({end: false, start: false}); onLoadRef.current = onLoad; + onReadyRef.current = onReady; ReactActual.useEffect(() => { onLoadRef.current?.({elapsedTimeInMs: 0}); + onReadyRef.current?.(); }, []); ReactActual.useImperativeHandle( @@ -62,6 +66,7 @@ export default function setupMockLegendList() { getState: () => ({ data: data ?? [], elementAtIndex: () => undefined, + positionByKey: () => undefined, endBuffered: (data?.length ?? 0) - 1, startBuffered: 0, ...listMetricsRef.current, diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch index 19a3139e8df6..19e8ee2cd261 100644 --- a/patches/@legendapp/list/@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch @@ -1,5 +1,57 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index 4af685dfb96..92b387b085f 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -4769,7 +4769,8 @@ function calculateItemsInView(ctx, params = {}) { + const stickyHeaderIndicesArr = state.props.stickyHeaderIndicesArr || []; + const stickyHeaderIndicesSet = state.props.stickyHeaderIndicesSet || /* @__PURE__ */ new Set(); + const drawDistance = getEffectiveDrawDistance(ctx, params.drawDistanceMode); +- const { dataChanged, doMVCP, forceFullItemPositions, mvcpAdjustmentSource } = params; ++ const { dataChanged, forceFullItemPositions } = params; ++ let { doMVCP, mvcpAdjustmentSource } = params; + const bootstrapInitialScrollState = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; + const suppressInitialScrollSideEffects = !!bootstrapInitialScrollState; + const prevNumContainers = peek$(ctx, "numContainers"); +@@ -4869,6 +4870,11 @@ function calculateItemsInView(ctx, params = {}) { + } + } + } ++ if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ doMVCP = true; ++ mvcpAdjustmentSource = "item-size"; ++ } + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged, mvcpAdjustmentSource) : void 0; + if (dataChanged) { + resetLayoutCachesForDataChange(state); +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index 9ebfddc183a..30105d17d72 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -4748,7 +4748,8 @@ function calculateItemsInView(ctx, params = {}) { + const stickyHeaderIndicesArr = state.props.stickyHeaderIndicesArr || []; + const stickyHeaderIndicesSet = state.props.stickyHeaderIndicesSet || /* @__PURE__ */ new Set(); + const drawDistance = getEffectiveDrawDistance(ctx, params.drawDistanceMode); +- const { dataChanged, doMVCP, forceFullItemPositions, mvcpAdjustmentSource } = params; ++ const { dataChanged, forceFullItemPositions } = params; ++ let { doMVCP, mvcpAdjustmentSource } = params; + const bootstrapInitialScrollState = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; + const suppressInitialScrollSideEffects = !!bootstrapInitialScrollState; + const prevNumContainers = peek$(ctx, "numContainers"); +@@ -4848,6 +4849,11 @@ function calculateItemsInView(ctx, params = {}) { + } + } + } ++ if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ doMVCP = true; ++ mvcpAdjustmentSource = "item-size"; ++ } + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged, mvcpAdjustmentSource) : void 0; + if (dataChanged) { + resetLayoutCachesForDataChange(state); diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js -index 5155454..c102661 100644 +index 515545471e0..c102661fc18 100644 --- a/node_modules/@legendapp/list/react-native.web.js +++ b/node_modules/@legendapp/list/react-native.web.js @@ -4843,7 +4843,8 @@ function calculateItemsInView(ctx, params = {}) { @@ -25,7 +77,7 @@ index 5155454..c102661 100644 if (dataChanged) { resetLayoutCachesForDataChange(state); diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs -index ce08abe..ff71fd6 100644 +index ce08abe7ee6..ff71fd62640 100644 --- a/node_modules/@legendapp/list/react-native.web.mjs +++ b/node_modules/@legendapp/list/react-native.web.mjs @@ -4822,7 +4822,8 @@ function calculateItemsInView(ctx, params = {}) { @@ -51,7 +103,7 @@ index ce08abe..ff71fd6 100644 if (dataChanged) { resetLayoutCachesForDataChange(state); diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js -index 5155454..c102661 100644 +index 515545471e0..c102661fc18 100644 --- a/node_modules/@legendapp/list/react.js +++ b/node_modules/@legendapp/list/react.js @@ -4843,7 +4843,8 @@ function calculateItemsInView(ctx, params = {}) { @@ -77,7 +129,7 @@ index 5155454..c102661 100644 if (dataChanged) { resetLayoutCachesForDataChange(state); diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs -index ce08abe..ff71fd6 100644 +index ce08abe7ee6..ff71fd62640 100644 --- a/node_modules/@legendapp/list/react.mjs +++ b/node_modules/@legendapp/list/react.mjs @@ -4822,7 +4822,8 @@ function calculateItemsInView(ctx, params = {}) { diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+002+add-mvcp-end-anchor.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+002+add-mvcp-end-anchor.patch new file mode 100644 index 000000000000..a7e06efae9a0 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+002+add-mvcp-end-anchor.patch @@ -0,0 +1,482 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index 92b387b085f..e687e9c0a41 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -1800,9 +1800,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const state = ctx.state; + const { idsInView, positions, props } = state; + const { +- maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } ++ maintainVisibleContentPosition: { anchor, data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; + const isWeb = Platform.OS === "web"; ++ const isEndAnchor = anchor === "end"; ++ const getAnchorCandidateId = (i) => idsInView[isEndAnchor ? idsInView.length - 1 - i : i]; + const now = Date.now(); + const enableMVCPAnchorLock = isWeb && (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1847,11 +1849,16 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ for (let i = 0; i < idsInView.length && targetId === void 0; i++) { ++ const id = getAnchorCandidateId(i); ++ if (indexByKey.get(id) !== void 0) { ++ targetId = id; ++ } ++ } + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { + for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ const id = getAnchorCandidateId(i); + const index = previousIndexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +@@ -8236,6 +8243,7 @@ function normalizeMaintainVisibleContentPosition(value) { + } + if (value && typeof value === "object") { + return { ++ anchor: value.anchor, + data: (_a3 = value.data) != null ? _a3 : false, + shouldRestorePosition: value.shouldRestorePosition, + size: (_b = value.size) != null ? _b : true +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index 30105d17d72..6271d782ea7 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -1779,9 +1779,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const state = ctx.state; + const { idsInView, positions, props } = state; + const { +- maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } ++ maintainVisibleContentPosition: { anchor, data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; + const isWeb = Platform.OS === "web"; ++ const isEndAnchor = anchor === "end"; ++ const getAnchorCandidateId = (i) => idsInView[isEndAnchor ? idsInView.length - 1 - i : i]; + const now = Date.now(); + const enableMVCPAnchorLock = isWeb && (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1826,11 +1828,16 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ for (let i = 0; i < idsInView.length && targetId === void 0; i++) { ++ const id = getAnchorCandidateId(i); ++ if (indexByKey.get(id) !== void 0) { ++ targetId = id; ++ } ++ } + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { + for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ const id = getAnchorCandidateId(i); + const index = previousIndexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +@@ -8215,6 +8222,7 @@ function normalizeMaintainVisibleContentPosition(value) { + } + if (value && typeof value === "object") { + return { ++ anchor: value.anchor, + data: (_a3 = value.data) != null ? _a3 : false, + shouldRestorePosition: value.shouldRestorePosition, + size: (_b = value.size) != null ? _b : true +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index c102661fc18..2b3171a6f4c 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -1704,8 +1704,10 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const state = ctx.state; + const { idsInView, positions, props } = state; + const { +- maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } ++ maintainVisibleContentPosition: { anchor, data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ const isEndAnchor = anchor === "end"; ++ const getAnchorCandidateId = (i) => idsInView[isEndAnchor ? idsInView.length - 1 - i : i]; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1743,11 +1745,16 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ for (let i = 0; i < idsInView.length && targetId === void 0; i++) { ++ const id = getAnchorCandidateId(i); ++ if (indexByKey.get(id) !== void 0) { ++ targetId = id; ++ } ++ } + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { + for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ const id = getAnchorCandidateId(i); + const index = previousIndexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +@@ -9018,6 +9025,7 @@ function normalizeMaintainVisibleContentPosition(value) { + } + if (value && typeof value === "object") { + return { ++ anchor: value.anchor, + data: (_a3 = value.data) != null ? _a3 : false, + shouldRestorePosition: value.shouldRestorePosition, + size: (_b = value.size) != null ? _b : true +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index ff71fd62640..dbd6fbbf3e8 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -1683,8 +1683,10 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const state = ctx.state; + const { idsInView, positions, props } = state; + const { +- maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } ++ maintainVisibleContentPosition: { anchor, data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ const isEndAnchor = anchor === "end"; ++ const getAnchorCandidateId = (i) => idsInView[isEndAnchor ? idsInView.length - 1 - i : i]; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1722,11 +1724,16 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ for (let i = 0; i < idsInView.length && targetId === void 0; i++) { ++ const id = getAnchorCandidateId(i); ++ if (indexByKey.get(id) !== void 0) { ++ targetId = id; ++ } ++ } + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { + for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ const id = getAnchorCandidateId(i); + const index = previousIndexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +@@ -8997,6 +9004,7 @@ function normalizeMaintainVisibleContentPosition(value) { + } + if (value && typeof value === "object") { + return { ++ anchor: value.anchor, + data: (_a3 = value.data) != null ? _a3 : false, + shouldRestorePosition: value.shouldRestorePosition, + size: (_b = value.size) != null ? _b : true +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index c102661fc18..2b3171a6f4c 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -1704,8 +1704,10 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const state = ctx.state; + const { idsInView, positions, props } = state; + const { +- maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } ++ maintainVisibleContentPosition: { anchor, data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ const isEndAnchor = anchor === "end"; ++ const getAnchorCandidateId = (i) => idsInView[isEndAnchor ? idsInView.length - 1 - i : i]; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1743,11 +1745,16 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ for (let i = 0; i < idsInView.length && targetId === void 0; i++) { ++ const id = getAnchorCandidateId(i); ++ if (indexByKey.get(id) !== void 0) { ++ targetId = id; ++ } ++ } + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { + for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ const id = getAnchorCandidateId(i); + const index = previousIndexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +@@ -9018,6 +9025,7 @@ function normalizeMaintainVisibleContentPosition(value) { + } + if (value && typeof value === "object") { + return { ++ anchor: value.anchor, + data: (_a3 = value.data) != null ? _a3 : false, + shouldRestorePosition: value.shouldRestorePosition, + size: (_b = value.size) != null ? _b : true +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index ff71fd62640..dbd6fbbf3e8 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -1683,8 +1683,10 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const state = ctx.state; + const { idsInView, positions, props } = state; + const { +- maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } ++ maintainVisibleContentPosition: { anchor, data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ const isEndAnchor = anchor === "end"; ++ const getAnchorCandidateId = (i) => idsInView[isEndAnchor ? idsInView.length - 1 - i : i]; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1722,11 +1724,16 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ for (let i = 0; i < idsInView.length && targetId === void 0; i++) { ++ const id = getAnchorCandidateId(i); ++ if (indexByKey.get(id) !== void 0) { ++ targetId = id; ++ } ++ } + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { + for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ const id = getAnchorCandidateId(i); + const index = previousIndexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +@@ -8997,6 +9004,7 @@ function normalizeMaintainVisibleContentPosition(value) { + } + if (value && typeof value === "object") { + return { ++ anchor: value.anchor, + data: (_a3 = value.data) != null ? _a3 : false, + shouldRestorePosition: value.shouldRestorePosition, + size: (_b = value.size) != null ? _b : true +diff --git a/node_modules/@legendapp/list/react-native.d.ts b/node_modules/@legendapp/list/react-native.d.ts +index 983e937cf5c..72e2d573b77 100644 +--- a/node_modules/@legendapp/list/react-native.d.ts ++++ b/node_modules/@legendapp/list/react-native.d.ts +@@ -3,6 +3,7 @@ import { Key, Dispatch, SetStateAction } from 'react'; + import { ScrollViewProps, NativeSyntheticEvent as NativeSyntheticEvent$1, NativeScrollEvent as NativeScrollEvent$1, ScrollView, StyleProp as StyleProp$1, ViewStyle as ViewStyle$1, ScrollViewComponent, ScrollResponderMixin, Insets as Insets$1, View } from 'react-native'; + + interface MaintainVisibleContentPositionNormalized { ++ anchor?: "start" | "end"; + data: boolean; + size: boolean; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; +@@ -292,6 +293,7 @@ interface LegendListSpecificProps { + * - size (default: true) stabilizes during size/layout changes while scrolling. + * - data (default: false) stabilizes when the data array changes. + * - shouldRestorePosition can opt out specific items from data-change anchoring. ++ * - anchor (default: "start") picks the visible item that stays in place: the first, or the last with "end". + * - undefined (default) enables scroll stabilization but skips data-change anchoring. + * - true enables both JS behaviors; false disables both. + * Native iOS maintainVisibleContentPosition is enabled when either size or data stabilization is enabled, +@@ -494,6 +496,12 @@ type LegendListPropsBase, TItemTyp + interface MaintainVisibleContentPositionConfig { + data?: boolean; + size?: boolean; ++ /** ++ * Which visible item stays in place when sizes or data change. "start" (default) keeps the first visible ++ * item in place, so changes move the content below it. "end" keeps the last visible item in place, so changes ++ * move the content above it, like in an inverted list. Use "end" for chat-style lists read from the bottom. ++ */ ++ anchor?: "start" | "end"; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; + } + interface AnchoredEndSpaceReadyInfo { +diff --git a/node_modules/@legendapp/list/react-native.web.d.ts b/node_modules/@legendapp/list/react-native.web.d.ts +index 358fcd7faa5..455c5857a8b 100644 +--- a/node_modules/@legendapp/list/react-native.web.d.ts ++++ b/node_modules/@legendapp/list/react-native.web.d.ts +@@ -30,6 +30,7 @@ interface ScrollViewMethods { + } + + interface MaintainVisibleContentPositionNormalized { ++ anchor?: "start" | "end"; + data: boolean; + size: boolean; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; +@@ -319,6 +320,7 @@ interface LegendListSpecificProps { + * - size (default: true) stabilizes during size/layout changes while scrolling. + * - data (default: false) stabilizes when the data array changes. + * - shouldRestorePosition can opt out specific items from data-change anchoring. ++ * - anchor (default: "start") picks the visible item that stays in place: the first, or the last with "end". + * - undefined (default) enables scroll stabilization but skips data-change anchoring. + * - true enables both JS behaviors; false disables both. + * Native iOS maintainVisibleContentPosition is enabled when either size or data stabilization is enabled, +@@ -521,6 +523,12 @@ type LegendListPropsBase, TItemTyp + interface MaintainVisibleContentPositionConfig { + data?: boolean; + size?: boolean; ++ /** ++ * Which visible item stays in place when sizes or data change. "start" (default) keeps the first visible ++ * item in place, so changes move the content below it. "end" keeps the last visible item in place, so changes ++ * move the content above it, like in an inverted list. Use "end" for chat-style lists read from the bottom. ++ */ ++ anchor?: "start" | "end"; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; + } + interface AnchoredEndSpaceReadyInfo { +diff --git a/node_modules/@legendapp/list/react.d.ts b/node_modules/@legendapp/list/react.d.ts +index 358fcd7faa5..455c5857a8b 100644 +--- a/node_modules/@legendapp/list/react.d.ts ++++ b/node_modules/@legendapp/list/react.d.ts +@@ -30,6 +30,7 @@ interface ScrollViewMethods { + } + + interface MaintainVisibleContentPositionNormalized { ++ anchor?: "start" | "end"; + data: boolean; + size: boolean; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; +@@ -319,6 +320,7 @@ interface LegendListSpecificProps { + * - size (default: true) stabilizes during size/layout changes while scrolling. + * - data (default: false) stabilizes when the data array changes. + * - shouldRestorePosition can opt out specific items from data-change anchoring. ++ * - anchor (default: "start") picks the visible item that stays in place: the first, or the last with "end". + * - undefined (default) enables scroll stabilization but skips data-change anchoring. + * - true enables both JS behaviors; false disables both. + * Native iOS maintainVisibleContentPosition is enabled when either size or data stabilization is enabled, +@@ -521,6 +523,12 @@ type LegendListPropsBase, TItemTyp + interface MaintainVisibleContentPositionConfig { + data?: boolean; + size?: boolean; ++ /** ++ * Which visible item stays in place when sizes or data change. "start" (default) keeps the first visible ++ * item in place, so changes move the content below it. "end" keeps the last visible item in place, so changes ++ * move the content above it, like in an inverted list. Use "end" for chat-style lists read from the bottom. ++ */ ++ anchor?: "start" | "end"; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; + } + interface AnchoredEndSpaceReadyInfo { +diff --git a/node_modules/@legendapp/list/animated.d.ts b/node_modules/@legendapp/list/animated.d.ts +index c519061902a..1397ff21793 100644 +--- a/node_modules/@legendapp/list/animated.d.ts ++++ b/node_modules/@legendapp/list/animated.d.ts +@@ -3,6 +3,7 @@ import { Key } from 'react'; + import { ScrollViewProps, NativeSyntheticEvent as NativeSyntheticEvent$1, NativeScrollEvent as NativeScrollEvent$1, ScrollView, StyleProp as StyleProp$1, ViewStyle as ViewStyle$1, ScrollViewComponent, ScrollResponderMixin, Insets as Insets$1, Animated } from 'react-native'; + + interface MaintainVisibleContentPositionNormalized { ++ anchor?: "start" | "end"; + data: boolean; + size: boolean; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; +@@ -292,6 +293,7 @@ interface LegendListSpecificProps { + * - size (default: true) stabilizes during size/layout changes while scrolling. + * - data (default: false) stabilizes when the data array changes. + * - shouldRestorePosition can opt out specific items from data-change anchoring. ++ * - anchor (default: "start") picks the visible item that stays in place: the first, or the last with "end". + * - undefined (default) enables scroll stabilization but skips data-change anchoring. + * - true enables both JS behaviors; false disables both. + * Native iOS maintainVisibleContentPosition is enabled when either size or data stabilization is enabled, +@@ -494,6 +496,12 @@ type LegendListPropsBase, TItemTyp + interface MaintainVisibleContentPositionConfig { + data?: boolean; + size?: boolean; ++ /** ++ * Which visible item stays in place when sizes or data change. "start" (default) keeps the first visible ++ * item in place, so changes move the content below it. "end" keeps the last visible item in place, so changes ++ * move the content above it, like in an inverted list. Use "end" for chat-style lists read from the bottom. ++ */ ++ anchor?: "start" | "end"; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; + } + interface AnchoredEndSpaceReadyInfo { +diff --git a/node_modules/@legendapp/list/keyboard.d.ts b/node_modules/@legendapp/list/keyboard.d.ts +index 2aa690be174..32f72309087 100644 +--- a/node_modules/@legendapp/list/keyboard.d.ts ++++ b/node_modules/@legendapp/list/keyboard.d.ts +@@ -6,6 +6,7 @@ import { SharedValue } from 'react-native-reanimated'; + import { AnimatedLegendListProps } from '@legendapp/list/reanimated'; + + interface MaintainVisibleContentPositionNormalized { ++ anchor?: "start" | "end"; + data: boolean; + size: boolean; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; +diff --git a/node_modules/@legendapp/list/keyboard-legacy.d.ts b/node_modules/@legendapp/list/keyboard-legacy.d.ts +index 0d2e865f8e7..61e36137d52 100644 +--- a/node_modules/@legendapp/list/keyboard-legacy.d.ts ++++ b/node_modules/@legendapp/list/keyboard-legacy.d.ts +@@ -5,6 +5,7 @@ import { ScrollEvent, ScrollHandlerProcessed } from 'react-native-reanimated'; + import { AnimatedLegendListProps } from '@legendapp/list/reanimated'; + + interface MaintainVisibleContentPositionNormalized { ++ anchor?: "start" | "end"; + data: boolean; + size: boolean; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; +diff --git a/node_modules/@legendapp/list/reanimated.d.ts b/node_modules/@legendapp/list/reanimated.d.ts +index 5b568f2d397..f6aa70389be 100644 +--- a/node_modules/@legendapp/list/reanimated.d.ts ++++ b/node_modules/@legendapp/list/reanimated.d.ts +@@ -4,6 +4,7 @@ import Reanimated, { SharedValue } from 'react-native-reanimated'; + import { ScrollViewProps, NativeSyntheticEvent as NativeSyntheticEvent$1, NativeScrollEvent as NativeScrollEvent$1, ScrollView, StyleProp as StyleProp$1, ViewStyle as ViewStyle$1, ScrollViewComponent, ScrollResponderMixin, Insets as Insets$1 } from 'react-native'; + + interface MaintainVisibleContentPositionNormalized { ++ anchor?: "start" | "end"; + data: boolean; + size: boolean; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; +@@ -293,6 +294,7 @@ interface LegendListSpecificProps { + * - size (default: true) stabilizes during size/layout changes while scrolling. + * - data (default: false) stabilizes when the data array changes. + * - shouldRestorePosition can opt out specific items from data-change anchoring. ++ * - anchor (default: "start") picks the visible item that stays in place: the first, or the last with "end". + * - undefined (default) enables scroll stabilization but skips data-change anchoring. + * - true enables both JS behaviors; false disables both. + * Native iOS maintainVisibleContentPosition is enabled when either size or data stabilization is enabled, +@@ -495,6 +497,12 @@ type LegendListPropsBase, TItemTyp + interface MaintainVisibleContentPositionConfig { + data?: boolean; + size?: boolean; ++ /** ++ * Which visible item stays in place when sizes or data change. "start" (default) keeps the first visible ++ * item in place, so changes move the content below it. "end" keeps the last visible item in place, so changes ++ * move the content above it, like in an inverted list. Use "end" for chat-style lists read from the bottom. ++ */ ++ anchor?: "start" | "end"; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; + } + interface AnchoredEndSpaceReadyInfo { +diff --git a/node_modules/@legendapp/list/section-list.d.ts b/node_modules/@legendapp/list/section-list.d.ts +index 94edf333f0d..aab8b29dc8d 100644 +--- a/node_modules/@legendapp/list/section-list.d.ts ++++ b/node_modules/@legendapp/list/section-list.d.ts +@@ -60,6 +60,7 @@ type BuildSectionListDataResult = { + }; + + interface MaintainVisibleContentPositionNormalized { ++ anchor?: "start" | "end"; + data: boolean; + size: boolean; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; +@@ -349,6 +350,7 @@ interface LegendListSpecificProps { + * - size (default: true) stabilizes during size/layout changes while scrolling. + * - data (default: false) stabilizes when the data array changes. + * - shouldRestorePosition can opt out specific items from data-change anchoring. ++ * - anchor (default: "start") picks the visible item that stays in place: the first, or the last with "end". + * - undefined (default) enables scroll stabilization but skips data-change anchoring. + * - true enables both JS behaviors; false disables both. + * Native iOS maintainVisibleContentPosition is enabled when either size or data stabilization is enabled, +@@ -551,6 +553,12 @@ type LegendListPropsBase, TItemTyp + interface MaintainVisibleContentPositionConfig { + data?: boolean; + size?: boolean; ++ /** ++ * Which visible item stays in place when sizes or data change. "start" (default) keeps the first visible ++ * item in place, so changes move the content below it. "end" keeps the last visible item in place, so changes ++ * move the content above it, like in an inverted list. Use "end" for chat-style lists read from the bottom. ++ */ ++ anchor?: "start" | "end"; + shouldRestorePosition?: (item: ItemT, index: number, data: readonly ItemT[]) => boolean; + } + interface AnchoredEndSpaceReadyInfo { diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch deleted file mode 100644 index bad48a60d7d7..000000000000 --- a/patches/@legendapp/list/@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch +++ /dev/null @@ -1,108 +0,0 @@ -diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js -index c102661..99cac83 100644 ---- a/node_modules/@legendapp/list/react-native.web.js -+++ b/node_modules/@legendapp/list/react-native.web.js -@@ -1706,6 +1706,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { - const { - maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } - } = props; -+ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; - const now = Date.now(); - const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); - const scrollingTo = state.scrollingTo; -@@ -1743,11 +1744,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { - } else if (scrollTarget !== void 0) { - targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); - } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { -- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); -+ targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); - } - if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { -- for (let i = 0; i < idsInView.length; i++) { -- const id = idsInView[i]; -+ for (let i = 0; i < anchorIdsInView.length; i++) { -+ const id = anchorIdsInView[i]; - const index = previousIndexByKey.get(id); - if (index !== void 0) { - const position = positions[index]; -diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs -index ff71fd6..a7a89dd 100644 ---- a/node_modules/@legendapp/list/react-native.web.mjs -+++ b/node_modules/@legendapp/list/react-native.web.mjs -@@ -1685,6 +1685,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { - const { - maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } - } = props; -+ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; - const now = Date.now(); - const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); - const scrollingTo = state.scrollingTo; -@@ -1722,11 +1723,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { - } else if (scrollTarget !== void 0) { - targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); - } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { -- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); -+ targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); - } - if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { -- for (let i = 0; i < idsInView.length; i++) { -- const id = idsInView[i]; -+ for (let i = 0; i < anchorIdsInView.length; i++) { -+ const id = anchorIdsInView[i]; - const index = previousIndexByKey.get(id); - if (index !== void 0) { - const position = positions[index]; -diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js -index c102661..99cac83 100644 ---- a/node_modules/@legendapp/list/react.js -+++ b/node_modules/@legendapp/list/react.js -@@ -1706,6 +1706,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { - const { - maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } - } = props; -+ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; - const now = Date.now(); - const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); - const scrollingTo = state.scrollingTo; -@@ -1743,11 +1744,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { - } else if (scrollTarget !== void 0) { - targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); - } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { -- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); -+ targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); - } - if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { -- for (let i = 0; i < idsInView.length; i++) { -- const id = idsInView[i]; -+ for (let i = 0; i < anchorIdsInView.length; i++) { -+ const id = anchorIdsInView[i]; - const index = previousIndexByKey.get(id); - if (index !== void 0) { - const position = positions[index]; -diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs -index ff71fd6..a7a89dd 100644 ---- a/node_modules/@legendapp/list/react.mjs -+++ b/node_modules/@legendapp/list/react.mjs -@@ -1685,6 +1685,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { - const { - maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } - } = props; -+ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; - const now = Date.now(); - const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); - const scrollingTo = state.scrollingTo; -@@ -1722,11 +1723,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { - } else if (scrollTarget !== void 0) { - targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); - } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { -- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); -+ targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); - } - if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { -- for (let i = 0; i < idsInView.length; i++) { -- const id = idsInView[i]; -+ for (let i = 0; i < anchorIdsInView.length; i++) { -+ const id = anchorIdsInView[i]; - const index = previousIndexByKey.get(id); - if (index !== void 0) { - const position = positions[index]; diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch index 86a890e5d799..22a71ea829e0 100644 --- a/patches/@legendapp/list/@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch @@ -1,8 +1,36 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index e687e9c0a41..e08a0f5d005 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -7048,6 +7048,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) + state.scrollForNextCalculateItemsInView = void 0; + } + state.positionsAreCurrent = false; ++ if (state.props.maintainVisibleContentPosition.data) { ++ state.userScrollAnchorReset = void 0; ++ } + calculateItemsInView(ctx, { dataChanged: true, doMVCP: true }); + if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { + const removedMeasurements = new Set(state.pendingEndMeasurements); +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index 6271d782ea7..5eac366737d 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -7027,6 +7027,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) + state.scrollForNextCalculateItemsInView = void 0; + } + state.positionsAreCurrent = false; ++ if (state.props.maintainVisibleContentPosition.data) { ++ state.userScrollAnchorReset = void 0; ++ } + calculateItemsInView(ctx, { dataChanged: true, doMVCP: true }); + if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { + const removedMeasurements = new Set(state.pendingEndMeasurements); diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js -index 99cac83..30d9757 100644 +index 2b3171a6f4c..b7c3ecb0d71 100644 --- a/node_modules/@legendapp/list/react-native.web.js +++ b/node_modules/@legendapp/list/react-native.web.js -@@ -7880,6 +7880,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) +@@ -7886,6 +7886,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) state.scrollForNextCalculateItemsInView = void 0; } state.positionsAreCurrent = false; @@ -13,10 +41,10 @@ index 99cac83..30d9757 100644 if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { const removedMeasurements = new Set(state.pendingEndMeasurements); diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs -index a7a89dd..f55dea9 100644 +index dbd6fbbf3e8..0831a154ba8 100644 --- a/node_modules/@legendapp/list/react-native.web.mjs +++ b/node_modules/@legendapp/list/react-native.web.mjs -@@ -7859,6 +7859,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) +@@ -7865,6 +7865,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) state.scrollForNextCalculateItemsInView = void 0; } state.positionsAreCurrent = false; @@ -27,10 +55,10 @@ index a7a89dd..f55dea9 100644 if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { const removedMeasurements = new Set(state.pendingEndMeasurements); diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js -index 99cac83..30d9757 100644 +index 2b3171a6f4c..b7c3ecb0d71 100644 --- a/node_modules/@legendapp/list/react.js +++ b/node_modules/@legendapp/list/react.js -@@ -7880,6 +7880,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) +@@ -7886,6 +7886,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) state.scrollForNextCalculateItemsInView = void 0; } state.positionsAreCurrent = false; @@ -41,10 +69,10 @@ index 99cac83..30d9757 100644 if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { const removedMeasurements = new Set(state.pendingEndMeasurements); diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs -index a7a89dd..f55dea9 100644 +index dbd6fbbf3e8..0831a154ba8 100644 --- a/node_modules/@legendapp/list/react.mjs +++ b/node_modules/@legendapp/list/react.mjs -@@ -7859,6 +7859,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) +@@ -7865,6 +7865,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) state.scrollForNextCalculateItemsInView = void 0; } state.positionsAreCurrent = false; diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+004+land-initial-mount-adjustments-once.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+004+land-initial-mount-adjustments-once.patch new file mode 100644 index 000000000000..c08c1313b69e --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+004+land-initial-mount-adjustments-once.patch @@ -0,0 +1,472 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index e08a0f5d005..c9df90ee7b9 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -1063,9 +1063,6 @@ function requestAdjust(ctx, positionDiff, source) { + ctx.doScrollTo({ horizontal: state.props.horizontal, isAdjustment: true, offset: state.scroll }); + } else { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -1073,6 +1070,7 @@ function requestAdjust(ctx, positionDiff, source) { + state.userScrollContentSize += positionDiff; + } + state.scrollForNextCalculateItemsInView = void 0; ++ shiftNativeInitialScrollTarget(state, positionDiff); + const readyToRender = peek$(ctx, "readyToRender"); + if (readyToRender) { + doit(); +@@ -1104,10 +1102,33 @@ function requestAdjust(ctx, positionDiff, source) { + } + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ const nativeScrollEpoch = state.nativeScrollEpoch; ++ requestAnimationFrame(() => { ++ if (state.nativeScrollEpoch === nativeScrollEpoch) { ++ doit(); ++ } ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } ++function shiftNativeInitialScrollTarget(state, positionDiff) { ++ const scrollingTo = state.scrollingTo; ++ if (Platform.OS === "web" || !(scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) || scrollingTo.animated) { ++ return; ++ } ++ scrollingTo.offset += positionDiff; ++ if (scrollingTo.targetOffset !== void 0) { ++ scrollingTo.targetOffset += positionDiff; ++ } ++ const watchdog = initialScrollWatchdog.get(state); ++ if (watchdog) { ++ watchdog.targetOffset += positionDiff; ++ } ++} + + // src/core/finishMaintainScrollAtEnd.ts + function interruptMaintainScrollAtEnd(ctx) { +@@ -1529,7 +1550,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +@@ -2560,8 +2583,11 @@ function checkFinishedScrollFrame(ctx) { + } + } + function scrollToFallbackOffset(ctx, offset, animated = false) { +- var _a3; +- (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); ++ var _a3, _b; ++ if (Platform.OS !== "web") { ++ ctx.state.nativeScrollEpoch = ((_a3 = ctx.state.nativeScrollEpoch) != null ? _a3 : 0) + 1; ++ } ++ (_b = ctx.state.refScroller.current) == null ? void 0 : _b.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); + } + function checkFinishedScrollFallback(ctx) { + const state = ctx.state; +@@ -2642,6 +2668,7 @@ function checkFinishedScrollFallback(ctx) { + + // src/core/doScrollTo.native.ts + function doScrollTo(ctx, params) { ++ var _a3; + const state = ctx.state; + const { animated, horizontal, isInitialScroll, offset } = params; + const isAnimated = !!animated; +@@ -2652,6 +2679,7 @@ function doScrollTo(ctx, params) { + } + if (!params.isAdjustment) { + state.userScrollContentSize = void 0; ++ state.nativeScrollEpoch = ((_a3 = state.nativeScrollEpoch) != null ? _a3 : 0) + 1; + } + scroller.scrollTo({ animated: isAnimated, ...getNativeScrollPoint(ctx, offset, horizontal) }); + if (isInitialScroll) { +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index 5eac366737d..7de20403d1d 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -1042,9 +1042,6 @@ function requestAdjust(ctx, positionDiff, source) { + ctx.doScrollTo({ horizontal: state.props.horizontal, isAdjustment: true, offset: state.scroll }); + } else { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -1052,6 +1049,7 @@ function requestAdjust(ctx, positionDiff, source) { + state.userScrollContentSize += positionDiff; + } + state.scrollForNextCalculateItemsInView = void 0; ++ shiftNativeInitialScrollTarget(state, positionDiff); + const readyToRender = peek$(ctx, "readyToRender"); + if (readyToRender) { + doit(); +@@ -1083,10 +1081,33 @@ function requestAdjust(ctx, positionDiff, source) { + } + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ const nativeScrollEpoch = state.nativeScrollEpoch; ++ requestAnimationFrame(() => { ++ if (state.nativeScrollEpoch === nativeScrollEpoch) { ++ doit(); ++ } ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } ++function shiftNativeInitialScrollTarget(state, positionDiff) { ++ const scrollingTo = state.scrollingTo; ++ if (Platform.OS === "web" || !(scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) || scrollingTo.animated) { ++ return; ++ } ++ scrollingTo.offset += positionDiff; ++ if (scrollingTo.targetOffset !== void 0) { ++ scrollingTo.targetOffset += positionDiff; ++ } ++ const watchdog = initialScrollWatchdog.get(state); ++ if (watchdog) { ++ watchdog.targetOffset += positionDiff; ++ } ++} + + // src/core/finishMaintainScrollAtEnd.ts + function interruptMaintainScrollAtEnd(ctx) { +@@ -1508,7 +1529,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +@@ -2539,8 +2562,11 @@ function checkFinishedScrollFrame(ctx) { + } + } + function scrollToFallbackOffset(ctx, offset, animated = false) { +- var _a3; +- (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); ++ var _a3, _b; ++ if (Platform.OS !== "web") { ++ ctx.state.nativeScrollEpoch = ((_a3 = ctx.state.nativeScrollEpoch) != null ? _a3 : 0) + 1; ++ } ++ (_b = ctx.state.refScroller.current) == null ? void 0 : _b.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); + } + function checkFinishedScrollFallback(ctx) { + const state = ctx.state; +@@ -2621,6 +2647,7 @@ function checkFinishedScrollFallback(ctx) { + + // src/core/doScrollTo.native.ts + function doScrollTo(ctx, params) { ++ var _a3; + const state = ctx.state; + const { animated, horizontal, isInitialScroll, offset } = params; + const isAnimated = !!animated; +@@ -2631,6 +2658,7 @@ function doScrollTo(ctx, params) { + } + if (!params.isAdjustment) { + state.userScrollContentSize = void 0; ++ state.nativeScrollEpoch = ((_a3 = state.nativeScrollEpoch) != null ? _a3 : 0) + 1; + } + scroller.scrollTo({ animated: isAnimated, ...getNativeScrollPoint(ctx, offset, horizontal) }); + if (isInitialScroll) { +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index b7c3ecb0d71..10dd9b9c9bb 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -1001,9 +1001,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -1011,15 +1008,31 @@ function requestAdjust(ctx, positionDiff, source) { + state.userScrollContentSize += positionDiff; + } + state.scrollForNextCalculateItemsInView = void 0; ++ shiftNativeInitialScrollTarget(state); + const readyToRender = peek$(ctx, "readyToRender"); + if (readyToRender) { + doit(); + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ const nativeScrollEpoch = state.nativeScrollEpoch; ++ requestAnimationFrame(() => { ++ if (state.nativeScrollEpoch === nativeScrollEpoch) { ++ doit(); ++ } ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } ++function shiftNativeInitialScrollTarget(state, positionDiff) { ++ state.scrollingTo; ++ { ++ return; ++ } ++} + + // src/core/finishMaintainScrollAtEnd.ts + function interruptMaintainScrollAtEnd(ctx) { +@@ -1435,7 +1448,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +@@ -2449,8 +2464,8 @@ function checkFinishedScrollFrame(ctx) { + } + } + function scrollToFallbackOffset(ctx, offset, animated = false) { +- var _a3; +- (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); ++ var _b; ++ (_b = ctx.state.refScroller.current) == null ? void 0 : _b.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); + } + function checkFinishedScrollFallback(ctx) { + const state = ctx.state; +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index 0831a154ba8..0ba06dac531 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -980,9 +980,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -990,15 +987,31 @@ function requestAdjust(ctx, positionDiff, source) { + state.userScrollContentSize += positionDiff; + } + state.scrollForNextCalculateItemsInView = void 0; ++ shiftNativeInitialScrollTarget(state); + const readyToRender = peek$(ctx, "readyToRender"); + if (readyToRender) { + doit(); + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ const nativeScrollEpoch = state.nativeScrollEpoch; ++ requestAnimationFrame(() => { ++ if (state.nativeScrollEpoch === nativeScrollEpoch) { ++ doit(); ++ } ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } ++function shiftNativeInitialScrollTarget(state, positionDiff) { ++ state.scrollingTo; ++ { ++ return; ++ } ++} + + // src/core/finishMaintainScrollAtEnd.ts + function interruptMaintainScrollAtEnd(ctx) { +@@ -1414,7 +1427,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +@@ -2428,8 +2443,8 @@ function checkFinishedScrollFrame(ctx) { + } + } + function scrollToFallbackOffset(ctx, offset, animated = false) { +- var _a3; +- (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); ++ var _b; ++ (_b = ctx.state.refScroller.current) == null ? void 0 : _b.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); + } + function checkFinishedScrollFallback(ctx) { + const state = ctx.state; +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index b7c3ecb0d71..10dd9b9c9bb 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -1001,9 +1001,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -1011,15 +1008,31 @@ function requestAdjust(ctx, positionDiff, source) { + state.userScrollContentSize += positionDiff; + } + state.scrollForNextCalculateItemsInView = void 0; ++ shiftNativeInitialScrollTarget(state); + const readyToRender = peek$(ctx, "readyToRender"); + if (readyToRender) { + doit(); + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ const nativeScrollEpoch = state.nativeScrollEpoch; ++ requestAnimationFrame(() => { ++ if (state.nativeScrollEpoch === nativeScrollEpoch) { ++ doit(); ++ } ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } ++function shiftNativeInitialScrollTarget(state, positionDiff) { ++ state.scrollingTo; ++ { ++ return; ++ } ++} + + // src/core/finishMaintainScrollAtEnd.ts + function interruptMaintainScrollAtEnd(ctx) { +@@ -1435,7 +1448,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +@@ -2449,8 +2464,8 @@ function checkFinishedScrollFrame(ctx) { + } + } + function scrollToFallbackOffset(ctx, offset, animated = false) { +- var _a3; +- (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); ++ var _b; ++ (_b = ctx.state.refScroller.current) == null ? void 0 : _b.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); + } + function checkFinishedScrollFallback(ctx) { + const state = ctx.state; +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index 0831a154ba8..0ba06dac531 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -980,9 +980,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -990,15 +987,31 @@ function requestAdjust(ctx, positionDiff, source) { + state.userScrollContentSize += positionDiff; + } + state.scrollForNextCalculateItemsInView = void 0; ++ shiftNativeInitialScrollTarget(state); + const readyToRender = peek$(ctx, "readyToRender"); + if (readyToRender) { + doit(); + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ const nativeScrollEpoch = state.nativeScrollEpoch; ++ requestAnimationFrame(() => { ++ if (state.nativeScrollEpoch === nativeScrollEpoch) { ++ doit(); ++ } ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } ++function shiftNativeInitialScrollTarget(state, positionDiff) { ++ state.scrollingTo; ++ { ++ return; ++ } ++} + + // src/core/finishMaintainScrollAtEnd.ts + function interruptMaintainScrollAtEnd(ctx) { +@@ -1414,7 +1427,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +@@ -2428,8 +2443,8 @@ function checkFinishedScrollFrame(ctx) { + } + } + function scrollToFallbackOffset(ctx, offset, animated = false) { +- var _a3; +- (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); ++ var _b; ++ (_b = ctx.state.refScroller.current) == null ? void 0 : _b.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); + } + function checkFinishedScrollFallback(ctx) { + const state = ctx.state; diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch deleted file mode 100644 index 3ced246136c8..000000000000 --- a/patches/@legendapp/list/@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch +++ /dev/null @@ -1,240 +0,0 @@ -diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js -index 4af685d..8fd2025 100644 ---- a/node_modules/@legendapp/list/react-native.js -+++ b/node_modules/@legendapp/list/react-native.js -@@ -1063,9 +1063,6 @@ function requestAdjust(ctx, positionDiff, source) { - ctx.doScrollTo({ horizontal: state.props.horizontal, isAdjustment: true, offset: state.scroll }); - } else { - state.scrollAdjustHandler.requestAdjust(positionDiff); -- if (state.adjustingFromInitialMount) { -- state.adjustingFromInitialMount--; -- } - } - }; - state.scroll += positionDiff; -@@ -1104,7 +1101,13 @@ function requestAdjust(ctx, positionDiff, source) { - } - } else { - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; -- requestAnimationFrame(doit); -+ requestAnimationFrame(() => { -+ doit(); -+ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; -+ if (!state.adjustingFromInitialMount) { -+ setInitialRenderState(ctx, {}); -+ } -+ }); - } - } - } -@@ -1529,7 +1532,9 @@ function setInitialRenderState(ctx, { - if (didInitialScroll) { - state.didFinishInitialScroll = true; - } -- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); -+ const isReadyToRender = Boolean( -+ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount -+ ); - if (isReadyToRender && !peek$(ctx, "readyToRender")) { - set$(ctx, "readyToRender", true); - setAdaptiveRender(ctx, "normal", "ready"); -diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs -index 9ebfddc..53fb116 100644 ---- a/node_modules/@legendapp/list/react-native.mjs -+++ b/node_modules/@legendapp/list/react-native.mjs -@@ -1042,9 +1042,6 @@ function requestAdjust(ctx, positionDiff, source) { - ctx.doScrollTo({ horizontal: state.props.horizontal, isAdjustment: true, offset: state.scroll }); - } else { - state.scrollAdjustHandler.requestAdjust(positionDiff); -- if (state.adjustingFromInitialMount) { -- state.adjustingFromInitialMount--; -- } - } - }; - state.scroll += positionDiff; -@@ -1083,7 +1080,13 @@ function requestAdjust(ctx, positionDiff, source) { - } - } else { - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; -- requestAnimationFrame(doit); -+ requestAnimationFrame(() => { -+ doit(); -+ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; -+ if (!state.adjustingFromInitialMount) { -+ setInitialRenderState(ctx, {}); -+ } -+ }); - } - } - } -@@ -1508,7 +1511,9 @@ function setInitialRenderState(ctx, { - if (didInitialScroll) { - state.didFinishInitialScroll = true; - } -- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); -+ const isReadyToRender = Boolean( -+ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount -+ ); - if (isReadyToRender && !peek$(ctx, "readyToRender")) { - set$(ctx, "readyToRender", true); - setAdaptiveRender(ctx, "normal", "ready"); -diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js -index 30d9757..b782503 100644 ---- a/node_modules/@legendapp/list/react-native.web.js -+++ b/node_modules/@legendapp/list/react-native.web.js -@@ -1001,9 +1001,6 @@ function requestAdjust(ctx, positionDiff, source) { - const doit = () => { - { - state.scrollAdjustHandler.requestAdjust(positionDiff); -- if (state.adjustingFromInitialMount) { -- state.adjustingFromInitialMount--; -- } - } - }; - state.scroll += positionDiff; -@@ -1016,7 +1013,13 @@ function requestAdjust(ctx, positionDiff, source) { - doit(); - } else { - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; -- requestAnimationFrame(doit); -+ requestAnimationFrame(() => { -+ doit(); -+ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; -+ if (!state.adjustingFromInitialMount) { -+ setInitialRenderState(ctx, {}); -+ } -+ }); - } - } - } -@@ -1435,7 +1438,9 @@ function setInitialRenderState(ctx, { - if (didInitialScroll) { - state.didFinishInitialScroll = true; - } -- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); -+ const isReadyToRender = Boolean( -+ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount -+ ); - if (isReadyToRender && !peek$(ctx, "readyToRender")) { - set$(ctx, "readyToRender", true); - setAdaptiveRender(ctx, "normal", "ready"); -diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs -index f55dea9..a1bcd90 100644 ---- a/node_modules/@legendapp/list/react-native.web.mjs -+++ b/node_modules/@legendapp/list/react-native.web.mjs -@@ -980,9 +980,6 @@ function requestAdjust(ctx, positionDiff, source) { - const doit = () => { - { - state.scrollAdjustHandler.requestAdjust(positionDiff); -- if (state.adjustingFromInitialMount) { -- state.adjustingFromInitialMount--; -- } - } - }; - state.scroll += positionDiff; -@@ -995,7 +992,13 @@ function requestAdjust(ctx, positionDiff, source) { - doit(); - } else { - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; -- requestAnimationFrame(doit); -+ requestAnimationFrame(() => { -+ doit(); -+ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; -+ if (!state.adjustingFromInitialMount) { -+ setInitialRenderState(ctx, {}); -+ } -+ }); - } - } - } -@@ -1414,7 +1417,9 @@ function setInitialRenderState(ctx, { - if (didInitialScroll) { - state.didFinishInitialScroll = true; - } -- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); -+ const isReadyToRender = Boolean( -+ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount -+ ); - if (isReadyToRender && !peek$(ctx, "readyToRender")) { - set$(ctx, "readyToRender", true); - setAdaptiveRender(ctx, "normal", "ready"); -diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js -index 30d9757..b782503 100644 ---- a/node_modules/@legendapp/list/react.js -+++ b/node_modules/@legendapp/list/react.js -@@ -1001,9 +1001,6 @@ function requestAdjust(ctx, positionDiff, source) { - const doit = () => { - { - state.scrollAdjustHandler.requestAdjust(positionDiff); -- if (state.adjustingFromInitialMount) { -- state.adjustingFromInitialMount--; -- } - } - }; - state.scroll += positionDiff; -@@ -1016,7 +1013,13 @@ function requestAdjust(ctx, positionDiff, source) { - doit(); - } else { - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; -- requestAnimationFrame(doit); -+ requestAnimationFrame(() => { -+ doit(); -+ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; -+ if (!state.adjustingFromInitialMount) { -+ setInitialRenderState(ctx, {}); -+ } -+ }); - } - } - } -@@ -1435,7 +1438,9 @@ function setInitialRenderState(ctx, { - if (didInitialScroll) { - state.didFinishInitialScroll = true; - } -- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); -+ const isReadyToRender = Boolean( -+ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount -+ ); - if (isReadyToRender && !peek$(ctx, "readyToRender")) { - set$(ctx, "readyToRender", true); - setAdaptiveRender(ctx, "normal", "ready"); -diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs -index f55dea9..a1bcd90 100644 ---- a/node_modules/@legendapp/list/react.mjs -+++ b/node_modules/@legendapp/list/react.mjs -@@ -980,9 +980,6 @@ function requestAdjust(ctx, positionDiff, source) { - const doit = () => { - { - state.scrollAdjustHandler.requestAdjust(positionDiff); -- if (state.adjustingFromInitialMount) { -- state.adjustingFromInitialMount--; -- } - } - }; - state.scroll += positionDiff; -@@ -995,7 +992,13 @@ function requestAdjust(ctx, positionDiff, source) { - doit(); - } else { - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; -- requestAnimationFrame(doit); -+ requestAnimationFrame(() => { -+ doit(); -+ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; -+ if (!state.adjustingFromInitialMount) { -+ setInitialRenderState(ctx, {}); -+ } -+ }); - } - } - } -@@ -1414,7 +1417,9 @@ function setInitialRenderState(ctx, { - if (didInitialScroll) { - state.didFinishInitialScroll = true; - } -- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); -+ const isReadyToRender = Boolean( -+ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount -+ ); - if (isReadyToRender && !peek$(ctx, "readyToRender")) { - set$(ctx, "readyToRender", true); - setAdaptiveRender(ctx, "normal", "ready"); diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch index 8c8072e4d7a7..1d0eeaac24e7 100644 --- a/patches/@legendapp/list/@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch @@ -1,29 +1,25 @@ diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index c9df90ee7b9..17727711406 100644 --- a/node_modules/@legendapp/list/react-native.js +++ b/node_modules/@legendapp/list/react-native.js -@@ -6636,7 +6636,10 @@ +@@ -6672,7 +6672,7 @@ function getAutoOtherAxisStyle({ function ScrollAdjust() { var _a3; const ctx = useStateContext(); - const bias = 1e7; -+ // The bias keeps this anchor below the scroll offset, so native maintainVisibleContentPosition always anchors to it. -+ // Native layout stores positions as 32-bit floats, which hold 10,000,000 only to the nearest whole point. That -+ // rounded each adjustment by up to half a point, and the visible items moved by the difference. + const bias = 1e6; const [scrollAdjust, scrollAdjustUserOffset] = useArr$(["scrollAdjust", "scrollAdjustUserOffset"]); const scrollOffset = (scrollAdjust || 0) + (scrollAdjustUserOffset || 0) + bias; const horizontal = !!((_a3 = ctx.state) == null ? void 0 : _a3.props.horizontal); diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index 7de20403d1d..a895a1aad01 100644 --- a/node_modules/@legendapp/list/react-native.mjs +++ b/node_modules/@legendapp/list/react-native.mjs -@@ -6615,7 +6615,10 @@ +@@ -6651,7 +6651,7 @@ function getAutoOtherAxisStyle({ function ScrollAdjust() { var _a3; const ctx = useStateContext(); - const bias = 1e7; -+ // The bias keeps this anchor below the scroll offset, so native maintainVisibleContentPosition always anchors to it. -+ // Native layout stores positions as 32-bit floats, which hold 10,000,000 only to the nearest whole point. That -+ // rounded each adjustment by up to half a point, and the visible items moved by the difference. + const bias = 1e6; const [scrollAdjust, scrollAdjustUserOffset] = useArr$(["scrollAdjust", "scrollAdjustUserOffset"]); const scrollOffset = (scrollAdjust || 0) + (scrollAdjustUserOffset || 0) + bias; diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+006+finish-silent-initial-scroll-retries.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+006+finish-silent-initial-scroll-retries.patch new file mode 100644 index 000000000000..9eccd66abcac --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+006+finish-silent-initial-scroll-retries.patch @@ -0,0 +1,282 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index 17727711406..3ccffa79110 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -518,7 +518,9 @@ var ENABLE_DEVMODE = IS_DEV && false; + var ENABLE_DEBUG_VIEW = IS_DEV && false; + + // src/core/cancelImperativeScroll.ts +-function cancelScrollCompletionChecks({ scheduledWork }) { ++function cancelScrollCompletionChecks(state) { ++ const { scheduledWork } = state; ++ state.silentInitialScrollReturn = void 0; + scheduledWork.cancel("checkFinishedScrollFrame"); + scheduledWork.cancel("checkFinishedScrollRetryFrame"); + scheduledWork.cancel("checkFinishedScrollFallback"); +@@ -2460,11 +2462,12 @@ function doMaintainScrollAtEnd(ctx, options) { + + // src/core/finishScrollTo.ts + function finishScrollTo(ctx) { +- var _a3, _b, _c; ++ var _a3, _b, _c, _d; + const state = ctx.state; + if (state == null ? void 0 : state.scrollingTo) { ++ (_a3 = state.silentInitialScrollReturn) == null ? void 0 : _a3.call(state); + cancelScrollCompletionChecks(state); +- const resolvePendingScroll = ((_a3 = ctx.scrollRequestTracker) == null ? void 0 : _a3.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; ++ const resolvePendingScroll = ((_b = ctx.scrollRequestTracker) == null ? void 0 : _b.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; + if (resolvePendingScroll) { + state.pendingScrollResolve = void 0; + } +@@ -2487,8 +2490,8 @@ function finishScrollTo(ctx) { + } + }; + if (scrollingTo.isInitialScroll || state.initialScroll) { +- const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; ++ const isOffsetSession = ((_c = state.initialScrollSession) == null ? void 0 : _c.kind) === "offset"; ++ const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_d = state.initialScroll) == null ? void 0 : _d.viewPosition) === 1; + finishInitialScroll(ctx, { + onFinished: complete, + preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +@@ -2636,10 +2639,13 @@ function checkFinishedScrollFallback(ctx) { + const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; + initialScrollCompletion.markSilentInitialScrollRetry(state); + scrollToFallbackOffset(ctx, jiggleOffset); +- state.scheduledWork.frame( +- () => scrollToFallbackOffset(ctx, targetOffset), +- "checkFinishedScrollRetryFrame" +- ); ++ const returnToTarget = () => { ++ var _a4, _b2; ++ state.silentInitialScrollReturn = void 0; ++ scrollToFallbackOffset(ctx, (_b2 = (_a4 = initialScrollWatchdog.get(state)) == null ? void 0 : _a4.targetOffset) != null ? _b2 : targetOffset); ++ }; ++ state.silentInitialScrollReturn = returnToTarget; ++ state.scheduledWork.frame(returnToTarget, "checkFinishedScrollRetryFrame"); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index a895a1aad01..676c117a45a 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -497,7 +497,9 @@ var ENABLE_DEVMODE = IS_DEV && false; + var ENABLE_DEBUG_VIEW = IS_DEV && false; + + // src/core/cancelImperativeScroll.ts +-function cancelScrollCompletionChecks({ scheduledWork }) { ++function cancelScrollCompletionChecks(state) { ++ const { scheduledWork } = state; ++ state.silentInitialScrollReturn = void 0; + scheduledWork.cancel("checkFinishedScrollFrame"); + scheduledWork.cancel("checkFinishedScrollRetryFrame"); + scheduledWork.cancel("checkFinishedScrollFallback"); +@@ -2439,11 +2441,12 @@ function doMaintainScrollAtEnd(ctx, options) { + + // src/core/finishScrollTo.ts + function finishScrollTo(ctx) { +- var _a3, _b, _c; ++ var _a3, _b, _c, _d; + const state = ctx.state; + if (state == null ? void 0 : state.scrollingTo) { ++ (_a3 = state.silentInitialScrollReturn) == null ? void 0 : _a3.call(state); + cancelScrollCompletionChecks(state); +- const resolvePendingScroll = ((_a3 = ctx.scrollRequestTracker) == null ? void 0 : _a3.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; ++ const resolvePendingScroll = ((_b = ctx.scrollRequestTracker) == null ? void 0 : _b.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; + if (resolvePendingScroll) { + state.pendingScrollResolve = void 0; + } +@@ -2466,8 +2469,8 @@ function finishScrollTo(ctx) { + } + }; + if (scrollingTo.isInitialScroll || state.initialScroll) { +- const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; ++ const isOffsetSession = ((_c = state.initialScrollSession) == null ? void 0 : _c.kind) === "offset"; ++ const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_d = state.initialScroll) == null ? void 0 : _d.viewPosition) === 1; + finishInitialScroll(ctx, { + onFinished: complete, + preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +@@ -2615,10 +2618,13 @@ function checkFinishedScrollFallback(ctx) { + const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; + initialScrollCompletion.markSilentInitialScrollRetry(state); + scrollToFallbackOffset(ctx, jiggleOffset); +- state.scheduledWork.frame( +- () => scrollToFallbackOffset(ctx, targetOffset), +- "checkFinishedScrollRetryFrame" +- ); ++ const returnToTarget = () => { ++ var _a4, _b2; ++ state.silentInitialScrollReturn = void 0; ++ scrollToFallbackOffset(ctx, (_b2 = (_a4 = initialScrollWatchdog.get(state)) == null ? void 0 : _a4.targetOffset) != null ? _b2 : targetOffset); ++ }; ++ state.silentInitialScrollReturn = returnToTarget; ++ state.scheduledWork.frame(returnToTarget, "checkFinishedScrollRetryFrame"); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index 10dd9b9c9bb..b6f2da18e38 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -459,7 +459,9 @@ function calculateOffsetForIndex(ctx, index) { + } + + // src/core/cancelImperativeScroll.ts +-function cancelScrollCompletionChecks({ scheduledWork }) { ++function cancelScrollCompletionChecks(state) { ++ const { scheduledWork } = state; ++ state.silentInitialScrollReturn = void 0; + scheduledWork.cancel("checkFinishedScrollFrame"); + scheduledWork.cancel("checkFinishedScrollRetryFrame"); + scheduledWork.cancel("checkFinishedScrollFallback"); +@@ -2342,11 +2344,12 @@ function doMaintainScrollAtEnd(ctx, options) { + + // src/core/finishScrollTo.ts + function finishScrollTo(ctx) { +- var _a3, _b, _c; ++ var _a3, _b, _c, _d; + const state = ctx.state; + if (state == null ? void 0 : state.scrollingTo) { ++ (_a3 = state.silentInitialScrollReturn) == null ? void 0 : _a3.call(state); + cancelScrollCompletionChecks(state); +- const resolvePendingScroll = ((_a3 = ctx.scrollRequestTracker) == null ? void 0 : _a3.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; ++ const resolvePendingScroll = ((_b = ctx.scrollRequestTracker) == null ? void 0 : _b.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; + if (resolvePendingScroll) { + state.pendingScrollResolve = void 0; + } +@@ -2369,8 +2372,8 @@ function finishScrollTo(ctx) { + } + }; + if (scrollingTo.isInitialScroll || state.initialScroll) { +- const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; ++ const isOffsetSession = ((_c = state.initialScrollSession) == null ? void 0 : _c.kind) === "offset"; ++ const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_d = state.initialScroll) == null ? void 0 : _d.viewPosition) === 1; + finishInitialScroll(ctx, { + onFinished: complete, + preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index 0ba06dac531..7413734a88e 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -438,7 +438,9 @@ function calculateOffsetForIndex(ctx, index) { + } + + // src/core/cancelImperativeScroll.ts +-function cancelScrollCompletionChecks({ scheduledWork }) { ++function cancelScrollCompletionChecks(state) { ++ const { scheduledWork } = state; ++ state.silentInitialScrollReturn = void 0; + scheduledWork.cancel("checkFinishedScrollFrame"); + scheduledWork.cancel("checkFinishedScrollRetryFrame"); + scheduledWork.cancel("checkFinishedScrollFallback"); +@@ -2321,11 +2323,12 @@ function doMaintainScrollAtEnd(ctx, options) { + + // src/core/finishScrollTo.ts + function finishScrollTo(ctx) { +- var _a3, _b, _c; ++ var _a3, _b, _c, _d; + const state = ctx.state; + if (state == null ? void 0 : state.scrollingTo) { ++ (_a3 = state.silentInitialScrollReturn) == null ? void 0 : _a3.call(state); + cancelScrollCompletionChecks(state); +- const resolvePendingScroll = ((_a3 = ctx.scrollRequestTracker) == null ? void 0 : _a3.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; ++ const resolvePendingScroll = ((_b = ctx.scrollRequestTracker) == null ? void 0 : _b.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; + if (resolvePendingScroll) { + state.pendingScrollResolve = void 0; + } +@@ -2348,8 +2351,8 @@ function finishScrollTo(ctx) { + } + }; + if (scrollingTo.isInitialScroll || state.initialScroll) { +- const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; ++ const isOffsetSession = ((_c = state.initialScrollSession) == null ? void 0 : _c.kind) === "offset"; ++ const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_d = state.initialScroll) == null ? void 0 : _d.viewPosition) === 1; + finishInitialScroll(ctx, { + onFinished: complete, + preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index 10dd9b9c9bb..b6f2da18e38 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -459,7 +459,9 @@ function calculateOffsetForIndex(ctx, index) { + } + + // src/core/cancelImperativeScroll.ts +-function cancelScrollCompletionChecks({ scheduledWork }) { ++function cancelScrollCompletionChecks(state) { ++ const { scheduledWork } = state; ++ state.silentInitialScrollReturn = void 0; + scheduledWork.cancel("checkFinishedScrollFrame"); + scheduledWork.cancel("checkFinishedScrollRetryFrame"); + scheduledWork.cancel("checkFinishedScrollFallback"); +@@ -2342,11 +2344,12 @@ function doMaintainScrollAtEnd(ctx, options) { + + // src/core/finishScrollTo.ts + function finishScrollTo(ctx) { +- var _a3, _b, _c; ++ var _a3, _b, _c, _d; + const state = ctx.state; + if (state == null ? void 0 : state.scrollingTo) { ++ (_a3 = state.silentInitialScrollReturn) == null ? void 0 : _a3.call(state); + cancelScrollCompletionChecks(state); +- const resolvePendingScroll = ((_a3 = ctx.scrollRequestTracker) == null ? void 0 : _a3.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; ++ const resolvePendingScroll = ((_b = ctx.scrollRequestTracker) == null ? void 0 : _b.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; + if (resolvePendingScroll) { + state.pendingScrollResolve = void 0; + } +@@ -2369,8 +2372,8 @@ function finishScrollTo(ctx) { + } + }; + if (scrollingTo.isInitialScroll || state.initialScroll) { +- const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; ++ const isOffsetSession = ((_c = state.initialScrollSession) == null ? void 0 : _c.kind) === "offset"; ++ const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_d = state.initialScroll) == null ? void 0 : _d.viewPosition) === 1; + finishInitialScroll(ctx, { + onFinished: complete, + preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index 0ba06dac531..7413734a88e 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -438,7 +438,9 @@ function calculateOffsetForIndex(ctx, index) { + } + + // src/core/cancelImperativeScroll.ts +-function cancelScrollCompletionChecks({ scheduledWork }) { ++function cancelScrollCompletionChecks(state) { ++ const { scheduledWork } = state; ++ state.silentInitialScrollReturn = void 0; + scheduledWork.cancel("checkFinishedScrollFrame"); + scheduledWork.cancel("checkFinishedScrollRetryFrame"); + scheduledWork.cancel("checkFinishedScrollFallback"); +@@ -2321,11 +2323,12 @@ function doMaintainScrollAtEnd(ctx, options) { + + // src/core/finishScrollTo.ts + function finishScrollTo(ctx) { +- var _a3, _b, _c; ++ var _a3, _b, _c, _d; + const state = ctx.state; + if (state == null ? void 0 : state.scrollingTo) { ++ (_a3 = state.silentInitialScrollReturn) == null ? void 0 : _a3.call(state); + cancelScrollCompletionChecks(state); +- const resolvePendingScroll = ((_a3 = ctx.scrollRequestTracker) == null ? void 0 : _a3.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; ++ const resolvePendingScroll = ((_b = ctx.scrollRequestTracker) == null ? void 0 : _b.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; + if (resolvePendingScroll) { + state.pendingScrollResolve = void 0; + } +@@ -2348,8 +2351,8 @@ function finishScrollTo(ctx) { + } + }; + if (scrollingTo.isInitialScroll || state.initialScroll) { +- const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; ++ const isOffsetSession = ((_c = state.initialScrollSession) == null ? void 0 : _c.kind) === "offset"; ++ const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_d = state.initialScroll) == null ? void 0 : _d.viewPosition) === 1; + finishInitialScroll(ctx, { + onFinished: complete, + preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch deleted file mode 100644 index 2bc501906ab1..000000000000 --- a/patches/@legendapp/list/@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch +++ /dev/null @@ -1,228 +0,0 @@ -diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js ---- a/node_modules/@legendapp/list/react-native.js -+++ b/node_modules/@legendapp/list/react-native.js -@@ -518,7 +518,9 @@ - var ENABLE_DEBUG_VIEW = IS_DEV && false; - - // src/core/cancelImperativeScroll.ts --function cancelScrollCompletionChecks({ scheduledWork }) { -+function cancelScrollCompletionChecks(state) { -+ const { scheduledWork } = state; -+ state.silentInitialScrollReturn = void 0; - scheduledWork.cancel("checkFinishedScrollFrame"); - scheduledWork.cancel("checkFinishedScrollRetryFrame"); - scheduledWork.cancel("checkFinishedScrollFallback"); -@@ -1051,6 +1053,20 @@ - } - - // src/utils/requestAdjust.ts -+function shiftNativeInitialScrollTarget(state, positionDiff) { -+ const scrollingTo = state.scrollingTo; -+ if (Platform.OS === "web" || !(scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) || scrollingTo.animated) { -+ return; -+ } -+ scrollingTo.offset += positionDiff; -+ if (scrollingTo.targetOffset !== void 0) { -+ scrollingTo.targetOffset += positionDiff; -+ } -+ const watchdog = initialScrollWatchdog.get(state); -+ if (watchdog) { -+ watchdog.targetOffset += positionDiff; -+ } -+} - function requestAdjust(ctx, positionDiff, source) { - var _a3; - const state = ctx.state; -@@ -1070,6 +1086,7 @@ - state.userScrollContentSize += positionDiff; - } - state.scrollForNextCalculateItemsInView = void 0; -+ shiftNativeInitialScrollTarget(state, positionDiff); - const readyToRender = peek$(ctx, "readyToRender"); - if (readyToRender) { - doit(); -@@ -1101,8 +1118,11 @@ - } - } else { - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; -+ const nativeScrollEpoch = state.nativeScrollEpoch; - requestAnimationFrame(() => { -- doit(); -+ if (state.nativeScrollEpoch === nativeScrollEpoch) { -+ doit(); -+ } - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; - if (!state.adjustingFromInitialMount) { - setInitialRenderState(ctx, {}); -@@ -2435,9 +2455,10 @@ - - // src/core/finishScrollTo.ts - function finishScrollTo(ctx) { -- var _a3, _b, _c; -+ var _a3, _b, _c, _d; - const state = ctx.state; - if (state == null ? void 0 : state.scrollingTo) { -+ (_d = state.silentInitialScrollReturn) == null ? void 0 : _d.call(state); - cancelScrollCompletionChecks(state); - const resolvePendingScroll = ((_a3 = ctx.scrollRequestTracker) == null ? void 0 : _a3.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; - if (resolvePendingScroll) { -@@ -2558,7 +2579,10 @@ - } - } - function scrollToFallbackOffset(ctx, offset, animated = false) { -- var _a3; -+ var _a3, _b; -+ if (Platform.OS !== "web") { -+ ctx.state.nativeScrollEpoch = ((_b = ctx.state.nativeScrollEpoch) != null ? _b : 0) + 1; -+ } - (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); - } - function checkFinishedScrollFallback(ctx) { -@@ -2608,10 +2632,13 @@ - const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; - initialScrollCompletion.markSilentInitialScrollRetry(state); - scrollToFallbackOffset(ctx, jiggleOffset); -- state.scheduledWork.frame( -- () => scrollToFallbackOffset(ctx, targetOffset), -- "checkFinishedScrollRetryFrame" -- ); -+ const returnToTarget = () => { -+ var _a4, _b2; -+ state.silentInitialScrollReturn = void 0; -+ scrollToFallbackOffset(ctx, (_b2 = (_a4 = initialScrollWatchdog.get(state)) == null ? void 0 : _a4.targetOffset) != null ? _b2 : targetOffset); -+ }; -+ state.silentInitialScrollReturn = returnToTarget; -+ state.scheduledWork.frame(returnToTarget, "checkFinishedScrollRetryFrame"); - scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); - } else if (shouldRetryUnalignedEndScroll) { - scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); -@@ -2640,6 +2667,7 @@ - - // src/core/doScrollTo.native.ts - function doScrollTo(ctx, params) { -+ var _nativeScrollEpoch; - const state = ctx.state; - const { animated, horizontal, isInitialScroll, offset } = params; - const isAnimated = !!animated; -@@ -2650,6 +2678,7 @@ - } - if (!params.isAdjustment) { - state.userScrollContentSize = void 0; -+ state.nativeScrollEpoch = ((_nativeScrollEpoch = state.nativeScrollEpoch) != null ? _nativeScrollEpoch : 0) + 1; - } - scroller.scrollTo({ animated: isAnimated, ...getNativeScrollPoint(ctx, offset, horizontal) }); - if (isInitialScroll) { -diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs ---- a/node_modules/@legendapp/list/react-native.mjs -+++ b/node_modules/@legendapp/list/react-native.mjs -@@ -497,7 +497,9 @@ - var ENABLE_DEBUG_VIEW = IS_DEV && false; - - // src/core/cancelImperativeScroll.ts --function cancelScrollCompletionChecks({ scheduledWork }) { -+function cancelScrollCompletionChecks(state) { -+ const { scheduledWork } = state; -+ state.silentInitialScrollReturn = void 0; - scheduledWork.cancel("checkFinishedScrollFrame"); - scheduledWork.cancel("checkFinishedScrollRetryFrame"); - scheduledWork.cancel("checkFinishedScrollFallback"); -@@ -1030,6 +1032,20 @@ - } - - // src/utils/requestAdjust.ts -+function shiftNativeInitialScrollTarget(state, positionDiff) { -+ const scrollingTo = state.scrollingTo; -+ if (Platform.OS === "web" || !(scrollingTo == null ? void 0 : scrollingTo.isInitialScroll) || scrollingTo.animated) { -+ return; -+ } -+ scrollingTo.offset += positionDiff; -+ if (scrollingTo.targetOffset !== void 0) { -+ scrollingTo.targetOffset += positionDiff; -+ } -+ const watchdog = initialScrollWatchdog.get(state); -+ if (watchdog) { -+ watchdog.targetOffset += positionDiff; -+ } -+} - function requestAdjust(ctx, positionDiff, source) { - var _a3; - const state = ctx.state; -@@ -1049,6 +1065,7 @@ - state.userScrollContentSize += positionDiff; - } - state.scrollForNextCalculateItemsInView = void 0; -+ shiftNativeInitialScrollTarget(state, positionDiff); - const readyToRender = peek$(ctx, "readyToRender"); - if (readyToRender) { - doit(); -@@ -1080,8 +1097,11 @@ - } - } else { - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; -+ const nativeScrollEpoch = state.nativeScrollEpoch; - requestAnimationFrame(() => { -- doit(); -+ if (state.nativeScrollEpoch === nativeScrollEpoch) { -+ doit(); -+ } - state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; - if (!state.adjustingFromInitialMount) { - setInitialRenderState(ctx, {}); -@@ -2414,9 +2434,10 @@ - - // src/core/finishScrollTo.ts - function finishScrollTo(ctx) { -- var _a3, _b, _c; -+ var _a3, _b, _c, _d; - const state = ctx.state; - if (state == null ? void 0 : state.scrollingTo) { -+ (_d = state.silentInitialScrollReturn) == null ? void 0 : _d.call(state); - cancelScrollCompletionChecks(state); - const resolvePendingScroll = ((_a3 = ctx.scrollRequestTracker) == null ? void 0 : _a3.isWaitingToRun()) ? void 0 : state.pendingScrollResolve; - if (resolvePendingScroll) { -@@ -2537,7 +2558,10 @@ - } - } - function scrollToFallbackOffset(ctx, offset, animated = false) { -- var _a3; -+ var _a3, _b; -+ if (Platform.OS !== "web") { -+ ctx.state.nativeScrollEpoch = ((_b = ctx.state.nativeScrollEpoch) != null ? _b : 0) + 1; -+ } - (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ animated, ...getNativeScrollPoint(ctx, offset) }); - } - function checkFinishedScrollFallback(ctx) { -@@ -2587,10 +2611,13 @@ - const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; - initialScrollCompletion.markSilentInitialScrollRetry(state); - scrollToFallbackOffset(ctx, jiggleOffset); -- state.scheduledWork.frame( -- () => scrollToFallbackOffset(ctx, targetOffset), -- "checkFinishedScrollRetryFrame" -- ); -+ const returnToTarget = () => { -+ var _a4, _b2; -+ state.silentInitialScrollReturn = void 0; -+ scrollToFallbackOffset(ctx, (_b2 = (_a4 = initialScrollWatchdog.get(state)) == null ? void 0 : _a4.targetOffset) != null ? _b2 : targetOffset); -+ }; -+ state.silentInitialScrollReturn = returnToTarget; -+ state.scheduledWork.frame(returnToTarget, "checkFinishedScrollRetryFrame"); - scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); - } else if (shouldRetryUnalignedEndScroll) { - scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); -@@ -2619,6 +2646,7 @@ - - // src/core/doScrollTo.native.ts - function doScrollTo(ctx, params) { -+ var _nativeScrollEpoch; - const state = ctx.state; - const { animated, horizontal, isInitialScroll, offset } = params; - const isAnimated = !!animated; -@@ -2629,6 +2657,7 @@ - } - if (!params.isAdjustment) { - state.userScrollContentSize = void 0; -+ state.nativeScrollEpoch = ((_nativeScrollEpoch = state.nativeScrollEpoch) != null ? _nativeScrollEpoch : 0) + 1; - } - scroller.scrollTo({ animated: isAnimated, ...getNativeScrollPoint(ctx, offset, horizontal) }); - if (isInitialScroll) { diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+007+keep-native-content-in-place-on-header-resize.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+007+keep-native-content-in-place-on-header-resize.patch new file mode 100644 index 000000000000..db08e34de5ed --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+007+keep-native-content-in-place-on-header-resize.patch @@ -0,0 +1,316 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index 3ccffa79110..56382a16e9d 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -549,6 +549,7 @@ function cancelImperativeScroll(state) { + } + + // src/core/clampScrollOffset.ts ++var ANDROID_SHRINK_CLAMP_EPSILON = 0.1; + function getContentInsetTop(ctx) { + var _a3, _b, _c, _d; + const state = ctx.state; +@@ -561,6 +562,20 @@ function getContentInsetTop(ctx) { + function getScrollRangeEnd(ctx, contentSize = getContentSize(ctx)) { + return Math.max(0 - getContentInsetTop(ctx), contentSize - ctx.state.scrollLength); + } ++function getAdjustAfterAndroidShrinkClamp(ctx, positionDiff) { ++ const state = ctx.state; ++ if (Platform.OS !== "android") { ++ return positionDiff; ++ } ++ const maxScroll = getScrollRangeEnd(ctx); ++ if (state.scroll <= maxScroll + ANDROID_SHRINK_CLAMP_EPSILON) { ++ return positionDiff; ++ } ++ const targetScroll = Math.max(0, Math.min(state.scroll + positionDiff, maxScroll)); ++ state.scroll = maxScroll; ++ state.scrollPending = maxScroll; ++ return targetScroll - maxScroll; ++} + function clampScrollOffset(ctx, offset, scrollTarget) { + var _a3; + const state = ctx.state; +@@ -1999,14 +2014,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + now, + positionDiff + }); +- if (Platform.OS === "android" && !dataChanged && scrollTarget === void 0) { +- const maxScroll = getScrollRangeEnd(ctx); +- if (state.scroll > maxScroll + MVCP_POSITION_EPSILON) { +- const targetScroll = Math.max(0, Math.min(state.scroll + positionDiff, maxScroll)); +- state.scroll = maxScroll; +- state.scrollPending = maxScroll; +- positionDiff = targetScroll - maxScroll; +- } ++ if (!dataChanged && scrollTarget === void 0) { ++ positionDiff = getAdjustAfterAndroidShrinkClamp(ctx, positionDiff); + } + if (shouldQueueNativeMVCPAdjust(dataChanged, state, positionDiff, prevTotalSize, prevScroll, scrollTarget)) { + state.pendingNativeMVCPAdjust = { +@@ -6715,7 +6724,7 @@ function shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, nextHeaderSize + const sizeDiff = nextHeaderSize - previousHeaderSize; + const leadingPadding = props.horizontal ? props.stylePaddingLeft : props.stylePaddingTop; + const previousHeaderEnd = (leadingPadding || 0) + previousHeaderSize; +- return Platform.OS === "web" && props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; ++ return props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; + } + function setHeaderSize(ctx, size) { + const { state } = ctx; +@@ -6726,7 +6735,7 @@ function setHeaderSize(ctx, size) { + set$(ctx, "headerSize", size); + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { +- requestAdjust(ctx, size - previousHeaderSize); ++ requestAdjust(ctx, getAdjustAfterAndroidShrinkClamp(ctx, size - previousHeaderSize)); + } + } + state.didMeasureHeader = true; +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index 676c117a45a..2c8dc675d44 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -528,6 +528,7 @@ function cancelImperativeScroll(state) { + } + + // src/core/clampScrollOffset.ts ++var ANDROID_SHRINK_CLAMP_EPSILON = 0.1; + function getContentInsetTop(ctx) { + var _a3, _b, _c, _d; + const state = ctx.state; +@@ -540,6 +541,20 @@ function getContentInsetTop(ctx) { + function getScrollRangeEnd(ctx, contentSize = getContentSize(ctx)) { + return Math.max(0 - getContentInsetTop(ctx), contentSize - ctx.state.scrollLength); + } ++function getAdjustAfterAndroidShrinkClamp(ctx, positionDiff) { ++ const state = ctx.state; ++ if (Platform.OS !== "android") { ++ return positionDiff; ++ } ++ const maxScroll = getScrollRangeEnd(ctx); ++ if (state.scroll <= maxScroll + ANDROID_SHRINK_CLAMP_EPSILON) { ++ return positionDiff; ++ } ++ const targetScroll = Math.max(0, Math.min(state.scroll + positionDiff, maxScroll)); ++ state.scroll = maxScroll; ++ state.scrollPending = maxScroll; ++ return targetScroll - maxScroll; ++} + function clampScrollOffset(ctx, offset, scrollTarget) { + var _a3; + const state = ctx.state; +@@ -1978,14 +1993,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + now, + positionDiff + }); +- if (Platform.OS === "android" && !dataChanged && scrollTarget === void 0) { +- const maxScroll = getScrollRangeEnd(ctx); +- if (state.scroll > maxScroll + MVCP_POSITION_EPSILON) { +- const targetScroll = Math.max(0, Math.min(state.scroll + positionDiff, maxScroll)); +- state.scroll = maxScroll; +- state.scrollPending = maxScroll; +- positionDiff = targetScroll - maxScroll; +- } ++ if (!dataChanged && scrollTarget === void 0) { ++ positionDiff = getAdjustAfterAndroidShrinkClamp(ctx, positionDiff); + } + if (shouldQueueNativeMVCPAdjust(dataChanged, state, positionDiff, prevTotalSize, prevScroll, scrollTarget)) { + state.pendingNativeMVCPAdjust = { +@@ -6694,7 +6703,7 @@ function shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, nextHeaderSize + const sizeDiff = nextHeaderSize - previousHeaderSize; + const leadingPadding = props.horizontal ? props.stylePaddingLeft : props.stylePaddingTop; + const previousHeaderEnd = (leadingPadding || 0) + previousHeaderSize; +- return Platform.OS === "web" && props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; ++ return props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && !scrollingTo && scroll >= previousHeaderEnd - SCROLL_ADJUST_EPSILON && Math.abs(sizeDiff) > SCROLL_ADJUST_EPSILON; + } + function setHeaderSize(ctx, size) { + const { state } = ctx; +@@ -6705,7 +6714,7 @@ function setHeaderSize(ctx, size) { + set$(ctx, "headerSize", size); + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { +- requestAdjust(ctx, size - previousHeaderSize); ++ requestAdjust(ctx, getAdjustAfterAndroidShrinkClamp(ctx, size - previousHeaderSize)); + } + } + state.didMeasureHeader = true; +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index b6f2da18e38..684b3017523 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -488,8 +488,6 @@ function cancelImperativeScroll(state) { + state.scrollTargetPinnedRange = void 0; + settlePendingImperativeScroll(state); + } +- +-// src/core/clampScrollOffset.ts + function getContentInsetTop(ctx) { + ctx.state; + { +@@ -499,6 +497,12 @@ function getContentInsetTop(ctx) { + function getScrollRangeEnd(ctx, contentSize = getContentSize(ctx)) { + return Math.max(0 - getContentInsetTop(ctx), contentSize - ctx.state.scrollLength); + } ++function getAdjustAfterAndroidShrinkClamp(ctx, positionDiff) { ++ ctx.state; ++ { ++ return positionDiff; ++ } ++} + function clampScrollOffset(ctx, offset, scrollTarget) { + var _a3; + const state = ctx.state; +@@ -1887,6 +1891,9 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + now, + positionDiff + }); ++ if (!dataChanged && scrollTarget === void 0) { ++ positionDiff = getAdjustAfterAndroidShrinkClamp(ctx, positionDiff); ++ } + if (shouldQueueNativeMVCPAdjust()) { + state.pendingNativeMVCPAdjust = { + amount: positionDiff, +@@ -7587,7 +7594,7 @@ function setHeaderSize(ctx, size) { + set$(ctx, "headerSize", size); + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { +- requestAdjust(ctx, size - previousHeaderSize); ++ requestAdjust(ctx, getAdjustAfterAndroidShrinkClamp(ctx, size - previousHeaderSize)); + } + } + state.didMeasureHeader = true; +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index 7413734a88e..5c99c97c4d9 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -467,8 +467,6 @@ function cancelImperativeScroll(state) { + state.scrollTargetPinnedRange = void 0; + settlePendingImperativeScroll(state); + } +- +-// src/core/clampScrollOffset.ts + function getContentInsetTop(ctx) { + ctx.state; + { +@@ -478,6 +476,12 @@ function getContentInsetTop(ctx) { + function getScrollRangeEnd(ctx, contentSize = getContentSize(ctx)) { + return Math.max(0 - getContentInsetTop(ctx), contentSize - ctx.state.scrollLength); + } ++function getAdjustAfterAndroidShrinkClamp(ctx, positionDiff) { ++ ctx.state; ++ { ++ return positionDiff; ++ } ++} + function clampScrollOffset(ctx, offset, scrollTarget) { + var _a3; + const state = ctx.state; +@@ -1866,6 +1870,9 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + now, + positionDiff + }); ++ if (!dataChanged && scrollTarget === void 0) { ++ positionDiff = getAdjustAfterAndroidShrinkClamp(ctx, positionDiff); ++ } + if (shouldQueueNativeMVCPAdjust()) { + state.pendingNativeMVCPAdjust = { + amount: positionDiff, +@@ -7566,7 +7573,7 @@ function setHeaderSize(ctx, size) { + set$(ctx, "headerSize", size); + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { +- requestAdjust(ctx, size - previousHeaderSize); ++ requestAdjust(ctx, getAdjustAfterAndroidShrinkClamp(ctx, size - previousHeaderSize)); + } + } + state.didMeasureHeader = true; +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index b6f2da18e38..684b3017523 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -488,8 +488,6 @@ function cancelImperativeScroll(state) { + state.scrollTargetPinnedRange = void 0; + settlePendingImperativeScroll(state); + } +- +-// src/core/clampScrollOffset.ts + function getContentInsetTop(ctx) { + ctx.state; + { +@@ -499,6 +497,12 @@ function getContentInsetTop(ctx) { + function getScrollRangeEnd(ctx, contentSize = getContentSize(ctx)) { + return Math.max(0 - getContentInsetTop(ctx), contentSize - ctx.state.scrollLength); + } ++function getAdjustAfterAndroidShrinkClamp(ctx, positionDiff) { ++ ctx.state; ++ { ++ return positionDiff; ++ } ++} + function clampScrollOffset(ctx, offset, scrollTarget) { + var _a3; + const state = ctx.state; +@@ -1887,6 +1891,9 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + now, + positionDiff + }); ++ if (!dataChanged && scrollTarget === void 0) { ++ positionDiff = getAdjustAfterAndroidShrinkClamp(ctx, positionDiff); ++ } + if (shouldQueueNativeMVCPAdjust()) { + state.pendingNativeMVCPAdjust = { + amount: positionDiff, +@@ -7587,7 +7594,7 @@ function setHeaderSize(ctx, size) { + set$(ctx, "headerSize", size); + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { +- requestAdjust(ctx, size - previousHeaderSize); ++ requestAdjust(ctx, getAdjustAfterAndroidShrinkClamp(ctx, size - previousHeaderSize)); + } + } + state.didMeasureHeader = true; +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index 7413734a88e..5c99c97c4d9 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -467,8 +467,6 @@ function cancelImperativeScroll(state) { + state.scrollTargetPinnedRange = void 0; + settlePendingImperativeScroll(state); + } +- +-// src/core/clampScrollOffset.ts + function getContentInsetTop(ctx) { + ctx.state; + { +@@ -478,6 +476,12 @@ function getContentInsetTop(ctx) { + function getScrollRangeEnd(ctx, contentSize = getContentSize(ctx)) { + return Math.max(0 - getContentInsetTop(ctx), contentSize - ctx.state.scrollLength); + } ++function getAdjustAfterAndroidShrinkClamp(ctx, positionDiff) { ++ ctx.state; ++ { ++ return positionDiff; ++ } ++} + function clampScrollOffset(ctx, offset, scrollTarget) { + var _a3; + const state = ctx.state; +@@ -1866,6 +1870,9 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + now, + positionDiff + }); ++ if (!dataChanged && scrollTarget === void 0) { ++ positionDiff = getAdjustAfterAndroidShrinkClamp(ctx, positionDiff); ++ } + if (shouldQueueNativeMVCPAdjust()) { + state.pendingNativeMVCPAdjust = { + amount: positionDiff, +@@ -7566,7 +7573,7 @@ function setHeaderSize(ctx, size) { + set$(ctx, "headerSize", size); + updateContentMetricsState(ctx); + if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { +- requestAdjust(ctx, size - previousHeaderSize); ++ requestAdjust(ctx, getAdjustAfterAndroidShrinkClamp(ctx, size - previousHeaderSize)); + } + } + state.didMeasureHeader = true; diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md index b3446ceaf904..85637bd6e55d 100644 --- a/patches/@legendapp/list/details.md +++ b/patches/@legendapp/list/details.md @@ -1,55 +1,66 @@ # `@legendapp/list` patches +Each patch is the build output of its upstream PR, so it changes exactly what that PR changes. Remove a patch once a LegendList release contains its PR. + ### [@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch](@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch) - Reason: On web, LegendList holds a scroll anchor for a moment after a data change and defers the scroll correction for item size changes to the next frame. Any other pass that repositions items before that frame, such as the one a scroll event triggers, moves the visible messages without the correction. They jump for a frame and only partly return. With this patch, the first pass that repositions items applies the pending correction. -- Scope: Only the web bundles, and only while a deferred item-size correction is pending. +- Scope: All bundles. Only web defers the correction, so native lists are unaffected. - Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/596 - E/App issue: https://github.com/Expensify/App/pull/100733#issuecomment-6411009930 - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: On web, open a chat with more history than the first page. Scroll to the top until older messages load. The message at the top of the screen should stay in place instead of jumping and snapping back. -### [@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch](@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch) +### [@legendapp+list+3.7.2+002+add-mvcp-end-anchor.patch](@legendapp+list+3.7.2+002+add-mvcp-end-anchor.patch) -- Reason: When items change size or data changes, LegendList keeps the first visible item in place. In a chat, the oldest loaded message shows the sender's avatar, and it loses that header once older messages from the same sender load. Its height shrinks, so every message below it moves up. An inverted list keeps the bottom in place instead. For lists with `alignItemsAtEnd`, this patch anchors the last visible item, so a size change above it only moves the content above. -- Scope: Only the web bundles, and only lists with `alignItemsAtEnd`. Other lists still anchor the first visible item. +- Reason: When items change size or data changes, LegendList keeps the first visible item in place. In a chat, the oldest loaded message shows the sender's avatar, and it loses that header once older messages from the same sender load. Its height shrinks, so every message below it moves up. An inverted list keeps the bottom in place instead. This patch adds `anchor: "end"` to `maintainVisibleContentPosition`, which keeps the last visible item in place, so a size change above it only moves the content above. +- Scope: All bundles and type definitions, and only lists that pass `anchor: "end"`. Other lists still keep the first visible item in place. - Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/597 - E/App issue: 🛑 - PR introducing patch: https://github.com/Expensify/App/pull/100733 -- Reproduction: On web, clear the cache and open a chat whose oldest loaded message shows the sender's avatar. Scroll to that message and wait until older messages from the same sender load. The avatar line should disappear without moving the messages below it. +- Reproduction: Clear the cache and open a chat whose oldest loaded message shows the sender's avatar. Scroll to that message and wait until older messages from the same sender load. The avatar line should disappear without moving the messages below it. ### [@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch](@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch) -- Reason: After a large scroll jump, such as a fast scrollbar drag, LegendList repositions newly measured items without keeping anything in place, so the region the user jumped to can lay out. This lasts until it has measured every item it rendered since the jump. If older messages load in the meantime, their measurements push the visible messages down by a few hundred pixels for a frame. This patch ends that state when the data changes, so the new messages are anchored like any other data change. -- Scope: Only the web bundles, and only lists that keep their visible content in place on data changes. +- Reason: After a large scroll jump, such as a fast scrollbar drag or fling, LegendList repositions newly measured items without keeping anything in place, so the region the user jumped to can lay out. This lasts until it has measured every item it rendered since the jump. If older messages load in the meantime, their measurements push the visible messages down by a few hundred pixels for a frame. This patch ends that state when the data changes, so the new messages are anchored like any other data change. +- Scope: All bundles, and only lists that keep their visible content in place on data changes. - Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/598 - E/App issue: 🛑 - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: On web, clear the cache and open a chat with a long history. Drag the scrollbar thumb quickly up and down a few times, then hold it near the top until older messages load. The messages on screen should not move. -### [@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch](@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch) +### [@legendapp+list+3.7.2+004+land-initial-mount-adjustments-once.patch](@legendapp+list+3.7.2+004+land-initial-mount-adjustments-once.patch) -- Reason: Before a list is ready, LegendList moves its items as soon as they are measured but applies the matching native scroll adjustment one frame later. It could mark the list ready, and show it, before that frame ran. On iOS, a chat that opens at a linked message then shows the message at the top of the list and moves it down by about 45 points. With this patch, the list becomes ready only after those adjustments have run. -- Scope: All bundles, and only during a list's initial layout while a scroll adjustment requested before the list was ready is still pending. +- Reason: Before a list is ready, LegendList moves its items as soon as they are measured but applies the matching native scroll adjustment one frame later. It could mark the list ready, and show it, before that frame ran. An initial scroll dispatched in between also already lands on the adjusted offset, so the late adjustment moved the list a second time, and LegendList's retries reissued the offset recorded when the scroll started. A chat that opens at a linked message then shows the message at the top of the list and moves it down. With this patch, the list becomes ready only after those adjustments have run, skips an adjustment that a later native scroll already includes, and moves the target of the initial scroll with each adjustment. +- Scope: All bundles, and only during a list's initial layout. Skipping adjustments and moving the target only apply on native. - Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/600 - E/App issue: 🛑 - PR introducing patch: https://github.com/Expensify/App/pull/100733 -- Reproduction: On iOS, open a chat from an LHN row with a Pay or Approve action, so it opens at the linked expense. The highlighted expense should appear once, a little below the top of the list, and not move afterward. +- Reproduction: On iOS or Android, clear the cache and open a chat from an LHN row with a Pay, Approve, or Fix action, so it opens at the linked expense. The highlighted expense should appear once, a little below the top of the list, and not move afterward. ### [@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch](@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch) -- Reason: LegendList keeps the visible items in place with a hidden anchor view placed 10,000,000 points down. Native layout stores positions as 32-bit floats, which hold that value only to the nearest whole point, so each scroll adjustment was rounded while the items moved by the exact amount. On Android, a chat that opens at a linked message moved up by 1–2 px when the rows above it were measured. This patch moves the anchor to 1,000,000 points, where its position stays precise to 1/16 of a point. +- Reason: LegendList keeps the visible items in place with a hidden anchor view placed 10,000,000 points down. Native layout stores positions as 32-bit floats, which hold that value only to the nearest whole point, so each scroll adjustment was rounded while the items moved by the exact amount. On Android, a chat that opens at a linked message moved up by 1 or 2 px when the rows above it were measured. This patch moves the anchor to 1,000,000 points, where its position stays precise to 1/16 of a point. Adjustments move the anchor along with the content, so the list can still scroll through 1,000,000 points of content that loaded without one, such as the first page and new messages. - Scope: Only the native bundles. - Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/602 - E/App issue: 🛑 - PR introducing patch: https://github.com/Expensify/App/pull/100733 - Reproduction: On Android, open a chat from an LHN row with a Fix action, so it opens at the linked expense. When the Latest messages button appears, the messages on screen should not move. -### [@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch](@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch) +### [@legendapp+list+3.7.2+006+finish-silent-initial-scroll-retries.patch](@legendapp+list+3.7.2+006+finish-silent-initial-scroll-retries.patch) + +- Reason: When Android dispatches an initial scroll without reporting a scroll event, LegendList retries it by moving the list one point away and back on the next frame. If another check finished the scroll first, the list stayed one point off its target, and the return used the offset recorded before adjustments moved the target. With this patch, finishing the scroll completes a pending return, and the return goes to the current target. +- Scope: All bundles, and only initial scrolls that LegendList retries because they reported no scroll event. That only happens on native. +- Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/604, which builds on https://github.com/LegendApp/legend-list/pull/600 +- E/App issue: 🛑 +- PR introducing patch: https://github.com/Expensify/App/pull/100733 +- Reproduction: On Android, clear the cache and open a chat from an LHN row with a Fix action, so it opens at the linked expense. The highlighted expense should come to rest a little below the top of the list and stay there. + +### [@legendapp+list+3.7.2+007+keep-native-content-in-place-on-header-resize.patch](@legendapp+list+3.7.2+007+keep-native-content-in-place-on-header-resize.patch) -- Reason: Before a list is ready, LegendList moves its item positions right away but moves its native scroll anchor a frame later. An initial scroll dispatched in between already lands on the adjusted offset, so the late anchor move shifted the list again, and LegendList's retries then reissued the offset recorded when the scroll started. A retry for scrolls Android reports silently also left the list one point off when the scroll finished first. On Android, a chat that opens at a linked message appeared with the message at the top of the list and then moved down to its offset. This patch skips anchor moves that a later native scroll already includes, moves the pending initial-scroll target with each adjustment, and completes the retry before the scroll finishes. -- Scope: Only the native bundles, and only while an initial scroll is in flight or the list is not ready yet. -- Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/604 +- Reason: LegendList keeps the visible items in place when the list header resizes above the viewport, but only on web. While offline, a chat shows a loading placeholder as the list header, and when it disappears on reconnect, the messages on screen moved by its height on iOS and Android. This patch makes header size changes above the viewport keep the visible items in place on every platform. On Android, it first accounts for the scroll offset Android clamps when the content shrinks, as LegendList already does for item size changes. +- Scope: All bundles, and only header size changes above the viewport. Web behaves as before. +- Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/607 - E/App issue: 🛑 - PR introducing patch: https://github.com/Expensify/App/pull/100733 -- Reproduction: On Android, clear the cache and open a chat from an LHN row with a Fix action, so it opens at the linked expense. The highlighted expense should appear once, a little below the top of the list, and not move afterward. +- Reproduction: On iOS or Android, open a chat with more history than the first page. Turn off the network connection. Turn the network connection back on. The messages on screen should not move when the placeholder above them disappears. diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f85a048bda3f..75b7a53d49e9 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8131,6 +8131,7 @@ const CONST = { MOBILE_PAGINATION_SIZE: 15, WEB_PAGINATION_SIZE: 30, + PAGINATION_LOADING_INDICATOR_HEIGHT: 120, /** Dimensions for illustration shown in Confirmation Modal */ CONFIRM_CONTENT_SVG_SIZE: { diff --git a/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx b/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx deleted file mode 100644 index bc11ccf61296..000000000000 --- a/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type {StyleProp, ViewProps, ViewStyle} from 'react-native'; - -import React from 'react'; -import {View} from 'react-native'; - -type CellRendererComponentProps = ViewProps & { - index: number; - style?: StyleProp; -}; - -function CellRendererComponent(props: CellRendererComponentProps) { - return ( - - ); -} - -export default CellRendererComponent; diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx deleted file mode 100644 index b343f95b8be1..000000000000 --- a/src/components/FlashList/InvertedFlashList/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type FlatListRefType from '@components/FlashList/types'; - -import type {FlashListProps} from '@shopify/flash-list'; - -import React from 'react'; - -import FlashList from '..'; -import CellRendererComponent from './CellRendererComponent'; - -type InvertedFlashListProps = FlashListProps & { - data: T[]; - keyExtractor: (item: T, index: number) => string; - - /** Ref to the underlying list instance. */ - ref: FlatListRefType; -}; - -function InvertedFlashList(props: InvertedFlashListProps) { - return ( - - {...props} - inverted - CellRendererComponent={CellRendererComponent} - /> - ); -} - -export default InvertedFlashList; diff --git a/src/components/FlashList/types.ts b/src/components/FlashList/types.ts deleted file mode 100644 index 8563ec9959a4..000000000000 --- a/src/components/FlashList/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type ActionListRefType from '@pages/inbox/ActionListTypes'; - -export default ActionListRefType; diff --git a/src/components/LegendList/allowLegendListItemOverflow.ts b/src/components/LegendList/allowLegendListItemOverflow.ts new file mode 100644 index 000000000000..a222d6d81055 --- /dev/null +++ b/src/components/LegendList/allowLegendListItemOverflow.ts @@ -0,0 +1,19 @@ +import {isRecord} from '@libs/ObjectUtils'; + +import type {LegendListRef} from '@legendapp/list/react-native'; + +/** + * LegendList uses paint containment for web item containers, which clips descendants that render + * outside their row. Remove only paint containment so inline menus can overflow while layout and + * style containment keep the rest of the row isolated. + */ +function allowLegendListItemOverflow(list: LegendListRef | null, index: number): void { + const itemContainer: unknown = list?.getState().elementAtIndex(index); + if (!isRecord(itemContainer) || !isRecord(itemContainer.style) || typeof itemContainer.style.contain !== 'string') { + return; + } + + itemContainer.style.contain = 'layout style'; +} + +export default allowLegendListItemOverflow; diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 1efd78833df8..082e3818cc8a 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -32,7 +32,7 @@ import { import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import ConciergeThinkingMessage from '@pages/home/report/ConciergeThinkingMessage'; -import {useActionListRef} from '@pages/inbox/ActionListContext'; +import {useActionListReady, useActionListRef} from '@pages/inbox/ActionListContext'; import {useConciergeDraft} from '@pages/inbox/ConciergeDraftContext'; import CollapsedSystemMessages from '@pages/inbox/report/CollapsedSystemMessages'; import FloatingMessageCounter from '@pages/inbox/report/FloatingMessageCounter'; @@ -98,6 +98,7 @@ function reportNameValuePairsSelector(reportNameValuePairs: OnyxEntry { // Force a layout commit even when closing the editor leaves the row's height unchanged. - setEditScrollRequestID((requestID) => requestID + 1); + setScrollToNewestRequestID((requestID) => requestID + 1); }; + // FlashList must measure the updated rows after the editor closes before scrolling to the newest action. const onCommitLayoutEffect = () => { - if (editScrollRequestID <= completedEditScrollRequestIDRef.current) { + if (scrollToNewestRequestID <= lastHandledScrollToNewestRequestIDRef.current) { return; } - completedEditScrollRequestIDRef.current = editScrollRequestID; + lastHandledScrollToNewestRequestIDRef.current = scrollToNewestRequestID; scrollToBottom(); }; @@ -372,6 +374,7 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon }; const onListLayout = (event: LayoutChangeEvent) => { + setIsReady(true); syncBottomOffsetFromLayout(event); recordTimeToMeasureItemLayout(); }; @@ -418,7 +421,10 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon { + setIsReady(true); + onLayout?.(event); + }} /> )} {!isReportEmpty && !!reportStable && ( diff --git a/src/components/MoneyRequestReportView/useMoneyRequestReportScroll.ts b/src/components/MoneyRequestReportView/useMoneyRequestReportScroll.ts index 886dca0b06d2..beb3ebd786d3 100644 --- a/src/components/MoneyRequestReportView/useMoneyRequestReportScroll.ts +++ b/src/components/MoneyRequestReportView/useMoneyRequestReportScroll.ts @@ -197,7 +197,7 @@ function useMoneyRequestReportScroll({ resetKey, }); - // The indicator renders in the list footer, below the row scrollToBottom targets, so only scrollToEnd reveals it + // The indicator is below the last row, so use the manager's full-list scroll to include the footer. const {candidateAgentIDs} = useAgentZeroStatus(); const isThinkingIndicatorVisible = candidateAgentIDs.length > 0; // Scroll once per appearance, because the label changes many times per run and re-firing would yank the viewport @@ -214,7 +214,7 @@ function useMoneyRequestReportScroll({ // Wait for the footer to lay out, otherwise there is nothing to scroll to yet const timeoutID = setTimeout(() => { - reportScrollManager.scrollToEnd(); + reportScrollManager.scrollToBottom(); }, DELAY_FOR_SCROLLING_TO_END); return () => clearTimeout(timeoutID); diff --git a/src/hooks/useLinkedReportActionScroll.ts b/src/hooks/useLinkedReportActionScroll.ts new file mode 100644 index 000000000000..41d80690528f --- /dev/null +++ b/src/hooks/useLinkedReportActionScroll.ts @@ -0,0 +1,174 @@ +/** Keeps a linked action positioned as its measured layout settles, until the reader takes over scrolling. */ +import TransitionTracker from '@libs/Navigation/TransitionTracker'; + +import {useActionListContext} from '@pages/inbox/ActionListContext'; + +import CONST from '@src/CONST'; + +import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; + +import {useIsFocused} from '@react-navigation/native'; +import {useEffect, useEffectEvent, useRef, useState} from 'react'; +import {Platform} from 'react-native'; + +import useReportScrollManager from './useReportScrollManager'; + +type LinkedReportActionScrollParams = { + listID: string; + linkedReportActionID: string | undefined; + targetIndex: number; + isLastAction: boolean; +}; + +function useLinkedReportActionScroll({listID, linkedReportActionID, targetIndex, isLastAction}: LinkedReportActionScrollParams) { + const isFocused = useIsFocused(); + const {getListRef} = useActionListContext(); + const reportScrollManager = useReportScrollManager(); + const [readyListID, setReadyListID] = useState(); + const [measurementVersion, setMeasurementVersion] = useState(0); + const shouldPositionRef = useRef(false); + const wasLastActionRef = useRef(false); + const cancelPositionRef = useRef<(() => void) | undefined>(undefined); + + const stopPositioning = () => { + shouldPositionRef.current = false; + cancelPositionRef.current?.(); + }; + const stopPositioningFromEffect = useEffectEvent(stopPositioning); + + // Each link and list mount starts a new positioning session. Refs keep this out of render, and the + // cleanup cancels a scroll that is still scheduled for the previous session. + useEffect(() => { + shouldPositionRef.current = !!linkedReportActionID; + wasLastActionRef.current = false; + return stopPositioningFromEffect; + }, [linkedReportActionID, listID]); + + useEffect(() => { + if (!isFocused) { + return; + } + + // Returning from a child screen must keep the reader's viewport, not reopen the old link. + return stopPositioningFromEffect; + }, [isFocused]); + + const positionLinkedAction = useEffectEvent(() => { + const shouldSkipPositioning = !shouldPositionRef.current || targetIndex < 0; + if (shouldSkipPositioning) { + return; + } + // Once a reply follows a linked final action, normal live-tail following owns the viewport. + const didLinkedActionGainReplies = wasLastActionRef.current && !isLastAction; + if (didLinkedActionGainReplies) { + shouldPositionRef.current = false; + return; + } + wasLastActionRef.current = isLastAction; + const shouldCheckAndroidAnchor = Platform.OS === 'android' && !isLastAction && linkedReportActionID; + if (shouldCheckAndroidAnchor) { + const state = getListRef()?.current?.getState?.(); + const position = state?.positionByKey(linkedReportActionID); + if (position !== undefined && state?.scroll !== undefined && state.contentLength !== undefined && state.scrollLength !== undefined) { + const linkedActionOffset = position + (state.headerSize ?? 0) - CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET; + const maximumScrollOffset = state.contentLength - state.scrollLength; + const targetOffset = Math.max(0, Math.min(linkedActionOffset, maximumScrollOffset)); + // LegendList includes pending native anchoring in its scroll offset. An imperative + // scroll to that same offset would apply the height correction twice on Android. + if (Math.abs(targetOffset - state.scroll) <= 1) { + return; + } + } + } + reportScrollManager.scrollToIndex( + targetIndex, + isLastAction ? {animated: false, viewPosition: 1} : {animated: false, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}, + ); + }); + + // Positioning scrolls LegendList imperatively, so it runs here where it can be cancelled. The size and scroll + // handlers only bump measurementVersion, so a burst of measurements collapses into one scroll to the latest target. + useEffect(() => { + const shouldSkipPositioning = !isFocused || !linkedReportActionID || readyListID !== listID || targetIndex < 0 || !shouldPositionRef.current; + if (shouldSkipPositioning) { + return; + } + + let frame: number | undefined; + // Row size notifications precede LegendList's position recalculation. Wait for that layout + // and the navigation transition before using the measured target position. + const transition = TransitionTracker.runAfterTransitions({ + callback: () => { + frame = requestAnimationFrame(positionLinkedAction); + }, + }); + const cancel = () => { + transition.cancel(); + if (frame !== undefined) { + cancelAnimationFrame(frame); + } + }; + cancelPositionRef.current = cancel; + return cancel; + }, [isFocused, linkedReportActionID, listID, readyListID, targetIndex, isLastAction, measurementVersion]); + + // On web, the reader can also scroll with the wheel, the scrollbar or the keyboard. Treat that input on the + // scroll element like a native drag, which hands the viewport back to the reader. + useEffect(() => { + const shouldSkipWebScrollListeners = Platform.OS !== 'web' || readyListID !== listID; + if (shouldSkipWebScrollListeners) { + return; + } + const scrollElement = getListRef()?.current?.getScrollableNode?.(); + if (!(scrollElement instanceof HTMLElement)) { + return; + } + const stopPositioningForScrollKey = (event: KeyboardEvent) => { + if (!['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(event.key)) { + return; + } + stopPositioningFromEffect(); + }; + scrollElement.addEventListener('wheel', stopPositioningFromEffect, {passive: true}); + scrollElement.addEventListener('pointerdown', stopPositioningFromEffect, {passive: true}); + scrollElement.addEventListener('keydown', stopPositioningForScrollKey); + return () => { + scrollElement.removeEventListener('wheel', stopPositioningFromEffect); + scrollElement.removeEventListener('pointerdown', stopPositioningFromEffect); + scrollElement.removeEventListener('keydown', stopPositioningForScrollKey); + }; + }, [getListRef, listID, readyListID]); + + const onReady = () => setReadyListID(listID); + const onScroll = (event: NativeSyntheticEvent) => { + const shouldIgnoreLinkedActionScroll = !shouldPositionRef.current || readyListID !== listID || !linkedReportActionID || isLastAction; + if (shouldIgnoreLinkedActionScroll) { + return; + } + const state = getListRef()?.current?.getState?.(); + const position = state?.positionByKey(linkedReportActionID); + if (position === undefined) { + return; + } + const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; + const linkedActionOffset = position + (state?.headerSize ?? 0) - CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET; + const maximumScrollOffset = contentSize.height - layoutMeasurement.height; + const targetOffset = Math.max(0, Math.min(linkedActionOffset, maximumScrollOffset)); + // Native anchoring can commit after the row-size correction. Check the resulting viewport + // as well, so that late adjustment cannot move a successfully positioned link out of view. + if (Math.abs(targetOffset - contentOffset.y) > 1) { + setMeasurementVersion((version) => version + 1); + } + }; + const onItemSizeChanged = ({index, size, previous}: {index: number; size: number; previous: number}) => { + const shouldIgnoreItemSizeChange = !shouldPositionRef.current || readyListID !== listID || index > targetIndex || size === previous; + if (shouldIgnoreItemSizeChange) { + return; + } + setMeasurementVersion((version) => version + 1); + }; + + return {onReady, onItemSizeChanged, onScroll, stopPositioning}; +} + +export default useLinkedReportActionScroll; diff --git a/src/hooks/useLoadReportActions.ts b/src/hooks/useLoadReportActions.ts index 140dd63b9ef5..df534dbb9207 100644 --- a/src/hooks/useLoadReportActions.ts +++ b/src/hooks/useLoadReportActions.ts @@ -76,6 +76,15 @@ function useLoadReportActions({ } } + const currentReportNewestActionID = newestFetchedReportActionID ?? currentReportNewestAction?.reportActionID; + const newestReportActionsRequestCursor = isTransactionThreadReport + ? JSON.stringify([currentReportNewestActionID, transactionThreadNewestAction?.reportActionID]) + : (currentReportNewestActionID ?? newestReportAction?.reportActionID); + const oldestReportActionsRequestCursor = isTransactionThreadReport + ? JSON.stringify([currentReportOldestAction?.reportActionID, transactionThreadOldestAction?.reportActionID]) + : currentReportOldestAction?.reportActionID; + const canLoadNewerChats = !!isFocused && !!newestReportAction && newestReportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + /** * Retrieves the next set of reportActions for the chat once we are nearing the end of what we are currently * displaying. @@ -100,16 +109,7 @@ function useLoadReportActions({ }; const loadNewerChats = (force = false) => { - if ( - !force && - (!isFocused || - !newestReportAction || - !hasNewerActions || - isOffline || - // If there was an error only try again once on initial mount. We should also still load - // more in case we have cached messages. - newestReportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) - ) { + if (!force && (!canLoadNewerChats || !newestReportAction || !hasNewerActions || isOffline)) { return; } @@ -138,6 +138,9 @@ function useLoadReportActions({ loadNewerChats, // The exact cursor `loadOlderChats` sends, which is not always the end of the rendered chain. currentReportOldestActionID: currentReportOldestAction?.reportActionID, + oldestReportActionsRequestCursor, + newestReportActionsRequestCursor, + canLoadNewerChats, }; } diff --git a/src/hooks/useReportActionsListModel.ts b/src/hooks/useReportActionsListModel.ts index 932f5f5d0af9..629bc0aff952 100644 --- a/src/hooks/useReportActionsListModel.ts +++ b/src/hooks/useReportActionsListModel.ts @@ -13,6 +13,7 @@ import {useRoute} from '@react-navigation/native'; import useConciergeAskState from './useConciergeAskState'; import {useIsAppLoadPending} from './useInFlightRequests'; +import useInitial from './useInitial'; import useLoadReportActions from './useLoadReportActions'; import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; import useOnyx from './useOnyx'; @@ -57,10 +58,16 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`, { selector: reportActionsListLoadingStateSelector, }); - const hasOnceLoadedReportActions = reportLoadingState?.hasOnceLoadedReportActions; + // Cached actions shown offline already have a usable layout. Refresh them in place on reconnect, + // and keep this visit loaded if its RAM-only entry briefly disappears during optimistic updates. + const hasUsableReportActions = reportLoadingState?.hasOnceLoadedReportActions || (isOffline && reportActions.length > 0); + const hasOnceLoadedReportActions = useInitial(hasUsableReportActions ? true : undefined); const isLoadingInitialReportActions = reportLoadingState?.isLoadingInitialReportActions; + const isInitialReportLoadPending = !hasOnceLoadedReportActions && (isReportLoadPending || isLoadingInitialReportActions !== false); const isLoadingOlderReportActions = reportLoadingState?.isLoadingOlderReportActions; const hasLoadingOlderReportActionsError = reportLoadingState?.hasLoadingOlderReportActionsError; + const isLoadingNewerReportActions = reportLoadingState?.isLoadingNewerReportActions; + const hasLoadingNewerReportActionsError = reportLoadingState?.hasLoadingNewerReportActionsError; const {setShowFullHistory: setConciergeShowFullHistory} = useConciergeChatHistoryActions(); const { @@ -78,8 +85,9 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const isAppLoadPending = useIsAppLoadPending(); const [reportPaginationState] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_PAGINATION_STATE}${reportID}`); + const [hasReportActionPages = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${reportID}`, {selector: (pages) => !!pages?.length}); - const {loadOlderChats, loadNewerChats, currentReportOldestActionID} = useLoadReportActions({ + const {loadOlderChats, loadNewerChats, currentReportOldestActionID, oldestReportActionsRequestCursor, newestReportActionsRequestCursor, canLoadNewerChats} = useLoadReportActions({ reportID, reportActions, allReportActionIDs, @@ -121,6 +129,7 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea isLoadingInitialReportActions, isLoadingApp: isAppLoadPending, reportActionsLength: reportActions.length, + hasReportActionPages, oldestReportActionID: currentReportOldestActionID, hasOlderActions, hasNewerActions, @@ -139,7 +148,17 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea const state = { report, hasOnceLoadedReportActions, + isInitialReportLoadPending, + isLoadingOlderReportActions, + hasLoadingOlderReportActionsError, + hasOlderActions, hasNewerActions, + isLoadingNewerReportActions, + hasLoadingNewerReportActionsError, + oldestReportActionID: currentReportOldestActionID, + olderReportActionsRequestCursor: oldestReportActionsRequestCursor, + newerReportActionsRequestCursor: newestReportActionsRequestCursor, + canLoadNewerChats, sortedAllReportActions, oldestUnreadReportAction, transactionThreadReport, @@ -151,6 +170,7 @@ function useReportActionsListModel(reportID: string, isReportLoadPending: boolea isConciergeHiddenHistory, showFullHistory, hasPreviousMessages, + allReportActionIDs, persistedReportActionIDs, shouldShowConciergeWelcome, isConciergeHistoryExpanded, diff --git a/src/hooks/useReportActionsPaginationScroll.ts b/src/hooks/useReportActionsPaginationScroll.ts new file mode 100644 index 000000000000..574115aff00d --- /dev/null +++ b/src/hooks/useReportActionsPaginationScroll.ts @@ -0,0 +1,295 @@ +/** Loads adjacent chat pages near visible messages, excluding loading indicators from the boundary calculation. */ +import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; +import TransitionTracker from '@libs/Navigation/TransitionTracker'; +import type {CancelHandle} from '@libs/Navigation/TransitionTracker'; + +import type {RefObject} from 'react'; + +import {useEffect, useEffectEvent, useLayoutEffect, useRef} from 'react'; + +/** How close to the first or last message, as a fraction of the viewport height, the reader must be before the next page loads. */ +const REPORT_ACTIONS_PAGINATION_THRESHOLD = 0.25; + +type PaginationGeometry = { + scroll: number; + scrollLength: number; + contentLength: number; +}; + +type PaginationListRef = { + getState: () => PaginationGeometry | undefined; +}; + +type ReportActionsPaginationDistances = { + older: number; + newer: number; +}; + +type UseReportActionsPaginationScrollArguments = { + reportID: string; + linkedReportActionID: string | undefined; + listRef: RefObject; + olderPaginationExtent: number; + newerPaginationExtent: number; + olderCursor: string | undefined; + newerCursor: string | undefined; + hasOlderActions: boolean; + hasNewerActions: boolean; + isLoadingOlderReportActions: boolean; + isLoadingNewerReportActions: boolean; + hasLoadingOlderReportActionsError: boolean; + hasLoadingNewerReportActionsError: boolean; + isOffline: boolean; + canLoadOlder: boolean; + canLoadNewer: boolean; + loadOlderActions: () => void; + loadNewerActions: () => void; +}; + +function useReportActionsPaginationScroll(options: UseReportActionsPaginationScrollArguments) { + const { + reportID, + linkedReportActionID, + olderCursor, + newerCursor, + isLoadingOlderReportActions, + isLoadingNewerReportActions, + isOffline, + canLoadOlder, + canLoadNewer, + olderPaginationExtent, + newerPaginationExtent, + hasOlderActions, + hasNewerActions, + } = options; + const latestArgumentsRef = useRef(options); + const lastRequestedOlderCursorRef = useRef(undefined); + const lastRequestedNewerCursorRef = useRef(undefined); + const wasNearOlderBoundaryRef = useRef(false); + const wasNearNewerBoundaryRef = useRef(false); + const wasOfflineRef = useRef(isOffline); + const couldLoadOlderRef = useRef(canLoadOlder); + const couldLoadNewerRef = useRef(canLoadNewer); + const wasLoadingOlderRef = useRef(isLoadingOlderReportActions); + const wasLoadingNewerRef = useRef(isLoadingNewerReportActions); + // Bumped when the report or linked message changes, so callbacks scheduled for the previous window do nothing. + const paginationWindowGenerationRef = useRef(0); + const scheduledFramesRef = useRef(new Set()); + const transitionHandlesRef = useRef(new Set()); + + useLayoutEffect(() => { + latestArgumentsRef.current = options; + }); + + const scheduleFrame = (callback: () => void, generation?: number) => { + const expectedGeneration = generation ?? paginationWindowGenerationRef.current; + const frame = requestAnimationFrame(() => { + scheduledFramesRef.current.delete(frame); + if (expectedGeneration === paginationWindowGenerationRef.current) { + callback(); + } + }); + scheduledFramesRef.current.add(frame); + }; + + const cancelScheduledFrames = () => { + for (const frame of scheduledFramesRef.current) { + cancelAnimationFrame(frame); + } + scheduledFramesRef.current.clear(); + for (const handle of transitionHandlesRef.current) { + handle.cancel(); + } + transitionHandlesRef.current.clear(); + }; + + const requestNewerActions = (cursor: string, canRetryError: boolean) => { + const runRequest = () => { + const latestArguments = latestArgumentsRef.current; + if ( + latestArguments.newerCursor !== cursor || + lastRequestedNewerCursorRef.current !== cursor || + !latestArguments.canLoadNewer || + !latestArguments.hasNewerActions || + latestArguments.isOffline || + latestArguments.isLoadingNewerReportActions || + (latestArguments.hasLoadingNewerReportActionsError && !canRetryError) + ) { + return; + } + latestArguments.loadNewerActions(); + }; + + if (!isSearchTopmostFullScreenRoute()) { + runRequest(); + return; + } + + // Loading a page re-renders the chat behind Search, so wait until Search's transition has finished. + const generation = paginationWindowGenerationRef.current; + const handle = TransitionTracker.runAfterTransitions({ + callback: () => { + scheduleFrame(() => { + transitionHandlesRef.current.delete(handle); + runRequest(); + }, generation); + }, + }); + transitionHandlesRef.current.add(handle); + }; + + const checkPaginationBoundaries = () => { + const latestArguments = latestArgumentsRef.current; + const listState = latestArguments.listRef.current?.getState(); + if (!listState || listState.scrollLength <= 0) { + return; + } + + const distances = getReportActionsPaginationDistances(listState, latestArguments.olderPaginationExtent, latestArguments.newerPaginationExtent); + const threshold = listState.scrollLength * REPORT_ACTIONS_PAGINATION_THRESHOLD; + const isNearOlderBoundary = distances.older <= threshold; + const isNearNewerBoundary = distances.newer <= threshold; + if (!isNearOlderBoundary) { + lastRequestedOlderCursorRef.current = undefined; + } + if (!isNearNewerBoundary) { + lastRequestedNewerCursorRef.current = undefined; + } + + // A failed page is retried only after the reader leaves that edge and comes back, not on every scroll event. + const canRetryOlderError = !wasNearOlderBoundaryRef.current; + const canRetryNewerError = !wasNearNewerBoundaryRef.current; + + if ( + isNearOlderBoundary && + latestArguments.canLoadOlder && + latestArguments.hasOlderActions && + !latestArguments.isOffline && + !latestArguments.isLoadingOlderReportActions && + (!latestArguments.hasLoadingOlderReportActionsError || canRetryOlderError) && + latestArguments.olderCursor && + lastRequestedOlderCursorRef.current !== latestArguments.olderCursor + ) { + lastRequestedOlderCursorRef.current = latestArguments.olderCursor; + latestArguments.loadOlderActions(); + } + + if ( + isNearNewerBoundary && + latestArguments.canLoadNewer && + latestArguments.hasNewerActions && + !latestArguments.isOffline && + !latestArguments.isLoadingNewerReportActions && + (!latestArguments.hasLoadingNewerReportActionsError || canRetryNewerError) && + latestArguments.newerCursor && + lastRequestedNewerCursorRef.current !== latestArguments.newerCursor + ) { + lastRequestedNewerCursorRef.current = latestArguments.newerCursor; + requestNewerActions(latestArguments.newerCursor, canRetryNewerError); + } + + wasNearOlderBoundaryRef.current = isNearOlderBoundary; + wasNearNewerBoundaryRef.current = isNearNewerBoundary; + }; + + const schedulePaginationBoundaryCheck = () => scheduleFrame(checkPaginationBoundaries); + const cancelScheduledFramesEffect = useEffectEvent(cancelScheduledFrames); + const schedulePaginationBoundaryCheckEffect = useEffectEvent(schedulePaginationBoundaryCheck); + + useEffect(() => { + paginationWindowGenerationRef.current += 1; + cancelScheduledFramesEffect(); + lastRequestedOlderCursorRef.current = undefined; + lastRequestedNewerCursorRef.current = undefined; + wasNearOlderBoundaryRef.current = false; + wasNearNewerBoundaryRef.current = false; + schedulePaginationBoundaryCheckEffect(); + }, [reportID, linkedReportActionID]); + + useEffect(() => { + lastRequestedOlderCursorRef.current = undefined; + schedulePaginationBoundaryCheckEffect(); + }, [olderCursor]); + + useEffect(() => { + lastRequestedNewerCursorRef.current = undefined; + schedulePaginationBoundaryCheckEffect(); + }, [newerCursor]); + + useEffect(() => { + const finishedLoadingOlder = wasLoadingOlderRef.current && !isLoadingOlderReportActions; + const finishedLoadingNewer = wasLoadingNewerRef.current && !isLoadingNewerReportActions; + wasLoadingOlderRef.current = isLoadingOlderReportActions; + wasLoadingNewerRef.current = isLoadingNewerReportActions; + if (finishedLoadingOlder || finishedLoadingNewer) { + schedulePaginationBoundaryCheckEffect(); + } + }, [isLoadingOlderReportActions, isLoadingNewerReportActions]); + + useEffect(() => { + const didReconnect = wasOfflineRef.current && !isOffline; + wasOfflineRef.current = isOffline; + if (!didReconnect) { + return; + } + + lastRequestedOlderCursorRef.current = undefined; + lastRequestedNewerCursorRef.current = undefined; + wasNearOlderBoundaryRef.current = false; + wasNearNewerBoundaryRef.current = false; + schedulePaginationBoundaryCheckEffect(); + }, [isOffline]); + + useEffect(() => { + const canNowLoadOlder = !couldLoadOlderRef.current && canLoadOlder; + couldLoadOlderRef.current = canLoadOlder; + if (!canNowLoadOlder) { + return; + } + lastRequestedOlderCursorRef.current = undefined; + wasNearOlderBoundaryRef.current = false; + schedulePaginationBoundaryCheckEffect(); + }, [canLoadOlder]); + + useEffect(() => { + const canNowLoadNewer = !couldLoadNewerRef.current && canLoadNewer; + couldLoadNewerRef.current = canLoadNewer; + if (!canNowLoadNewer) { + return; + } + lastRequestedNewerCursorRef.current = undefined; + wasNearNewerBoundaryRef.current = false; + schedulePaginationBoundaryCheckEffect(); + }, [canLoadNewer]); + + useEffect(() => { + schedulePaginationBoundaryCheckEffect(); + }, [olderPaginationExtent, newerPaginationExtent, hasOlderActions, hasNewerActions]); + + useEffect( + () => () => { + paginationWindowGenerationRef.current += 1; + cancelScheduledFramesEffect(); + }, + [], + ); + + return { + onScroll: checkPaginationBoundaries, + onContentSizeChange: schedulePaginationBoundaryCheck, + }; +} + +function getReportActionsPaginationDistances( + {scroll, scrollLength, contentLength}: PaginationGeometry, + olderPaginationExtent: number, + newerPaginationExtent: number, +): ReportActionsPaginationDistances { + return { + older: scroll - olderPaginationExtent, + newer: contentLength - scrollLength - scroll - newerPaginationExtent, + }; +} + +export default useReportActionsPaginationScroll; +export {REPORT_ACTIONS_PAGINATION_THRESHOLD}; diff --git a/src/hooks/useReportActionsScroll.ts b/src/hooks/useReportActionsScroll.ts index c5f2342031a0..d54678f59d59 100644 --- a/src/hooks/useReportActionsScroll.ts +++ b/src/hooks/useReportActionsScroll.ts @@ -16,7 +16,6 @@ import useReportUnreadMessageScrollTracking from '@pages/inbox/report/useReportU import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import {isEmptyValueObject} from '@src/types/utils/EmptyObject'; @@ -28,14 +27,17 @@ import type {OnyxEntry} from 'react-native-onyx'; import {useRoute} from '@react-navigation/native'; import {useEffect, useEffectEvent, useState} from 'react'; +import useLinkedReportActionScroll from './useLinkedReportActionScroll'; import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; import useOnyx from './useOnyx'; import usePrevious from './usePrevious'; import useReportScrollManager from './useReportScrollManager'; import useScrollToEndOnNewMessageReceived from './useScrollToEndOnNewMessageReceived'; -import useWindowDimensions from './useWindowDimensions'; type UseReportActionsScrollParams = { + /** Identifies the mounted list, including linked-message and hydration remounts. */ + listID: string; + /** The ID of the report currently being looked at */ reportID: string; @@ -50,7 +52,7 @@ type UseReportActionsScrollParams = { /** Sorted actions that should be visible to the user */ sortedVisibleReportActions: OnyxTypes.ReportAction[]; - /** Actions actually rendered by the list (may include a synthetic draft), used for mount scroll positioning */ + /** Actions actually rendered by the list in chronological order (may include a synthetic draft), used for scroll positioning */ renderedVisibleReportActions: OnyxTypes.ReportAction[]; /** Maps hidden canonical actions to the summary row used by the list. */ @@ -60,8 +62,6 @@ type UseReportActionsScrollParams = { keyExtractor: (item: OnyxTypes.ReportAction) => string; /** Whether the user has scrolled past the "visible" threshold */ - hasScrolledOverThreshold: boolean; - /** Marks the newest action as read and clears any pending skipped mark-as-read */ markNewestActionAsRead: () => void; @@ -77,9 +77,6 @@ type UseReportActionsScrollParams = { /** Whether the report has newer actions to load */ hasNewerActions: boolean; - /** Stable key that changes when a streamed concierge draft becomes visible, used to trigger autoscroll */ - draftAutoScrollKey: string; - /** The index of the action badge target in the rendered actions list (-1 if none) */ actionBadgeTargetIndex: number; @@ -110,32 +107,39 @@ type UseReportActionsScrollResult = { scrollToActionBadgeTarget: () => void; - /** Completes a live-tail scroll-to-bottom once the list has laid out; call on every list layout and content size change */ - flushPendingScrollToBottom: () => void; - /** Whether the list should be pinned to the visual top (transaction thread / money request) */ shouldBeAlignedToTop: boolean; - /** Whether the list should focus to the visual top on mount */ - shouldFocusToTopOnMount: boolean; - - /** The initial scroll target key for the list */ - initialScrollKey: string | undefined; - - /** maintainVisibleContentPosition config for the inverted list */ - maintainVisibleContentPosition: {disabled: boolean; autoscrollToBottomThreshold?: number; animateAutoScrollToBottom?: boolean}; - /** The index the list should scroll to on mount (undefined to keep default position) */ initialScrollIndex: number | undefined; /** Positioning params (viewPosition/viewOffset) paired with initialScrollIndex */ initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; - /** onLoad handler that disables autoscroll-to-top once the initial render settles */ + /** Whether to show a skeleton below a linked final action while newer actions are still to load */ + shouldShowNewerActionsSkeleton: boolean; + + /** Whether the linked action's row shows a skeleton below it until the first newer row is measured */ + shouldShowSkeletonBelowLinkedAction: boolean; + + /** Whether rows follow the linked action whose row shows that skeleton */ + hasRowsBelowLinkedAction: boolean; + + /** Called by that skeleton once LegendList has measured the first newer row */ + hideSkeletonBelowLinkedAction: () => void; + + /** onLoad handler that enables pill tracking after initial positioning settles */ onLoad: () => void; + + onItemSizeChanged: (info: {index: number; size: number; previous: number}) => void; + + trackLinkedMessageScroll: (event: NativeSyntheticEvent) => void; + + stopLinkedMessagePositioning: () => void; }; function useReportActionsScroll({ + listID, reportID, report, transactionThreadReport, @@ -144,13 +148,11 @@ function useReportActionsScroll({ renderedVisibleReportActions, reportActionIDToDisplayIndex, keyExtractor, - hasScrolledOverThreshold, markNewestActionAsRead, completeSkippedMarkAsRead, unreadMarkerReportActionID, unreadMarkerReportActionIndex, hasNewerActions, - draftAutoScrollKey, actionBadgeTargetIndex, sortedAllReportActionsForPagination, treatAsNoPaginationAnchor, @@ -158,10 +160,8 @@ function useReportActionsScroll({ }: UseReportActionsScrollParams): UseReportActionsScrollResult { const reportScrollManager = useReportScrollManager(); const {scrollOffsetRef, getListRef} = useActionListContext(); - const {windowHeight} = useWindowDimensions(); const route = useRoute>(); const linkedReportActionID = route?.params?.reportActionID; - const backTo = route?.params?.backTo; const {isOffline} = useNetworkWithOfflineStatus(); const [reportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportID}`); const [reportActionPages] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${getNonEmptyStringOnyxID(reportID)}`); @@ -172,7 +172,9 @@ function useReportActionsScroll({ const lastAction = sortedVisibleReportActions.at(0); const lastVisibleActionCreated = getReportLastVisibleActionCreated(report, transactionThreadReport); - const hasNewestReportAction = lastAction?.created === lastVisibleActionCreated || isReportPreviewAction(lastAction); + // A report preview can be the newest loaded action of a historical page. While newer pages are missing, loading + // the next one must not read as a new message, or each page would scroll to its end and request another. + const hasNewestReportAction = !hasNewerActions && (lastAction?.created === lastVisibleActionCreated || isReportPreviewAction(lastAction)); const sortedVisibleReportActionsObjects: OnyxTypes.ReportActions = Object.fromEntries(sortedVisibleReportActions.map((action) => [action.reportActionID, action])); const prevSortedVisibleReportActionsObjects = usePrevious(sortedVisibleReportActionsObjects); @@ -188,28 +190,22 @@ function useReportActionsScroll({ // When the report is aligned to the top, only the linked action should drive the initial scroll position and the unread marker must be ignored. // Otherwise, prefer the linked action and fall back to the unread marker. + // Sending from a link clears the route param to an empty string, which must not suppress unread positioning. let initialScrollKey = linkedReportActionID; - if (!shouldBeAlignedToTop && linkedReportActionID === undefined && unreadMarkerReportActionID) { + const shouldPositionAtUnreadMarker = !shouldBeAlignedToTop && !linkedReportActionID && unreadMarkerReportActionID; + if (shouldPositionAtUnreadMarker) { initialScrollKey = unreadMarkerReportActionID; } // The CREATED action is the top anchor of an aligned-to-top report; scrolling to it is handled by shouldFocusToTopOnMount instead. - if (shouldBeAlignedToTop && initialScrollKey && sortedVisibleReportActionsObjects[initialScrollKey]?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { + const isCreatedActionTopAnchor = shouldBeAlignedToTop && initialScrollKey && sortedVisibleReportActionsObjects[initialScrollKey]?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED; + if (isCreatedActionTopAnchor) { initialScrollKey = undefined; } const shouldFocusToTopOnMount = shouldBeAlignedToTop && !initialScrollKey && !shouldScrollToLatestOnOpen; - const shouldMaintainVisibleContentPosition = hasScrolledOverThreshold || shouldFocusToTopOnMount; - const [shouldAutoscrollToBottom, setShouldAutoscrollToBottom] = useState(shouldFocusToTopOnMount); - const [shouldDisablePillTracking, setShouldDisablePillTracking] = useState(!!initialScrollKey); - - const maintainVisibleContentPosition = { - disabled: !shouldMaintainVisibleContentPosition, - // Focus-to-top mode: once autoscroll is released, keep the threshold at 0 rather than - // removing it — FlashList only clears its pending-autoscroll flag while threshold >= 0, - // otherwise the next content change (e.g. mark-as-unread) scrolls back to top. - ...(shouldFocusToTopOnMount ? {autoscrollToBottomThreshold: shouldAutoscrollToBottom ? CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD : 0, animateAutoScrollToBottom: false} : {}), - }; + const [readyListID, setReadyListID] = useState(); + const shouldDisablePillTracking = readyListID !== listID; const { isFloatingMessageCounterVisible, @@ -225,7 +221,7 @@ function useReportActionsScroll({ onUnreadActionVisible: completeSkippedMarkAsRead, hasNewerActions, unreadMarkerReportActionIndex, - isInverted: true, + isInverted: false, // The list is hidden behind the expanded composer, so scrolling it must not bring the Latest messages pill back shouldDisablePillTracking: shouldDisablePillTracking || isComposerFullSize, onTrackScrolling: (event: NativeSyntheticEvent) => { @@ -235,7 +231,8 @@ function useReportActionsScroll({ getVisibleIndexRange: () => { const list = getListRef()?.current; // getLayout is the one accessor that does not throw before FlashList has laid out - if (!list?.getLayout?.(0)) { + const isListLayoutPending = !list || (list.getLayout && !list.getLayout(0)); + if (isListLayoutPending) { return undefined; } const range = list.computeVisibleIndices?.(); @@ -257,7 +254,7 @@ function useReportActionsScroll({ syncPillAfterComposerCollapse(); }, [isComposerFullSize]); - const {isScrollToBottomEnabled, setIsScrollToBottomEnabled, completeLiveTailPruneAfterScrollToBottom, openReportAtLiveTail} = useReportActionsNewActionLiveTail({ + const {isScrollToBottomEnabled, setIsScrollToBottomEnabled, completeLiveTailPruneAfterScrollToBottom, requestLiveTailJump} = useReportActionsNewActionLiveTail({ reportID, policyID: report?.policyID, isOffline, @@ -268,7 +265,7 @@ function useReportActionsScroll({ hasNewerActions, linkedReportActionID, hasNewestReportAction, - sortedVisibleReportActions, + renderedVisibleReportActions, sortedAllReportActionsForPagination, reportActionPages, setTreatAsNoPaginationAnchor, @@ -277,57 +274,20 @@ function useReportActionsScroll({ reportLoadingState, }); - useScrollToEndOnNewMessageReceived({ - sizeChangeType: 'changed', - scrollOffsetRef, - lastActionID: lastAction?.reportActionID, - visibleActionsLength: sortedVisibleReportActions.length, - hasNewestReportAction, - setIsFloatingMessageCounterVisible, - scrollToEnd: reportScrollManager.scrollToBottom, - // Include reportID so list-length / last-id baselines reset when the same screen instance shows another report. - resetKey: `${reportID}:${linkedReportActionID}`, - }); - - const previousDraftAutoScrollKey = usePrevious(draftAutoScrollKey); - + // Consume an explicit live-tail request after the data render, not on an unrelated future + // viewport layout (for example, opening the keyboard after the user has scrolled away). useEffect(() => { - if (!draftAutoScrollKey || previousDraftAutoScrollKey === draftAutoScrollKey) { - return; - } - - if (scrollOffsetRef.current >= CONST.REPORT.ACTIONS.AUTOSCROLL_TO_TOP_THRESHOLD || !hasNewestReportAction) { + if (!isScrollToBottomEnabled) { return; } - + reportScrollManager.scrollToBottom(); + // The list can already sit at the bottom, so no scroll event corrects the offset left by the linked message. + // Sync it here, or the next content size change shows the Latest messages pill again. + scrollOffsetRef.current = 0; setIsFloatingMessageCounterVisible(false); - requestAnimationFrame(() => { - reportScrollManager.scrollToBottom(); - }); - }, [draftAutoScrollKey, hasNewestReportAction, previousDraftAutoScrollKey, reportScrollManager, scrollOffsetRef, setIsFloatingMessageCounterVisible]); - - const scheduleInitialScrollToBottom = useEffectEvent(() => { - if (initialScrollKey) { - return undefined; - } - - return TransitionTracker.runAfterTransitions({ - callback: () => { - if (shouldFocusToTopOnMount) { - return; - } - setIsFloatingMessageCounterVisible(false); - reportScrollManager.scrollToBottom(); - }, - waitForUpcomingTransition: true, - }); - }); - - // The initial scroll-to-bottom must be scheduled exactly once, on mount; re-running it as deps change would yank the user back down while they read history. - useEffect(() => { - const handle = scheduleInitialScrollToBottom(); - return () => handle?.cancel(); - }, []); + setIsScrollToBottomEnabled(false); + completeLiveTailPruneAfterScrollToBottom(); + }, [isScrollToBottomEnabled, reportScrollManager, scrollOffsetRef, setIsFloatingMessageCounterVisible, setIsScrollToBottomEnabled, completeLiveTailPruneAfterScrollToBottom]); // Clear the shouldScrollToLatest route param once the mount scroll above has consumed it, so a later remount of // this report doesn't pull the user down again. MoneyRequestReportActionsList clears it the same way for the @@ -387,11 +347,73 @@ function useReportActionsScroll({ return () => handle?.cancel(); }, [lastAction]); + // Decide where the list should be positioned on mount. + // 1. If we're opening a linked or unread message, find that action in the chronological list. + // 2. Otherwise, aligned-to-top reports start at the first action. + const targetIndex = initialScrollKey + ? (reportActionIDToDisplayIndex?.get(initialScrollKey) ?? renderedVisibleReportActions.findIndex((item) => keyExtractor(item) === initialScrollKey)) + : -1; + const isLinkedTargetLastRow = !!linkedReportActionID && initialScrollKey === linkedReportActionID && targetIndex >= 0 && targetIndex === renderedVisibleReportActions.length - 1; + // OpenReport returns a linked action with older actions only. The list shows a skeleton below it until the newest + // page loads, so the link can open near the top like any other instead of at the end of a historical page. Removing + // the skeleton with an intermediate page would shrink the content before LegendList sizes the new rows. + const [newerActionsSkeletonListID, setNewerActionsSkeletonListID] = useState(); + const shouldStartLinkedActionSkeleton = isLinkedTargetLastRow && hasNewerActions && newerActionsSkeletonListID !== listID; + if (shouldStartLinkedActionSkeleton) { + setNewerActionsSkeletonListID(listID); + } + const shouldShowNewerActionsSkeleton = hasNewerActions && (isLinkedTargetLastRow || newerActionsSkeletonListID === listID); + // LegendList hides each newer row until it is measured, and rendering a page of them can take seconds on a slow + // device. The footer skeleton moves below them as soon as they enter the data, so the linked action's row covers + // the area below it until the first of them is measured. + const [measuredNewerRowListID, setMeasuredNewerRowListID] = useState(); + const hasRowsBelowLinkedAction = newerActionsSkeletonListID === listID && targetIndex >= 0 && targetIndex < renderedVisibleReportActions.length - 1; + const shouldShowSkeletonBelowLinkedAction = newerActionsSkeletonListID === listID && measuredNewerRowListID !== listID && (hasNewerActions || hasRowsBelowLinkedAction); + const hideSkeletonBelowLinkedAction = () => setMeasuredNewerRowListID(listID); + const isLinkedLastAction = isLinkedTargetLastRow && !hasNewerActions; + let initialScrollIndex: number | undefined; + let initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; + if (targetIndex >= 0) { + initialScrollIndex = targetIndex; + // A linked final action can be taller than the viewport, so show its highlighted end. + // Unread actions still start at the New marker. Omit viewOffset for bottom alignment so + // LegendList includes the footer (for example, Concierge's thinking indicator). + initialScrollIndexParams = isLinkedLastAction ? {viewPosition: 1} : {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; + } else if (shouldFocusToTopOnMount) { + initialScrollIndex = 0; + } + + const { + onReady, + onItemSizeChanged, + onScroll: trackLinkedMessageScroll, + stopPositioning: stopLinkedMessagePositioning, + } = useLinkedReportActionScroll({ + listID, + linkedReportActionID: initialScrollKey === linkedReportActionID ? linkedReportActionID : undefined, + targetIndex, + isLastAction: isLinkedLastAction, + }); + + useScrollToEndOnNewMessageReceived({ + scrollOffsetRef, + lastActionID: lastAction?.reportActionID, + visibleActionsLength: sortedVisibleReportActions.length, + hasNewestReportAction, + setIsFloatingMessageCounterVisible, + scrollToEnd: () => { + stopLinkedMessagePositioning(); + reportScrollManager.scrollToBottom(); + }, + resetKey: `${reportID}:${linkedReportActionID}`, + }); + const scrollToBottomAndMarkReportAsRead = () => { + stopLinkedMessagePositioning(); setIsFloatingMessageCounterVisible(false); if (unreadMarkerReportActionIndex >= 0) { - reportScrollManager.scrollToIndex(unreadMarkerReportActionIndex); + reportScrollManager.scrollToIndex(unreadMarkerReportActionIndex, {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); if (hasNewestReportAction) { markNewestActionAsRead(); } @@ -399,11 +421,8 @@ function useReportActionsScroll({ } if (!hasNewestReportAction) { - if (!Navigation.getReportRHPActiveRoute()) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID, undefined, undefined, backTo)); - } - openReportAtLiveTail(); - reportScrollManager.scrollToBottom(); + // The historical page's end is not the report's end. Reuse the send flow to wait for the newest page before scrolling. + requestLiveTailJump(); return; } reportScrollManager.scrollToBottom(); @@ -414,77 +433,26 @@ function useReportActionsScroll({ if (actionBadgeTargetIndex < 0) { return; } + stopLinkedMessagePositioning(); // The expanded composer covers the list, so the target never becomes viewable to hide the pill if (isComposerFullSize) { setIsActionBadgeAboveViewport(false); setIsFloatingMessageCounterVisible(false); } // `animated` is explicit because native defaults to an instant jump, which would teleport the list and lose the user's place. - reportScrollManager.scrollToIndex(actionBadgeTargetIndex, {animated: true, viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + reportScrollManager.scrollToIndex(actionBadgeTargetIndex, {animated: true, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }; - const flushPendingScrollToBottom = () => { - if (!isScrollToBottomEnabled) { - return; - } - reportScrollManager.scrollToBottom(); - // A list remounted for the live tail already sits at the bottom, so no scroll event corrects the offset left - // by the linked message. Sync it here, or the next content size change shows the Latest messages pill again. - scrollOffsetRef.current = 0; - setIsFloatingMessageCounterVisible(false); - setIsScrollToBottomEnabled(false); - completeLiveTailPruneAfterScrollToBottom(); - }; - - // Data is ready at the moment FlashList finishes its first render. const onLoad = () => { - if (shouldDisablePillTracking) { - // Wait one frame so the initial positioning can settle, then disable it. - requestAnimationFrame(() => { - setShouldDisablePillTracking(false); - updatePillVisibility(); - }); - } - if (!shouldFocusToTopOnMount) { + onReady(); + if (!shouldDisablePillTracking) { return; } - if (!reportLoadingState?.hasOnceLoadedReportActions && !isOffline) { - return; - } - // Wait one frame so the initial autoscroll-to-top can settle, then disable it. - requestAnimationFrame(() => setShouldAutoscrollToBottom(false)); + requestAnimationFrame(() => { + setReadyListID(listID); + updatePillVisibility(); + }); }; - const prevHasOnceLoadedReportActions = usePrevious(reportLoadingState?.hasOnceLoadedReportActions); - - // Data finished initial loading after the list mounted. onLoad has already fired, so we need - // a separate trigger to turn off autoscroll-to-top. - useEffect(() => { - if (!shouldFocusToTopOnMount || !shouldAutoscrollToBottom) { - return; - } - if (prevHasOnceLoadedReportActions || !reportLoadingState?.hasOnceLoadedReportActions) { - return; - } - requestAnimationFrame(() => setShouldAutoscrollToBottom(false)); - }, [shouldFocusToTopOnMount, shouldAutoscrollToBottom, prevHasOnceLoadedReportActions, reportLoadingState?.hasOnceLoadedReportActions]); - - // Decide where the list should be positioned on mount. - // 1. If we're opening a linked message (initialScrollKey), find that action in the list and scroll it to the top - // of the viewport (viewPosition: 1) with a small offset so the message above is partly visible. - // 2. Otherwise, if the report should be opened at top (ex: for transaction threads), scroll to the top message and offset by - // the window height so we land at top of the top message for sure. - const targetIndex = initialScrollKey - ? (reportActionIDToDisplayIndex?.get(initialScrollKey) ?? renderedVisibleReportActions.findIndex((item) => keyExtractor(item) === initialScrollKey)) - : -1; - let initialScrollIndex: number | undefined; - let initialScrollIndexParams: {viewPosition?: number; viewOffset?: number} | undefined; - if (targetIndex > 0) { - initialScrollIndex = targetIndex; - initialScrollIndexParams = {viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}; - } else if (shouldFocusToTopOnMount) { - initialScrollIndex = renderedVisibleReportActions.length - 1; - initialScrollIndexParams = {viewOffset: windowHeight}; - } return { trackVerticalScrolling, @@ -493,14 +461,17 @@ function useReportActionsScroll({ isActionBadgeAboveViewport, scrollToBottomAndMarkReportAsRead, scrollToActionBadgeTarget, - flushPendingScrollToBottom, shouldBeAlignedToTop, - shouldFocusToTopOnMount, - initialScrollKey, - maintainVisibleContentPosition, initialScrollIndex, initialScrollIndexParams, + shouldShowNewerActionsSkeleton, + shouldShowSkeletonBelowLinkedAction, + hasRowsBelowLinkedAction, + hideSkeletonBelowLinkedAction, onLoad, + onItemSizeChanged, + trackLinkedMessageScroll, + stopLinkedMessagePositioning, }; } diff --git a/src/hooks/useReportScrollManager/index.native.ts b/src/hooks/useReportScrollManager/index.native.ts index 0113fa6b9765..8c9dea34397a 100644 --- a/src/hooks/useReportScrollManager/index.native.ts +++ b/src/hooks/useReportScrollManager/index.native.ts @@ -18,35 +18,13 @@ function useReportScrollManager(): ReportScrollManagerData { listRef.current.scrollToIndex({index, animated, viewOffset, viewPosition}); }; - /** - * Scroll to the bottom of the inverted FlatList. - * When FlatList is inverted it's "bottom" is really it's top - */ + /** Scroll to the bottom of the chronological action list. */ const scrollToBottom = () => { const listRef = getListRef(); if (!listRef?.current) { return; } - listRef.current.scrollToIndex({animated: false, index: 0}); - }; - - /** - * Scroll to the end of the FlatList. - */ - const scrollToEnd = () => { - const listRef = getListRef(); - if (!listRef?.current) { - return; - } - - const scrollViewRef = listRef.current.getNativeScrollRef?.(); - // Try to scroll on underlying scrollView if available, fallback to usual listRef - if (scrollViewRef && typeof scrollViewRef.scrollToEnd === 'function') { - scrollViewRef.scrollToEnd({animated: false}); - return; - } - listRef.current.scrollToEnd({animated: false}); }; @@ -58,7 +36,7 @@ function useReportScrollManager(): ReportScrollManagerData { listRef.current.scrollToOffset({offset, animated: false}); }; - return {scrollToIndex, scrollToBottom, scrollToEnd, scrollToOffset}; + return {scrollToIndex, scrollToBottom, scrollToOffset}; } export default useReportScrollManager; diff --git a/src/hooks/useReportScrollManager/index.ts b/src/hooks/useReportScrollManager/index.ts index a2e615e7d6d6..9ff4bafbcc4e 100644 --- a/src/hooks/useReportScrollManager/index.ts +++ b/src/hooks/useReportScrollManager/index.ts @@ -18,28 +18,13 @@ function useReportScrollManager(): ReportScrollManagerData { listRef.current.scrollToIndex({index, animated, viewOffset, viewPosition}); }; - /** - * Scroll to the bottom of the inverted FlatList. - * When FlatList is inverted it's "bottom" is really it's top - */ + /** Scroll to the bottom of the chronological action list. */ const scrollToBottom = () => { const listRef = getListRef(); if (!listRef?.current) { return; } - listRef.current.scrollToIndex({animated: false, index: 0}); - }; - - /** - * Scroll to the end of the FlatList. - */ - const scrollToEnd = () => { - const listRef = getListRef(); - if (!listRef?.current) { - return; - } - listRef.current.scrollToEnd({animated: false}); }; @@ -52,7 +37,7 @@ function useReportScrollManager(): ReportScrollManagerData { listRef.current.scrollToOffset({animated: true, offset}); }; - return {scrollToIndex, scrollToBottom, scrollToEnd, scrollToOffset}; + return {scrollToIndex, scrollToBottom, scrollToOffset}; } export default useReportScrollManager; diff --git a/src/hooks/useReportScrollManager/types.ts b/src/hooks/useReportScrollManager/types.ts index c7649ecc334a..01587bf2f60e 100644 --- a/src/hooks/useReportScrollManager/types.ts +++ b/src/hooks/useReportScrollManager/types.ts @@ -18,7 +18,6 @@ type ReportScrollManagerData = { */ scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void; scrollToBottom: () => void; - scrollToEnd: () => void; scrollToOffset: (offset: number) => void; }; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index bdb10efcf5ee..673159a90809 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -12328,7 +12328,6 @@ function navigateToLinkedReportAction( ancestor: Ancestor, isInNarrowPaneModal: boolean, canUserPerformWrite: boolean | undefined, - isOffline: boolean, visibleReportActionsData?: VisibleReportActionsDerivedValue, ) { if (isInNarrowPaneModal) { @@ -12361,7 +12360,7 @@ function navigateToLinkedReportAction( const reportID = ancestor.report.reportID; const isVisibleAction = !!reportID && isReportActionVisible(ancestor.reportAction, reportID, canUserPerformWrite, visibleReportActionsData); - if (isVisibleAction && !isOffline) { + if (isVisibleAction) { // Pop the chat report screen before navigating to the linked report action. Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(ancestor.report.reportID, ancestor.reportAction.reportActionID)); } diff --git a/src/pages/inbox/ActionListContext.tsx b/src/pages/inbox/ActionListContext.tsx index f620b9e53724..749b77145008 100644 --- a/src/pages/inbox/ActionListContext.tsx +++ b/src/pages/inbox/ActionListContext.tsx @@ -1,6 +1,6 @@ import type {ReactNode, RefObject} from 'react'; -import React, {createContext, useContext, useLayoutEffect, useRef} from 'react'; +import React, {createContext, useContext, useLayoutEffect, useRef, useState} from 'react'; import type ActionListRefType from './ActionListTypes'; import type {ActionListRef} from './ActionListTypes'; @@ -25,6 +25,14 @@ const ActionListContext = createContext({ getListRef: () => null, }); +const ActionListReadyContext = createContext(true); +const ActionListReadyActionsContext = createContext<(isReady: boolean) => void>(() => {}); + +/** Coordinates automatic composer focus with the list's visible initial layout. */ +function useActionListReady() { + return {isReady: useContext(ActionListReadyContext), setIsReady: useContext(ActionListReadyActionsContext)}; +} + function useActionListContext() { return useContext(ActionListContext); } @@ -35,18 +43,24 @@ function useActionListContext() { */ function useActionListRef() { const {registerListRef} = useActionListContext(); + const {setIsReady} = useActionListReady(); const listRef = useRef(null); useLayoutEffect(() => { + setIsReady(false); registerListRef(listRef); - return () => registerListRef(null); - }, [registerListRef]); + return () => { + registerListRef(null); + setIsReady(false); + }; + }, [registerListRef, setIsReady]); return listRef; } /** Owns the action-list context value so screens don't wire it up themselves. */ function ActionListContextProvider({children}: {children: ReactNode}) { + const [isReady, setIsReady] = useState(false); // Each list owns its own ref locally and publishes it here on mount; only the register/get // callbacks live in context, so attaching `ref={}` stays local to each list. const listRefHolder = useRef(null); @@ -64,8 +78,14 @@ function ActionListContextProvider({children}: {children: ReactNode}) { // The value is only stable refs and ref accessors — none of it triggers re-renders — so a // single context is intentional; splitting state/actions into two providers would add ceremony // for no benefit. - // eslint-disable-next-line rulesdir/context-provider-split-values - return {children}; + return ( + + + {/* eslint-disable-next-line rulesdir/context-provider-split-values */} + {children} + + + ); } -export {ActionListContext, ActionListContextProvider, useActionListContext, useActionListRef}; +export {ActionListContext, ActionListContextProvider, useActionListContext, useActionListReady, useActionListRef}; diff --git a/src/pages/inbox/ActionListTypes.ts b/src/pages/inbox/ActionListTypes.ts index 29887c07c515..c2a8367c16ac 100644 --- a/src/pages/inbox/ActionListTypes.ts +++ b/src/pages/inbox/ActionListTypes.ts @@ -26,7 +26,17 @@ type ActionListRef = { getLayout?: (index: number) => unknown; computeVisibleIndices?: () => {startIndex: number; endIndex: number} | undefined; /** Measured item positions supplied by LegendList. Other list implementations can omit this. */ - getState?: () => {positionByKey: (key: string) => number | undefined; scroll?: number; contentLength?: number; scrollLength?: number} | undefined; + getState?: () => + | { + positionByKey: (key: string) => number | undefined; + sizeAtIndex?: (index: number) => number | undefined; + elementAtIndex?: (index: number) => unknown; + scroll?: number; + contentLength?: number; + scrollLength?: number; + headerSize?: number; + } + | undefined; }; /** Ref to the underlying list instance attached via `ref={}`. */ diff --git a/src/pages/inbox/ChatReportActions.tsx b/src/pages/inbox/ChatReportActions.tsx index 2c98a0405ff4..b283ab7f1f71 100644 --- a/src/pages/inbox/ChatReportActions.tsx +++ b/src/pages/inbox/ChatReportActions.tsx @@ -1,5 +1,6 @@ /** Keep chat effects unmounted during app loading, except for the Concierge welcome screen. */ import {useIsAppLoadPending} from '@hooks/useInFlightRequests'; +import useInitial from '@hooks/useInitial'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -24,8 +25,10 @@ function ChatReportActions({report}: ChatReportActionsProps) { const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const isAppLoadPending = useIsAppLoadPending(); const isConciergeMainDM = isConciergeChatReport(report, conciergeReportID); + const shouldMountReportActions = isOffline || !isAppLoadPending || isConciergeMainDM; + const hasRenderedReportActions = useInitial(shouldMountReportActions ? true : undefined); - if (isAppLoadPending && !isOffline && !isConciergeMainDM) { + if (!hasRenderedReportActions) { return ( { @@ -128,7 +126,6 @@ function AncestorReportActionItem({ }, isInNarrowPaneModal, canUserPerformWriteActionReportUtils(report, isArchived), - isOffline, ); }; diff --git a/src/pages/inbox/report/AskConciergeWelcome.tsx b/src/pages/inbox/report/AskConciergeWelcome.tsx index ef7d0d2fca3b..9ca26e1c9e55 100644 --- a/src/pages/inbox/report/AskConciergeWelcome.tsx +++ b/src/pages/inbox/report/AskConciergeWelcome.tsx @@ -6,9 +6,11 @@ import useThemeStyles from '@hooks/useThemeStyles'; import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; +import {useActionListReady} from '@pages/inbox/ActionListContext'; + import ONYXKEYS from '@src/ONYXKEYS'; -import React from 'react'; +import React, {useEffect} from 'react'; import AskConciergeEmptyState from './AskConciergeEmptyState'; import ConciergeChatHistoryToggle from './ConciergeChatHistoryToggle'; @@ -22,6 +24,8 @@ type AskConciergeWelcomeProps = { }; function AskConciergeWelcome({reportID, onShowPreviousMessages}: AskConciergeWelcomeProps) { + const {setIsReady} = useActionListReady(); + useEffect(() => () => setIsReady(false), [setIsReady]); const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); @@ -31,7 +35,10 @@ function AskConciergeWelcome({reportID, onShowPreviousMessages}: AskConciergeWel style={[!shouldUseNarrowLayout && styles.flexGrow0, styles.conciergeAskColumn]} contentContainerStyle={[styles.flexGrow1, styles.justifyContentCenter]} keyboardShouldPersistTaps="handled" - onLayout={() => markOpenReportEnd(reportID, report, {warm: true})} + onLayout={() => { + setIsReady(true); + markOpenReportEnd(reportID, report, {warm: true}); + }} > { const isModalVisible = modal?.isVisible; @@ -858,7 +859,9 @@ function ComposerWithSuggestions({ // We want to focus or refocus the input when a modal has been closed or the underlying screen is refocused. // We avoid doing this on native platforms since the software keyboard popping // open creates a jarring and broken UX. - if (!((willBlurTextInputOnTapOutside || shouldAutoFocus) && !isNextModalWillOpenRef.current && !isModalVisible && (!!prevIsModalVisible || !prevIsFocused))) { + const shouldRefocusComposer = + (willBlurTextInputOnTapOutside || shouldAutoFocus) && !isNextModalWillOpenRef.current && !isModalVisible && (!!prevIsModalVisible || !prevIsFocused || prevShouldSkipAutoFocus); + if (!shouldRefocusComposer) { return; } @@ -882,6 +885,7 @@ function ComposerWithSuggestions({ isNextModalWillOpenRef, shouldAutoFocus, shouldSkipAutoFocus, + prevShouldSkipAutoFocus, isSidePanelHiddenOrLargeScreen, shouldHideSidePanel, isInSidePanel, diff --git a/src/pages/inbox/report/ReportActionCompose/useShouldSkipComposerAutoFocus.ts b/src/pages/inbox/report/ReportActionCompose/useShouldSkipComposerAutoFocus.ts index d4029d0ff96e..2dc024e80638 100644 --- a/src/pages/inbox/report/ReportActionCompose/useShouldSkipComposerAutoFocus.ts +++ b/src/pages/inbox/report/ReportActionCompose/useShouldSkipComposerAutoFocus.ts @@ -3,6 +3,8 @@ import useOnyx from '@hooks/useOnyx'; import getPlatform from '@libs/getPlatform'; import {isOneTransactionReport, isReportTransactionThread} from '@libs/ReportUtils'; +import {useActionListReady} from '@pages/inbox/ActionListContext'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; @@ -24,12 +26,18 @@ const hasExpenseCarouselSelector = (transactionIDs: string[] | undefined) => (tr * A report with a single expense is shown as that expense's view. */ function useShouldSkipComposerAutoFocus(report: OnyxEntry): boolean { + const {isReady} = useActionListReady(); // Read off the context because `useRoute` throws outside a screen. const routeName = useContext(NavigationRouteContext)?.name; const [hasExpenseCarousel = false] = useOnyx(ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS, {selector: hasExpenseCarouselSelector}); const isExpenseView = isReportTransactionThread(report) || isOneTransactionReport(report); - return isWeb && routeName !== SCREENS.REPORT && hasExpenseCarousel && isExpenseView; + if (!isReady) { + return true; + } + + const shouldPreserveExpenseNavigation = isWeb && routeName !== SCREENS.REPORT && hasExpenseCarousel && isExpenseView; + return shouldPreserveExpenseNavigation; } export default useShouldSkipComposerAutoFocus; diff --git a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx index 6b11439089c2..6a8c8ceee9d8 100644 --- a/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx +++ b/src/pages/inbox/report/ReportActionsList/ReportActionsListContent.tsx @@ -1,5 +1,5 @@ +/** Renders report messages with history controls, unread indicators, and loading states. */ import {renderScrollComponent as renderActionSheetAwareScrollView} from '@components/ActionSheetAwareScrollView'; -import InvertedFlashList from '@components/FlashList/InvertedFlashList'; import MerchantRuleSuggestionBanner from '@components/MerchantRuleSuggestionBanner'; import {ReportActionsAnimatedSkeletonCover} from '@components/ReportActionsSkeletonCover'; import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView'; @@ -22,13 +22,14 @@ import {isArchivedReport} from '@libs/ReportUtils'; import type {ReportsSplitNavigatorParamList} from '@navigation/types'; -import {useActionListContext} from '@pages/inbox/ActionListContext'; +import {useActionListContext, useActionListReady} from '@pages/inbox/ActionListContext'; import {useConciergeChatHistoryActions} from '@pages/inbox/ConciergeChatHistoryContext'; import AskConciergeWelcome from '@pages/inbox/report/AskConciergeWelcome'; import ConciergeChatHistoryToggle from '@pages/inbox/report/ConciergeChatHistoryToggle'; -import {useReportActionsListActions, useReportActionsListState} from '@pages/inbox/report/ReportActionsListContext'; +import {useReportActionsListState} from '@pages/inbox/report/ReportActionsListContext'; import ReportActionsListHeader from '@pages/inbox/report/ReportActionsListHeader'; import ReportActionsListPaddingView from '@pages/inbox/report/ReportActionsListPaddingView'; +import ReportActionsPaginationLoadingIndicator from '@pages/inbox/report/ReportActionsPaginationLoadingIndicator'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -37,8 +38,10 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; +import {LegendList} from '@legendapp/list/react-native'; import {useRoute} from '@react-navigation/native'; -import React, {useState} from 'react'; +import React, {useEffect, useState} from 'react'; +import {Platform, View} from 'react-native'; import type {ReportActionsListProps} from './types'; @@ -49,6 +52,11 @@ import useReportActionsListRenderItem from './useReportActionsListRenderItem'; import useReportActionsListScroll from './useReportActionsListScroll'; import useReportActionsListTelemetry from './useReportActionsListTelemetry'; +const REPORT_ACTIONS_DRAW_DISTANCE = 1500; +// Close to a typical message row. Unmeasured rows keep this estimate, and LegendList's 100px default made every +// measurement shift the scroll offset. On web, dragging the scrollbar undoes each shift, which shows as a jump. +const REPORT_ACTIONS_ESTIMATED_ITEM_SIZE = 50; + /** * Renders the report-actions list. Reads its data from `ReportActionsListStateContext` / `ReportActionsListActionsContext` and holds the * UI-close hooks (`useUnreadMarker` / `useMarkAsRead` / `useReportActionsScroll`). `ReportActionsSkeletonGuard` @@ -79,16 +87,12 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) ? unfilteredVisibleReportActions.filter((action) => !shouldHidePayAction(action, true)) : unfilteredVisibleReportActions; - const {handleShowPreviousMessages: onShowPreviousMessages} = useReportActionsListActions(); - const {isOffline} = useNetwork(); const route = useRoute>(); const linkedReportActionID = route?.params?.reportActionID; const {sessionStartTime} = useSidePanelState(); const {collapseHistory: collapseConciergeHistory} = useConciergeChatHistoryActions(); - const recordTimeToMeasureItemLayout = useReportActionsListTelemetry({reportID, onLayout}); - useLinkedMessageOfflineLoading({reportID: report?.reportID ?? reportID, reportActionIDFromRoute: linkedReportActionID}); // Owned here rather than by the callout, which unmounts as the layout and composer change size. @@ -127,7 +131,6 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) const {getScrollOffset} = useActionListContext(); const [hasScrolledOverThreshold, setHasScrolledOverThreshold] = useState(() => getScrollOffset() >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - const {unreadMarkerReportActionID} = useUnreadMarker({ reportID, sortedVisibleReportActions, @@ -149,31 +152,56 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) const reportActionsListData = useReportActionsListData({ sortedVisibleReportActions, + isOffline, sessionStartTime, linkedReportActionID, unreadMarkerReportActionID, }); - const {listData, reportActionIDToDisplayIndex, unreadMarkerListIndex, focusRequestedReportActionID, draftAutoScrollKey, isDraftPendingCompletion} = reportActionsListData; - - const {listID, listProps, shouldBeAlignedToTop, isFloatingMessageCounterVisible, isActionBadgeAboveViewport, scrollToBottomAndMarkReportAsRead, scrollToActionBadgeTarget} = - useReportActionsListScroll({ - reportID, - // Hidden surveys still prove that the selected page contains the newest action. - sortedVisibleReportActions: paymentFilteredVisibleReportActions, - renderedVisibleReportActions: listData, - reportActionIDToDisplayIndex, - hasScrolledOverThreshold, - markNewestActionAsRead, - completeSkippedMarkAsRead, - unreadMarkerReportActionID, - unreadMarkerReportActionIndex: unreadMarkerListIndex, - draftAutoScrollKey, - focusRequestedReportActionID, - actionBadgeTargetID: reportAttributes?.actionTargetReportActionID, - linkedReportActionID, - onScrolledOverThresholdChange: setHasScrolledOverThreshold, - onLayout: recordTimeToMeasureItemLayout, - }); + const {listData, reportActionIDToDisplayIndex, unreadMarkerListIndex, focusRequestedReportActionID, isDraftPendingCompletion} = reportActionsListData; + + const { + listID, + legendListProps, + loadedInitialViewportListID, + shouldShowInitialViewportSkeleton, + shouldShowNewerActionsSkeleton, + shouldShowSkeletonBelowLinkedAction, + hasRowsBelowLinkedAction, + hideSkeletonBelowLinkedAction, + shouldBeAlignedToTop, + onShowPreviousMessages, + isFloatingMessageCounterVisible, + isActionBadgeAboveViewport, + scrollToBottomAndMarkReportAsRead, + scrollToActionBadgeTarget, + shouldShowOlderPaginationLoadingIndicator, + shouldShowNewerPaginationLoadingIndicator, + onNewerFooterLayout, + } = useReportActionsListScroll({ + reportID, + // Hidden surveys still prove that the selected page contains the newest action. + sortedVisibleReportActions: paymentFilteredVisibleReportActions, + renderedVisibleReportActions: listData, + reportActionIDToDisplayIndex, + markNewestActionAsRead, + completeSkippedMarkAsRead, + unreadMarkerReportActionID, + unreadMarkerReportActionIndex: unreadMarkerListIndex, + focusRequestedReportActionID, + actionBadgeTargetID: reportAttributes?.actionTargetReportActionID, + isOffline, + linkedReportActionID, + onScrolledOverThresholdChange: setHasScrolledOverThreshold, + onLayout, + }); + + useReportActionsListTelemetry({reportID, listID, loadedInitialViewportListID, shouldShowInitialViewportSkeleton}); + + const {setIsReady} = useActionListReady(); + useEffect(() => { + const isComposerReady = shouldShowConciergeWelcome || (!shouldShowInitialViewportSkeleton && loadedInitialViewportListID === listID); + setIsReady(isComposerReady); + }, [listID, loadedInitialViewportListID, setIsReady, shouldShowConciergeWelcome, shouldShowInitialViewportSkeleton]); const {renderItem, extraData} = useReportActionsListRenderItem({ ...reportActionsListData, @@ -185,31 +213,48 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) unreadMarkerReportActionID, linkedReportActionID, onShowPreviousMessages, + shouldShowSkeletonBelowLinkedAction, + hasRowsBelowLinkedAction, + hideSkeletonBelowLinkedAction, }); - const listHeaderComponent = ( + const latestActionsFooter = ( <> - {isConciergeHistoryExpanded && ( - { - collapseConciergeHistory(); - if (linkedReportActionID) { - Navigation.setParams({reportActionID: ''}, route.key); - } - }} + + {isConciergeHistoryExpanded && ( + { + collapseConciergeHistory(); + if (linkedReportActionID) { + Navigation.setParams({reportActionID: ''}, route.key); + } + }} + /> + )} + - )} - + {shouldShowNewerActionsSkeleton && ( + // The linked row shows its own skeleton; this one reserves the height below it. + + + + )} + + {shouldShowNewerPaginationLoadingIndicator && } ); const shouldShowOfflineSkeleton = isOffline && !sortedVisibleReportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); - const listFooterComponent = shouldShowOfflineSkeleton ? : undefined; + const olderHistoryHeader = ( + <> + {shouldShowOlderPaginationLoadingIndicator && } + {shouldShowOfflineSkeleton && } + + ); // The guard only mounts this content when the report is loaded, so this is effectively unreachable. // It narrows `report` to non-undefined for the render below and stays a safe fallback if the report @@ -250,23 +295,40 @@ function ReportActionsListContent({reportID, onLayout}: ReportActionsListProps) report={report} isReportArchived={isReportArchived} > - + {shouldShowInitialViewportSkeleton && ( + + + + )} ); diff --git a/src/pages/inbox/report/ReportActionsList/ReportActionsListRow.tsx b/src/pages/inbox/report/ReportActionsList/ReportActionsListRow.tsx index da1b15ce71ec..027dee13a198 100644 --- a/src/pages/inbox/report/ReportActionsList/ReportActionsListRow.tsx +++ b/src/pages/inbox/report/ReportActionsList/ReportActionsListRow.tsx @@ -18,6 +18,9 @@ import React from 'react'; type ReportActionsListRowProps = Omit & { index: number; + /** Whether this row shows the newest action, which is the last row in the chronological list */ + isNewest: boolean; + /** Set only when this row anchors a collapsed run of system messages. The row then renders the run instead of the action. */ collapsedSystemMessageRun?: SystemMessageRun; @@ -35,6 +38,7 @@ type ReportActionsListRowProps = Omit {collapsedSystemMessageRun ? ( void; +}; + +function SkeletonBelowLinkedAction({linkedActionIndex, hasNewerRows, onNewerRowMeasured}: SkeletonBelowLinkedActionProps) { + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + const {getListRef} = useActionListContext(); + const [isNewerRowMeasured, setIsNewerRowMeasured] = useState(false); + + useEffect(() => { + if (!hasNewerRows) { + return; + } + let frame: number; + const checkNewerRowMeasured = () => { + const listState = getListRef()?.current?.getState?.(); + // A row without a container is off screen, so there is nothing left to cover. + const shouldWaitForNewerRowMeasurement = listState?.sizeAtIndex?.(linkedActionIndex + 1) === undefined && listState?.elementAtIndex?.(linkedActionIndex + 1); + if (shouldWaitForNewerRowMeasurement) { + frame = requestAnimationFrame(checkNewerRowMeasured); + return; + } + // Local state re-renders only this view, so the skeleton goes away in the frame the row appears. The list + // re-renders afterwards to drop it for good. + setIsNewerRowMeasured(true); + onNewerRowMeasured(); + }; + frame = requestAnimationFrame(checkNewerRowMeasured); + return () => cancelAnimationFrame(frame); + }, [getListRef, hasNewerRows, linkedActionIndex, onNewerRowMeasured]); + + if (isNewerRowMeasured) { + return null; + } + + return ( + + + + ); +} + +export default SkeletonBelowLinkedAction; diff --git a/src/pages/inbox/report/ReportActionsList/index.tsx b/src/pages/inbox/report/ReportActionsList/index.tsx index 047b3d8083db..5a7b01610f53 100644 --- a/src/pages/inbox/report/ReportActionsList/index.tsx +++ b/src/pages/inbox/report/ReportActionsList/index.tsx @@ -1,5 +1,6 @@ import ReportActionsSkeletonGuard from '@pages/inbox/report/ReportActionsSkeletonGuard'; +/** Keeps the report-data readiness guard outside the scrollable message list. */ import React from 'react'; import type {ReportActionsListProps} from './types'; @@ -12,7 +13,10 @@ import ReportActionsListContent from './ReportActionsListContent'; */ function ReportActionsList({reportID, onLayout}: ReportActionsListProps) { return ( - + & Pick, 'reportActionIDToDisplayIndex' | 'focusRequestedReportActionID'> & - Pick & + Pick & Pick & { actionBadgeTargetID: string | undefined; onScrolledOverThresholdChange: (isOverThreshold: boolean) => void; @@ -45,6 +44,7 @@ type UseReportActionsListScrollParams = Pick< type UseReportActionsListRenderItemParams = Pick< ReturnType, | 'renderedVisibleReportActions' + | 'reportActionsToRender' | 'listData' | 'canonicalIndexByReportActionID' | 'runsByAnchorReportActionID' @@ -56,15 +56,35 @@ type UseReportActionsListRenderItemParams = Pick< | 'draftReportActionID' | 'isDraftPendingCompletion' > & - Pick & + Pick & Pick & { reportNameValuePairs: OnyxEntry; isReportArchived: boolean; - isOffline: boolean; sortedReportActions: OnyxTypes.ReportAction[]; onShowPreviousMessages: () => void; + shouldShowSkeletonBelowLinkedAction: boolean; + hasRowsBelowLinkedAction: boolean; + hideSkeletonBelowLinkedAction: () => void; }; -type UseReportActionsListTelemetryParams = Pick; +type UseReportActionsListTelemetryParams = { + reportID: string; + listID: string; + loadedInitialViewportListID: string | undefined; + shouldShowInitialViewportSkeleton: boolean; +}; -export type {ReportActionsListProps, UseReportActionsListDataParams, UseReportActionsListScrollParams, UseReportActionsListRenderItemParams, UseReportActionsListTelemetryParams}; +type ReportActionsListScrollMetrics = { + offset: number; + contentHeight: number; + viewportHeight: number; +}; + +export type { + ReportActionsListProps, + UseReportActionsListDataParams, + UseReportActionsListScrollParams, + UseReportActionsListRenderItemParams, + UseReportActionsListTelemetryParams, + ReportActionsListScrollMetrics, +}; diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListData.ts b/src/pages/inbox/report/ReportActionsList/useReportActionsListData.ts index f7296b1de56e..87df574c609f 100644 --- a/src/pages/inbox/report/ReportActionsList/useReportActionsListData.ts +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListData.ts @@ -1,3 +1,6 @@ +/** Prepares chronological report rows, including streamed drafts, initial snapshots, and collapsed system messages. */ +import useInitial from '@hooks/useInitial'; + import {getReportActionMessage, isTransactionThread} from '@libs/ReportActionsUtils'; import {isExpenseReport, isIOUReport, isInvoiceReport} from '@libs/ReportUtils'; @@ -7,15 +10,14 @@ import useReportActionsPresentation from '@pages/inbox/report/useReportActionsPr import CONST from '@src/CONST'; -import {useEffect} from 'react'; +import {useEffect, useState} from 'react'; import type {UseReportActionsListDataParams} from './types'; import getReportActionsWithStreamedReply from './getReportActionsWithStreamedReply'; -/** Prepares the rendered report rows, including streamed Concierge drafts and collapsed system messages. */ -function useReportActionsListData({sortedVisibleReportActions, sessionStartTime, linkedReportActionID, unreadMarkerReportActionID}: UseReportActionsListDataParams) { - const {report, sortedAllReportActions, parentReportAction, isConciergeHiddenHistory, showFullHistory} = useReportActionsListState(); +function useReportActionsListData({sortedVisibleReportActions, isOffline, sessionStartTime, linkedReportActionID, unreadMarkerReportActionID}: UseReportActionsListDataParams) { + const {report, sortedAllReportActions, parentReportAction, isInitialReportLoadPending, isConciergeHiddenHistory, showFullHistory} = useReportActionsListState(); const {draftReportAction, isDraftPendingCompletion} = useConciergeDraft(); const {clearDraft, revealDraftFromReportAction} = useConciergeDraftActions(); const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; @@ -35,6 +37,20 @@ function useReportActionsListData({sortedVisibleReportActions, sessionStartTime, shouldHideDraft, }); + // OpenReport starts with a tiny cached page before replacing it with the hydrated page. Keep that + // already-visible page mounted until hydration finishes instead of exposing intermediate estimated + // layouts. The hydrated list then mounts from scratch using the full dataset. + const initialReportActionsSnapshot = useInitial(renderedVisibleReportActions.length > 0 ? renderedVisibleReportActions : undefined); + const [hasReleasedInitialSnapshot, setHasReleasedInitialSnapshot] = useState(isOffline || !isInitialReportLoadPending || showFullHistory); + const shouldReleaseInitialSnapshot = !hasReleasedInitialSnapshot && (isOffline || !isInitialReportLoadPending || showFullHistory); + if (shouldReleaseInitialSnapshot) { + setHasReleasedInitialSnapshot(true); + } + const shouldKeepInitialSnapshot = !hasReleasedInitialSnapshot; + const reportActionsToRender = shouldKeepInitialSnapshot ? (initialReportActionsSnapshot ?? renderedVisibleReportActions) : renderedVisibleReportActions; + + // Report actions are stored newest-first. LegendList renders them chronologically. + const chronologicalReportActions = [...reportActionsToRender].reverse(); const shouldCollapseSystemMessages = isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report) || isTransactionThread(parentReportAction); const { displayReportActions, @@ -46,19 +62,15 @@ function useReportActionsListData({sortedVisibleReportActions, sessionStartTime, focusRequestedReportActionID, onExpandedSystemMessageReady, } = useReportActionsPresentation({ - visibleReportActions: shouldCollapseSystemMessages ? renderedVisibleReportActions : [], + visibleReportActions: shouldCollapseSystemMessages ? chronologicalReportActions : [], linkedReportActionID, unreadMarkerReportActionID, }); - const listData = shouldCollapseSystemMessages ? displayReportActions : renderedVisibleReportActions; - const canonicalIndexByReportActionID = new Map(renderedVisibleReportActions.map((action, index) => [action.reportActionID, index])); - const unreadMarkerListIndex = shouldCollapseSystemMessages - ? unreadMarkerReportActionIndex - : renderedVisibleReportActions.findIndex((action) => action.reportActionID === unreadMarkerReportActionID); + const listData = shouldCollapseSystemMessages ? displayReportActions : chronologicalReportActions; + const canonicalIndexByReportActionID = new Map(reportActionsToRender.map((action, index) => [action.reportActionID, index])); const draftMessageHTML = draftReportAction ? getReportActionMessage(draftReportAction)?.html : undefined; const draftReportActionID = draftReportAction?.reportActionID; - const draftAutoScrollKey = isSyntheticDraftVisible && draftReportAction ? `${draftReportAction.reportActionID}:${draftMessageHTML ?? ''}` : ''; useEffect(() => { if (!draftReportAction || isSyntheticDraftVisible) { @@ -78,8 +90,11 @@ function useReportActionsListData({sortedVisibleReportActions, sessionStartTime, revealDraftFromReportAction(persistedDraftReportAction); }, [draftReportAction, isDraftPendingCompletion, persistedDraftReportAction, revealDraftFromReportAction]); + const unreadMarkerListIndex = shouldCollapseSystemMessages ? unreadMarkerReportActionIndex : listData.findIndex((action) => action.reportActionID === unreadMarkerReportActionID); + return { renderedVisibleReportActions, + reportActionsToRender, listData, canonicalIndexByReportActionID, runsByAnchorReportActionID, @@ -91,7 +106,6 @@ function useReportActionsListData({sortedVisibleReportActions, sessionStartTime, onExpandedSystemMessageReady, draftMessageHTML, draftReportActionID, - draftAutoScrollKey, isDraftPendingCompletion, }; } diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListRenderItem.tsx b/src/pages/inbox/report/ReportActionsList/useReportActionsListRenderItem.tsx index a3df5eb32970..e7e7db834c99 100644 --- a/src/pages/inbox/report/ReportActionsList/useReportActionsListRenderItem.tsx +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListRenderItem.tsx @@ -1,7 +1,6 @@ import useOnyx from '@hooks/useOnyx'; import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import {shouldKeepBillPayActorHeader} from '@libs/BillPayReportActionUtils'; import {isConsecutiveChronosAutomaticTimerAction} from '@libs/ChronosUtils'; @@ -35,7 +34,7 @@ import {isLinkedToBillSelector} from '@src/selectors/ReportNameValuePairs'; import type * as OnyxTypes from '@src/types/onyx'; import getEmptyArray from '@src/types/utils/getEmptyArray'; -import type {ListRenderItemInfo} from '@shopify/flash-list'; +import type {LegendListRenderItemProps} from '@legendapp/list/react-native'; import {getCustomAgentAccountIDs} from '@selectors/AgentZeroChat'; import {accountIDSelector} from '@selectors/Session'; @@ -44,6 +43,7 @@ import React from 'react'; import type {UseReportActionsListRenderItemParams} from './types'; import ReportActionsListRow from './ReportActionsListRow'; +import SkeletonBelowLinkedAction from './SkeletonBelowLinkedAction'; /** Builds the report-actions row renderer and the `extraData` values that must re-render rows when they change. */ function useReportActionsListRenderItem({ @@ -53,6 +53,7 @@ function useReportActionsListRenderItem({ isOffline, sortedReportActions, renderedVisibleReportActions, + reportActionsToRender, listData, canonicalIndexByReportActionID, runsByAnchorReportActionID, @@ -66,8 +67,10 @@ function useReportActionsListRenderItem({ unreadMarkerReportActionID, linkedReportActionID, onShowPreviousMessages, + shouldShowSkeletonBelowLinkedAction, + hasRowsBelowLinkedAction, + hideSkeletonBelowLinkedAction, }: UseReportActionsListRenderItemParams) { - const {shouldUseNarrowLayout} = useResponsiveLayout(); const { report, hasNewerActions, @@ -129,27 +132,31 @@ function useReportActionsListRenderItem({ return isExpenseReport(report) || isIOUReport(report) || isInvoiceReport(report); })(); - const renderItem = ({item: reportAction, index}: ListRenderItemInfo) => { + const renderItem = ({item: reportAction, index}: LegendListRenderItemProps) => { const canonicalIndex = canonicalIndexByReportActionID.get(reportAction.reportActionID) ?? index; const systemMessageRun = runsByAnchorReportActionID.get(reportAction.reportActionID); - const previousReportAction = listData.at(index + 1); + const previousReportAction = index > 0 ? listData.at(index - 1) : undefined; const previousRun = previousReportAction ? runsByAnchorReportActionID.get(previousReportAction.reportActionID) : undefined; const isAfterCollapsedRun = !!previousRun && !previousRun.isExpanded; const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID; const collapsedSystemMessageRun = systemMessageRun && !systemMessageRun.isExpanded ? systemMessageRun : undefined; - - return ( + const collapsedRunUnreadMarkerReportActionID = + unreadMarkerReportActionID && collapsedSystemMessageRun?.reportActionIDs.includes(unreadMarkerReportActionID) && (!shouldUseThreadDividerLine || !shouldHideThreadDividerLine) + ? unreadMarkerReportActionID + : undefined; + const shouldGroupWithPreviousActor = + !isAfterCollapsedRun && + !shouldKeepBillPayActorHeader(reportAction, previousReportAction, reportStable, isInvoiceLinkedToBill) && + !isConsecutiveChronosAutomaticTimerAction(reportActionsToRender, canonicalIndex, chatIncludesChronosWithID(reportAction?.reportID), isOffline) && + isConsecutiveActionMadeByPreviousActor(reportActionsToRender, canonicalIndex, isOffline); + + const row = ( 1} + shouldDisplayReplyDivider={reportActionsToRender.length > 1} isFirstVisibleReportAction={firstVisibleReportActionID === reportAction.reportActionID} isLatestConciergeFeedbackAction={latestConciergeFeedbackActionIDs.includes(reportAction.reportActionID)} reportsParentHierarchy={reportsParentHierarchy} @@ -179,12 +181,27 @@ function useReportActionsListRenderItem({ shouldDisableContextMenuForConciergeDraft={shouldDisableContextMenuForConciergeDraft} /> ); + if (reportAction.reportActionID !== linkedReportActionID) { + return row; + } + // The linked row always renders as a fragment, so removing the skeleton does not remount the row. + return ( + <> + {row} + {shouldShowSkeletonBelowLinkedAction && ( + + )} + + ); }; - // Native mobile does not render updates flatlist the changes even though component did update called. - // To notify there something changes we can use extraData prop to flatlist + // Ensure row presentation updates when these values change without changing the action data. const extraData = [ - shouldUseNarrowLayout ? unreadMarkerReportActionID : undefined, + unreadMarkerReportActionID, isArchivedNonExpenseReport(report, isReportArchived), draftReportActionID, draftMessageHTML, @@ -192,6 +209,8 @@ function useReportActionsListRenderItem({ latestConciergeFeedbackActionIDs, expandedSystemMessageReportActionIDs, focusRequestedReportActionID, + shouldShowSkeletonBelowLinkedAction, + hasRowsBelowLinkedAction, ]; return {renderItem, extraData}; diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts index e4de34fece9d..788bd1f8560d 100644 --- a/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListScroll.ts @@ -1,11 +1,13 @@ +/** Owns LegendList positioning, end following, pagination, and scroll events for report actions. */ +import allowLegendListItemOverflow from '@components/LegendList/allowLegendListItemOverflow'; + +import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents'; +import useReportActionsPaginationScroll from '@hooks/useReportActionsPaginationScroll'; import useReportActionsScroll from '@hooks/useReportActionsScroll'; import useReportScrollManager from '@hooks/useReportScrollManager'; -import useWindowDimensions from '@hooks/useWindowDimensions'; - -import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; -import TransitionTracker from '@libs/Navigation/TransitionTracker'; -import {useActionListRef} from '@pages/inbox/ActionListContext'; +import {useActionListContext, useActionListRef} from '@pages/inbox/ActionListContext'; +import type {ActionListRef} from '@pages/inbox/ActionListTypes'; import {useReportActionsListActions, useReportActionsListState} from '@pages/inbox/report/ReportActionsListContext'; import useFollowActionBadgeTarget from '@pages/inbox/report/useFollowActionBadgeTarget'; import useScrollToEditingReportAction from '@pages/inbox/report/useScrollToEditingReportAction'; @@ -13,49 +15,44 @@ import useScrollToEditingReportAction from '@pages/inbox/report/useScrollToEditi import CONST from '@src/CONST'; import type * as OnyxTypes from '@src/types/onyx'; -import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ViewToken} from 'react-native'; +import type {LegendListRef, OnViewableItemsChangedInfo} from '@legendapp/list/react-native'; +import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native'; -import {useEffect, useRef} from 'react'; +import {useEffect, useImperativeHandle, useRef, useState} from 'react'; -import type {UseReportActionsListScrollParams} from './types'; +import type {ReportActionsListScrollMetrics, UseReportActionsListScrollParams} from './types'; -/** - * Create a unique key for each action in the FlatList. - * We use the reportActionID that is a string representation of a random 64-bit int, which should be - * random enough to avoid collisions - */ -function keyExtractor(item: OnyxTypes.ReportAction): string { - // A report has exactly one CREATED action. Using a stable key lets FlashList recycle the same cell - // when the optimistic CREATED is swapped for the server one, avoiding a remount-induced scroll jump. - if (item.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { - return CONST.REPORT.ACTIONS.TYPE.CREATED; - } - return item.reportActionID; -} +import {getReportActionItemType, getReportActionKey} from './reportActionListUtils'; -/** Owns initial positioning, pagination, unread tracking, and scroll events for the report-actions list. */ function useReportActionsListScroll({ reportID, sortedVisibleReportActions, renderedVisibleReportActions: listData, reportActionIDToDisplayIndex, - hasScrolledOverThreshold, markNewestActionAsRead, completeSkippedMarkAsRead, unreadMarkerReportActionID, unreadMarkerReportActionIndex, - draftAutoScrollKey, focusRequestedReportActionID, actionBadgeTargetID, - linkedReportActionID, + isOffline, + linkedReportActionID: reportActionIDFromRoute, onScrolledOverThresholdChange, onLayout, }: UseReportActionsListScrollParams) { - const {windowHeight} = useWindowDimensions(); const { report, hasOnceLoadedReportActions, + isInitialReportLoadPending, + isLoadingOlderReportActions, + hasLoadingOlderReportActionsError, + isLoadingNewerReportActions, + hasLoadingNewerReportActionsError, + hasOlderActions, hasNewerActions, + olderReportActionsRequestCursor, + newerReportActionsRequestCursor, + canLoadNewerChats, sortedAllReportActions, oldestUnreadReportAction, transactionThreadReport, @@ -63,14 +60,102 @@ function useReportActionsListScroll({ treatAsNoPaginationAnchor, isConciergeHiddenHistory, showFullHistory, + allReportActionIDs, } = useReportActionsListState(); - const {setTreatAsNoPaginationAnchor, loadOlderChats, loadNewerChats} = useReportActionsListActions(); + const {setTreatAsNoPaginationAnchor, loadOlderChats, loadNewerChats, handleShowPreviousMessages} = useReportActionsListActions(); const showHiddenHistory = isConciergeHiddenHistory && !showFullHistory; + const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true}); - // Remount the list when the deep-linked message or unread anchor changes (scroll positioning), or when the report changes. - const listID = [reportID, linkedReportActionID, hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID].join(':'); + // Clearing a link after sending returns to the live tail of the same list. Only opening a link + // needs fresh initial positioning, including reopening the same action after its link was cleared. + const [linkedActionListState, setLinkedActionListState] = useState({reportActionID: reportActionIDFromRoute, version: 0}); + if (linkedActionListState.reportActionID !== reportActionIDFromRoute) { + setLinkedActionListState({ + reportActionID: reportActionIDFromRoute, + version: linkedActionListState.version + (reportActionIDFromRoute ? 1 : 0), + }); + } + + // OpenReport can first provide a tiny cached page and then replace it with the hydrated page. Remounting + // gives the complete dataset a fresh initial layout so initialScrollAtEnd targets its actual end. + const listID = [ + reportID, + linkedActionListState.version, + hasOnceLoadedReportActions ? 'hydrated' : 'initial', + hasOnceLoadedReportActions ? undefined : oldestUnreadReportAction?.reportActionID, + ].join(':'); + const {getScrollOffset, scrollOffsetRef} = useActionListContext(); const listRef = useActionListRef(); + const legendListRef = useRef(null); + const headerSizeRef = useRef(0); + + useImperativeHandle( + listRef, + (): ActionListRef => ({ + getScrollableNode: (): unknown => legendListRef.current?.getScrollableNode(), + getState: () => { + const state = legendListRef.current?.getState(); + return state ? {...state, headerSize: headerSizeRef.current} : undefined; + }, + computeVisibleIndices: () => { + const state = legendListRef.current?.getState(); + return state ? {startIndex: state.start, endIndex: state.end} : undefined; + }, + scrollToEnd: (options) => { + legendListRef.current?.scrollToEnd(options); + }, + scrollToIndex: (options) => { + legendListRef.current?.scrollToIndex(options); + }, + scrollToOffset: (options) => { + legendListRef.current?.scrollToOffset(options); + }, + }), + [], + ); + + const shouldRestoreTailAfterHistoryRevealRef = useRef(false); + const oldestLoadedActionID = allReportActionIDs.at(-1); + const oldestLoadedActionIDAtHistoryRevealRef = useRef(undefined); + const distanceFromBottomRef = useRef(getScrollOffset()); + const shouldFollowEndOnResizeRef = useRef(false); + const onShowPreviousMessages = () => { + // Revealing older actions can make a short session scrollable before the requested history page arrives. + // Follow the tail only when the reader was actually there; the action-visible threshold is much wider. + shouldRestoreTailAfterHistoryRevealRef.current = distanceFromBottomRef.current <= 1 && !hasNewerActions; + const shouldWaitForHistoryPage = shouldRestoreTailAfterHistoryRevealRef.current && hasOlderActions; + oldestLoadedActionIDAtHistoryRevealRef.current = shouldWaitForHistoryPage ? oldestLoadedActionID : undefined; + if (shouldRestoreTailAfterHistoryRevealRef.current) { + shouldFollowEndOnResizeRef.current = true; + } + handleShowPreviousMessages(); + }; + + useEffect(() => { + if (!showFullHistory) { + shouldRestoreTailAfterHistoryRevealRef.current = false; + return; + } + if (!shouldRestoreTailAfterHistoryRevealRef.current) { + return; + } + const animationFrame = requestAnimationFrame(() => { + const shouldSkipTailRestoration = !shouldRestoreTailAfterHistoryRevealRef.current || !shouldFollowEndOnResizeRef.current; + if (shouldSkipTailRestoration) { + return; + } + legendListRef.current?.scrollToEnd({animated: false}); + const hasReceivedHistoryPage = oldestLoadedActionID !== oldestLoadedActionIDAtHistoryRevealRef.current || !hasOlderActions; + const hasFinishedHistoryReveal = + !oldestLoadedActionIDAtHistoryRevealRef.current || (!isLoadingOlderReportActions && (hasReceivedHistoryPage || hasLoadingOlderReportActionsError)); + if (hasFinishedHistoryReveal) { + shouldRestoreTailAfterHistoryRevealRef.current = false; + } + }); + return () => cancelAnimationFrame(animationFrame); + }, [showFullHistory, listData.length, oldestLoadedActionID, hasOlderActions, isLoadingOlderReportActions, hasLoadingOlderReportActionsError]); + const reportScrollManager = useReportScrollManager(); // Which rows are on screen, tracked so an edit started on a message the user can already see doesn't move the list. @@ -78,7 +163,8 @@ function useReportActionsListScroll({ const expandedFocusIndex = focusRequestedReportActionID ? (reportActionIDToDisplayIndex.get(focusRequestedReportActionID) ?? -1) : -1; useEffect(() => { - if (expandedFocusIndex < 0 || viewableRowIndexesRef.current.has(expandedFocusIndex)) { + const shouldSkipExpandedActionScroll = expandedFocusIndex < 0 || viewableRowIndexesRef.current.has(expandedFocusIndex); + if (shouldSkipExpandedActionScroll) { return; } @@ -102,11 +188,10 @@ function useReportActionsListScroll({ }, }); - // Find the index of the action badge target in the rendered actions list (which is what the FlatList uses as data) + // Find the index of the action badge target in the chronological data rendered by LegendList. const actionBadgeTargetIndex = actionBadgeTargetID ? (reportActionIDToDisplayIndex.get(actionBadgeTargetID) ?? listData.findIndex((action) => action.reportActionID === actionBadgeTargetID)) : -1; - const { trackVerticalScrolling, onViewableItemsChanged, @@ -114,14 +199,19 @@ function useReportActionsListScroll({ isActionBadgeAboveViewport, scrollToBottomAndMarkReportAsRead, scrollToActionBadgeTarget, - flushPendingScrollToBottom, shouldBeAlignedToTop, - shouldFocusToTopOnMount, initialScrollIndex, initialScrollIndexParams, - maintainVisibleContentPosition, + shouldShowNewerActionsSkeleton, + shouldShowSkeletonBelowLinkedAction, + hasRowsBelowLinkedAction, + hideSkeletonBelowLinkedAction, onLoad, + onItemSizeChanged, + trackLinkedMessageScroll, + stopLinkedMessagePositioning, } = useReportActionsScroll({ + listID, reportID, report, transactionThreadReport, @@ -129,48 +219,171 @@ function useReportActionsListScroll({ sortedVisibleReportActions, renderedVisibleReportActions: listData, reportActionIDToDisplayIndex, - keyExtractor, - hasScrolledOverThreshold, + keyExtractor: getReportActionKey, markNewestActionAsRead, completeSkippedMarkAsRead, unreadMarkerReportActionID, unreadMarkerReportActionIndex, hasNewerActions, - draftAutoScrollKey, actionBadgeTargetIndex, sortedAllReportActionsForPagination: sortedAllReportActions ?? [], treatAsNoPaginationAnchor, setTreatAsNoPaginationAnchor, }); - const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { - trackVerticalScrolling(event); - onScrolledOverThresholdChange(event.nativeEvent.contentOffset.y >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + const [loadedInitialViewportListID, setLoadedInitialViewportListID] = useState(); + // Cover every initial LegendList layout, including cached chats, until the rows are positioned. + // Offline reports still need their cached rows positioned, but cannot wait for OpenReport. + const shouldShowInitialViewportSkeleton = loadedInitialViewportListID !== listID || (!isOffline && isInitialReportLoadPending); + + const [newerFooterHeight, setNewerFooterHeight] = useState(0); + const canPaginateOlder = !isOffline && !!hasOnceLoadedReportActions && hasOlderActions && !showHiddenHistory; + const canPaginateNewer = !isOffline && !!hasOnceLoadedReportActions && hasNewerActions; + const shouldShowOlderPaginationLoadingIndicator = canPaginateOlder && !!isLoadingOlderReportActions && !hasLoadingOlderReportActionsError; + // The newer-actions skeleton already shows that the page below a linked message is loading. + const shouldShowNewerPaginationLoadingIndicator = canPaginateNewer && !!isLoadingNewerReportActions && !hasLoadingNewerReportActionsError && !shouldShowNewerActionsSkeleton; + // Pagination boundaries are measured from the messages, so loading indicators and footer content don't count toward them. + const olderPaginationExtent = shouldShowOlderPaginationLoadingIndicator ? CONST.PAGINATION_LOADING_INDICATOR_HEIGHT : 0; + const newerPaginationExtent = canPaginateNewer ? newerFooterHeight + (shouldShowNewerPaginationLoadingIndicator ? CONST.PAGINATION_LOADING_INDICATOR_HEIGHT : 0) : 0; + + const {onScroll: checkPaginationOnScroll, onContentSizeChange: checkPaginationOnContentSizeChange} = useReportActionsPaginationScroll({ + reportID, + linkedReportActionID: reportActionIDFromRoute, + listRef: legendListRef, + olderPaginationExtent, + newerPaginationExtent, + olderCursor: olderReportActionsRequestCursor, + newerCursor: newerReportActionsRequestCursor, + hasOlderActions, + hasNewerActions, + isLoadingOlderReportActions: !!isLoadingOlderReportActions, + isLoadingNewerReportActions: !!isLoadingNewerReportActions, + hasLoadingOlderReportActionsError: !!hasLoadingOlderReportActionsError, + hasLoadingNewerReportActionsError: !!hasLoadingNewerReportActionsError, + isOffline, + canLoadOlder: !showHiddenHistory && loadedInitialViewportListID === listID, + canLoadNewer: canLoadNewerChats && loadedInitialViewportListID === listID, + loadOlderActions: () => loadOlderChats(false), + loadNewerActions: () => loadNewerChats(false), + }); + + const readyEndListIDRef = useRef(undefined); + const lastScrollMetricsRef = useRef(undefined); + const endCorrectionFrameRef = useRef(undefined); + const userScrolledListIDRef = useRef(undefined); + const userDraggedListIDRef = useRef(undefined); + + const stopFollowingEnd = () => { + userScrolledListIDRef.current = listID; + shouldFollowEndOnResizeRef.current = false; + shouldRestoreTailAfterHistoryRevealRef.current = false; + if (endCorrectionFrameRef.current === undefined) { + return; + } + cancelAnimationFrame(endCorrectionFrameRef.current); + endCorrectionFrameRef.current = undefined; }; - const trackViewableRowIndexes = (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => { - viewableRowIndexesRef.current = new Set(info.viewableItems.map((token) => token.index).filter((index) => index !== null)); - onViewableItemsChanged(info); + const stopAutomaticPositioning = () => { + userDraggedListIDRef.current = listID; + stopLinkedMessagePositioning(); + stopFollowingEnd(); }; - const loadOlderChatsOnEndReached = () => { - if (showHiddenHistory) { + const scheduleEndCorrection = () => { + const shouldSkipEndCorrection = readyEndListIDRef.current !== listID || !shouldFollowEndOnResizeRef.current || hasNewerActions || endCorrectionFrameRef.current !== undefined; + if (shouldSkipEndCorrection) { return; } - loadOlderChats(false); + endCorrectionFrameRef.current = requestAnimationFrame(() => { + endCorrectionFrameRef.current = undefined; + const shouldFollowListEnd = readyEndListIDRef.current === listID && shouldFollowEndOnResizeRef.current; + if (shouldFollowListEnd) { + legendListRef.current?.scrollToEnd({animated: false}); + } + }); }; - const loadNewerChatsAfterTransitions = () => { - if (!isSearchTopmostFullScreenRoute()) { - loadNewerChats(false); - return; + useEffect( + () => () => { + if (endCorrectionFrameRef.current === undefined) { + return; + } + cancelAnimationFrame(endCorrectionFrameRef.current); + endCorrectionFrameRef.current = undefined; + }, + [listID], + ); + + const updateVisibleItemOverflow = (info: OnViewableItemsChangedInfo) => { + viewableRowIndexesRef.current = new Set(info.viewableItems.map((token) => token.index)); + onViewableItemsChanged(info); + for (const item of info.changed) { + if (!item.isViewable) { + continue; + } + allowLegendListItemOverflow(legendListRef.current, item.index); + } + }; + + const markInitialViewportReady = () => { + // onLoad only means rows rendered; keep their estimated positions covered until initial scrolling settles. + onLoad(); + setLoadedInitialViewportListID(listID); + readyEndListIDRef.current = listID; + // An unread marker near the newest action lands at the end. Rows above it are still measured after + // onReady, so keep that end in view instead of exposing older messages. + const isUnreadInitialScroll = initialScrollIndex !== undefined && !reportActionIDFromRoute && !shouldBeAlignedToTop; + const didLandAtEnd = initialScrollIndex === undefined || (isUnreadInitialScroll && !!legendListRef.current?.getState()?.isAtEnd); + shouldFollowEndOnResizeRef.current = didLandAtEnd && !hasNewerActions && userScrolledListIDRef.current !== listID; + lastScrollMetricsRef.current = undefined; + // The final row can grow after LegendList's initial end estimate (for example, Concierge feedback). + scheduleEndCorrection(); + }; + + const trackScrollPositionAndThreshold = (event: NativeSyntheticEvent) => { + trackLinkedMessageScroll(event); + const {contentOffset, contentSize, layoutMeasurement} = event.nativeEvent; + const distanceFromBottom = Math.max(0, contentSize.height - layoutMeasurement.height - contentOffset.y); + distanceFromBottomRef.current = distanceFromBottom; + const previousMetrics = lastScrollMetricsRef.current; + const canFollowListEnd = readyEndListIDRef.current === listID && !hasNewerActions; + if (canFollowListEnd) { + if (distanceFromBottom < CONST.REPORT.ACTIONS.AUTOSCROLL_TO_TOP_THRESHOLD) { + // A list opened at an unread or linked message can settle near the end. Following it from there would + // push that message out of view as rows resize, so wait until the list reaches the end or the user drags. + const shouldFollowResizingEnd = initialScrollIndex === undefined || distanceFromBottom <= 1 || userDraggedListIDRef.current === listID; + if (shouldFollowResizingEnd) { + shouldFollowEndOnResizeRef.current = true; + } + } else { + const hasReaderMovedAwayFromEnd = + (previousMetrics && contentOffset.y < previousMetrics.offset - 1) || + (previousMetrics && + previousMetrics.contentHeight === contentSize.height && + previousMetrics.viewportHeight === layoutMeasurement.height && + endCorrectionFrameRef.current === undefined); + if (hasReaderMovedAwayFromEnd) { + // A pending history request must not reclaim the end after the reader has moved away. + stopFollowingEnd(); + } + } } + lastScrollMetricsRef.current = {offset: contentOffset.y, contentHeight: contentSize.height, viewportHeight: layoutMeasurement.height}; - TransitionTracker.runAfterTransitions({ - callback: () => { - requestAnimationFrame(() => loadNewerChats(false)); + checkPaginationOnScroll(); + + const bottomRelativeEvent = { + ...event, + nativeEvent: { + ...event.nativeEvent, + contentOffset: {...contentOffset, y: distanceFromBottom}, }, - }); + }; + + trackVerticalScrolling(bottomRelativeEvent); + onScrolledOverThresholdChange(distanceFromBottom >= CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); + emitComposerScrollEvents(); }; useFollowActionBadgeTarget({ @@ -182,38 +395,65 @@ function useReportActionsListScroll({ scrollToActionBadgeTarget, }); + // Keep bottom-relative scroll metrics current when the viewport is resized. + const recordTimeToMeasureItemLayout = (event: LayoutChangeEvent) => { + onLayout?.(event); + const state = legendListRef.current?.getState(); + const canMeasureBottomOffset = state && readyEndListIDRef.current === listID; + if (canMeasureBottomOffset) { + const distanceFromBottom = Math.max(0, state.contentLength - state.scrollLength - state.scroll); + scrollOffsetRef.current = distanceFromBottom; + distanceFromBottomRef.current = distanceFromBottom; + } + scheduleEndCorrection(); + // A resized viewport changes the pagination threshold. + checkPaginationOnScroll(); + }; + + const trackContentSizeChange = () => { + trackVerticalScrolling(undefined); + scheduleEndCorrection(); + checkPaginationOnContentSizeChange(); + }; + + const updateNewerFooterHeight = (event: LayoutChangeEvent) => { + setNewerFooterHeight(event.nativeEvent.layout.height); + }; + return { listID, + loadedInitialViewportListID, + shouldShowInitialViewportSkeleton, + shouldShowNewerActionsSkeleton, + shouldShowSkeletonBelowLinkedAction, + hasRowsBelowLinkedAction, + hideSkeletonBelowLinkedAction, shouldBeAlignedToTop, + onShowPreviousMessages, isFloatingMessageCounterVisible, isActionBadgeAboveViewport, scrollToBottomAndMarkReportAsRead, scrollToActionBadgeTarget, - listProps: { - ref: listRef, - keyExtractor, - getItemType: (item: OnyxTypes.ReportAction) => item.actionName, - onEndReached: loadOlderChatsOnEndReached, - onEndReachedThreshold: 0.75, - onStartReached: loadNewerChatsAfterTransitions, - onStartReachedThreshold: 0.75, - onLayout: (event: LayoutChangeEvent) => { - onLayout?.(event); - flushPendingScrollToBottom(); - }, + shouldShowOlderPaginationLoadingIndicator, + shouldShowNewerPaginationLoadingIndicator, + onNewerFooterLayout: updateNewerFooterHeight, + legendListProps: { + ref: legendListRef, + keyExtractor: getReportActionKey, + getItemType: getReportActionItemType, + onLayout: recordTimeToMeasureItemLayout, onScroll: trackScrollPositionAndThreshold, - onViewableItemsChanged: trackViewableRowIndexes, - overrideProps: { - isInvertedVirtualizedList: true, - contentOffset: shouldFocusToTopOnMount ? {x: 0, y: windowHeight} : undefined, - }, - initialScrollIndex, - initialScrollIndexParams, - maintainVisibleContentPosition, - onLoad, - onContentSizeChange: () => { - trackVerticalScrolling(undefined); - flushPendingScrollToBottom(); + onScrollBeginDrag: stopAutomaticPositioning, + estimatedHeaderSize: olderPaginationExtent, + onViewableItemsChanged: updateVisibleItemOverflow, + initialScrollAtEnd: initialScrollIndex === undefined, + initialScrollIndex: initialScrollIndex === undefined ? undefined : {index: initialScrollIndex, ...initialScrollIndexParams}, + alignItemsAtEnd: !shouldBeAlignedToTop, + onReady: markInitialViewportReady, + onItemSizeChanged, + onContentSizeChange: trackContentSizeChange, + onMetricsChange: ({headerSize}: {headerSize: number}) => { + headerSizeRef.current = headerSize; }, }, }; diff --git a/src/pages/inbox/report/ReportActionsList/useReportActionsListTelemetry.ts b/src/pages/inbox/report/ReportActionsList/useReportActionsListTelemetry.ts index 230dc8d7737b..a93e56ff81ea 100644 --- a/src/pages/inbox/report/ReportActionsList/useReportActionsListTelemetry.ts +++ b/src/pages/inbox/report/ReportActionsList/useReportActionsListTelemetry.ts @@ -1,35 +1,26 @@ +/** Completes warm report spans once positioned content is visible, including when the report regains focus. */ import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import {useReportActionsListState} from '@pages/inbox/report/ReportActionsListContext'; -import type {LayoutChangeEvent} from 'react-native'; - -import {useEffect, useRef} from 'react'; +import {useIsFocused} from '@react-navigation/native'; +import {useEffect} from 'react'; import type {UseReportActionsListTelemetryParams} from './types'; -/** Returns the list layout handler, which completes the warm open-report span on the first layout. */ -function useReportActionsListTelemetry({reportID, onLayout}: UseReportActionsListTelemetryParams) { +function useReportActionsListTelemetry({reportID, listID, loadedInitialViewportListID, shouldShowInitialViewportSkeleton}: UseReportActionsListTelemetryParams) { + const isFocused = useIsFocused(); const {report} = useReportActionsListState(); - const didLayout = useRef(false); useEffect(() => { - didLayout.current = false; - }, [reportID]); - - /** - * Runs when the FlatList finishes laying out - */ - return (event: LayoutChangeEvent) => { - onLayout?.(event); - if (didLayout.current) { + const shouldWaitForVisibleViewport = !isFocused || shouldShowInitialViewportSkeleton || loadedInitialViewportListID !== listID; + if (shouldWaitForVisibleViewport) { return; } - - didLayout.current = true; - + // onReady positions the list, but only this committed render has removed its cover. + // Refocus also completes new spans when the positioned list stays mounted. markOpenReportEnd(reportID, report, {warm: true}); - }; + }, [isFocused, listID, loadedInitialViewportListID, report, reportID, shouldShowInitialViewportSkeleton]); } export default useReportActionsListTelemetry; diff --git a/src/pages/inbox/report/ReportActionsListContext.tsx b/src/pages/inbox/report/ReportActionsListContext.tsx index c29fb76a9049..3779b1479cc7 100644 --- a/src/pages/inbox/report/ReportActionsListContext.tsx +++ b/src/pages/inbox/report/ReportActionsListContext.tsx @@ -56,6 +56,7 @@ function computeReportActionsSkeletonState(readinessSignals: ReportActionsReadin isLoadingInitialReportActions, isLoadingApp, reportActionsLength, + hasReportActionPages, oldestUnreadReportAction, isSingleExpenseReport, isMissingReportActions, @@ -90,7 +91,7 @@ function computeReportActionsSkeletonState(readinessSignals: ReportActionsReadin const shouldShowSkeletonForInitialLoad = isReportLoadPending && (isReportDataIncomplete || isMissingReportActions) && !isOffline; - const shouldShowSkeletonForAppLoad = !!isLoadingApp && !isOffline; + const shouldShowSkeletonForAppLoad = !!isLoadingApp && !hasOnceLoadedReportActions && !isOffline; // Show skeleton for the Concierge side panel until report data has been loaded // at least once. Before the first openReport response, hasOlderActions is @@ -102,7 +103,18 @@ function computeReportActionsSkeletonState(readinessSignals: ReportActionsReadin // actions that arrives right after the initial load shifts the list and breaks the anchor to the linked action. const shouldShowSkeletonForLinkedMessageLoad = !!reportActionIDFromRoute && !isOffline && !hasOnceLoadedReportActions && isReportLoadPending; - const shouldShowInitialSkeleton = shouldShowSkeletonForConciergePanel || shouldShowSkeletonForInitialLoad || shouldShowSkeletonForAppLoad || shouldShowSkeletonForLinkedMessageLoad; + // Actions cached without a loaded page, such as the few that OpenApp returns after a cache clear, are scattered + // across the history. Showing them would open the chat at old messages that jump away once OpenReport loads the newest page. + // Gate from the first render, before OpenReport is queued: a list that mounts early marks the chat as read and loses its unread marker on remount. + const shouldShowSkeletonForUnpagedActions = + shouldKeepInitialReportLoadGated && !hasOnceLoadedReportActions && !hasReportActionPages && !report?.pendingFields?.createReport && !isOffline; + + const shouldShowInitialSkeleton = + shouldShowSkeletonForConciergePanel || + shouldShowSkeletonForInitialLoad || + shouldShowSkeletonForAppLoad || + shouldShowSkeletonForLinkedMessageLoad || + shouldShowSkeletonForUnpagedActions; const hasDerivedValueTimingIssue = reportActionsLength > 0 && isMissingReportActions; diff --git a/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx b/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx new file mode 100644 index 000000000000..8d32b0dfa440 --- /dev/null +++ b/src/pages/inbox/report/ReportActionsPaginationLoadingIndicator.tsx @@ -0,0 +1,39 @@ +import ActivityIndicator from '@components/ActivityIndicator'; + +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import React from 'react'; +import {View} from 'react-native'; + +type PaginationDirection = 'older' | 'newer'; + +type ReportActionsPaginationLoadingIndicatorProps = { + direction: PaginationDirection; +}; + +function ReportActionsPaginationLoadingIndicator({direction}: ReportActionsPaginationLoadingIndicatorProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const testID = `report-actions-pagination-${direction}`; + + return ( + + + + ); +} + +export default ReportActionsPaginationLoadingIndicator; +export type {PaginationDirection, ReportActionsPaginationLoadingIndicatorProps}; diff --git a/src/pages/inbox/report/shouldFollowActionBadgeTarget.ts b/src/pages/inbox/report/shouldFollowActionBadgeTarget.ts index 6686da21fd50..28808dccd1d7 100644 --- a/src/pages/inbox/report/shouldFollowActionBadgeTarget.ts +++ b/src/pages/inbox/report/shouldFollowActionBadgeTarget.ts @@ -5,19 +5,19 @@ type ShouldFollowActionBadgeTargetParams = { /** The report action the badge targeted on the previous render */ prevActionTargetReportActionID: string | undefined; - /** Index of the current target in the rendered (inverted) list, or -1 when it is not rendered */ + /** Index of the current target in the chronological list, or -1 when it is not rendered */ actionBadgeTargetIndex: number; - /** Index of the previous target in the rendered (inverted) list, or -1 when it is not rendered */ + /** Index of the previous target in the chronological list, or -1 when it is not rendered */ prevActionBadgeTargetIndex: number; }; /** * Decide whether to auto-scroll the report list to follow the action badge once its current target is resolved. * - * The list is inverted: index 0 is the newest action at the bottom and higher indexes are older actions at the top. The badge - * always targets the oldest actionable preview, so resolving it advances the target to a newer preview (a strictly lower index). - * We only follow when the target moves to a lower index, so we scroll downward to the next actionable preview and never jump + * The list is chronological: index 0 is the oldest action and higher indexes are newer actions. The badge always targets the + * oldest actionable preview, so resolving it advances the target to a newer preview (a strictly higher index). + * We only follow when the target moves to a higher index, so we scroll downward to the next actionable preview and never jump * upward/backward (e.g. when older actions are loaded in via pagination). */ function shouldFollowActionBadgeTarget({ @@ -29,7 +29,7 @@ function shouldFollowActionBadgeTarget({ if (!actionTargetReportActionID || !prevActionTargetReportActionID || actionTargetReportActionID === prevActionTargetReportActionID || actionBadgeTargetIndex < 0) { return false; } - return prevActionBadgeTargetIndex >= 0 && actionBadgeTargetIndex < prevActionBadgeTargetIndex; + return prevActionBadgeTargetIndex >= 0 && actionBadgeTargetIndex > prevActionBadgeTargetIndex; } export default shouldFollowActionBadgeTarget; diff --git a/src/pages/inbox/report/useFollowActionBadgeTarget.ts b/src/pages/inbox/report/useFollowActionBadgeTarget.ts index 185b3df4e053..40c6350fef98 100644 --- a/src/pages/inbox/report/useFollowActionBadgeTarget.ts +++ b/src/pages/inbox/report/useFollowActionBadgeTarget.ts @@ -15,10 +15,10 @@ type UseFollowActionBadgeTargetParams = { /** The report action the badge currently targets (the oldest preview still requiring action) */ actionTargetReportActionID: string | undefined; - /** Index of the current target in the rendered (inverted) list, or -1 when it is not rendered */ + /** Index of the current target in the chronological list, or -1 when it is not rendered */ actionBadgeTargetIndex: number; - /** The rendered (inverted) report actions the list is displaying */ + /** The chronological report actions the list is displaying */ renderedVisibleReportActions: OnyxTypes.ReportAction[]; /** Maps canonical action IDs to their rendered indices when collapsed runs hide individual actions */ diff --git a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts index bd0d902b1255..3090c6be035d 100644 --- a/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts +++ b/src/pages/inbox/report/useReportActionsNewActionLiveTail.ts @@ -47,7 +47,8 @@ type UseReportActionsNewActionLiveTailParams = { hasNewerActions: boolean; linkedReportActionID: string | undefined; hasNewestReportAction: boolean; - sortedVisibleReportActions: OnyxTypes.ReportAction[]; + /** Actions rendered by the list in chronological order. */ + renderedVisibleReportActions: OnyxTypes.ReportAction[]; sortedAllReportActionsForPagination: OnyxTypes.ReportAction[]; reportActionPages: OnyxTypes.Pages | undefined; setTreatAsNoPaginationAnchor: (value: boolean) => void; @@ -57,12 +58,12 @@ type UseReportActionsNewActionLiveTailParams = { }; type LiveTailJumpStage = 'idle' | 'open_report' | 'await_scroll' | 'await_prune'; +const PENDING_SENT_ACTION_TIMEOUT_MS = 5000; /** * Owns subscribe-to-new-action scrolling, live-tail jump (openReport → scroll → prune), and the - * deferred scroll + pagination prune after layout. Uses useEffectEvent for the Pusher subscription handler so it - * always sees the latest props without mirror refs. The layout-time prune step uses useCallback so callers can invoke - * it from list `onLayout` outside this hook. + * deferred scroll + pagination prune after the data render. Uses useEffectEvent for the Pusher subscription handler so it + * always sees the latest props without mirror refs. The prune callback completes the explicit scroll request in the caller. */ function useReportActionsNewActionLiveTail({ reportID, @@ -75,7 +76,7 @@ function useReportActionsNewActionLiveTail({ hasNewerActions, linkedReportActionID, hasNewestReportAction, - sortedVisibleReportActions, + renderedVisibleReportActions, sortedAllReportActionsForPagination, reportActionPages, setTreatAsNoPaginationAnchor, @@ -87,6 +88,22 @@ function useReportActionsNewActionLiveTail({ const isInSidePanel = useIsInSidePanel(); const liveTailJumpRef = useRef<{stage: LiveTailJumpStage}>({stage: 'idle'}); const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(false); + const [pendingScrollActionID, setPendingScrollActionID] = useState(); + const [scrollRequestReportID, setScrollRequestReportID] = useState(reportID); + if (scrollRequestReportID !== reportID) { + setScrollRequestReportID(reportID); + setIsScrollToBottomEnabled(false); + setPendingScrollActionID(undefined); + } + + const setShouldScrollToBottom = (enabled: boolean) => { + setIsScrollToBottomEnabled(enabled); + if (!enabled) { + setPendingScrollActionID(undefined); + } + }; + + const isPendingScrollActionRendered = !!pendingScrollActionID && renderedVisibleReportActions.some((item) => item.reportActionID === pendingScrollActionID); const openReportAtLiveTail = async () => { const [conciergeReportID, introSelected, onboarding, session, fullPolicySnapshotData] = await Onyx.multiGet([ @@ -111,12 +128,47 @@ function useReportActionsNewActionLiveTail({ }); }; - const scrollToBottomForCurrentUserAction = useEffectEvent((isFromCurrentUser: boolean, action?: OnyxTypes.ReportAction) => { + const requestLiveTailJump = () => { + if (liveTailJumpRef.current.stage !== 'idle') { + return; + } + if (isOffline) { + // Cached newest messages can be outside the linked page. Release that anchor without waiting for a network request. + liveTailJumpRef.current = {stage: 'await_scroll'}; + setTreatAsNoPaginationAnchor(true); + if (!isInSidePanel) { + navigation.setParams({reportActionID: ''}); + } + return; + } + liveTailJumpRef.current = {stage: 'open_report'}; + openReportAtLiveTail(); + }; + + useEffect(() => { + if (!pendingScrollActionID) { + return; + } + + // The optimistic row normally arrives immediately. A filtered or failed row must not + // scroll the reader much later when pagination or reconnecting makes it visible. + const timeout = setTimeout(() => setPendingScrollActionID(undefined), PENDING_SENT_ACTION_TIMEOUT_MS); + return () => clearTimeout(timeout); + }, [pendingScrollActionID]); + + const scrollToBottomForCurrentUserAction = useEffectEvent[1]>((isFromCurrentUser, action, source = 'local') => { TransitionTracker.runAfterTransitions({ callback: () => { // If a new comment is added and it's from the current user scroll to the bottom otherwise leave the user positioned where // they are now in the list. - if (!isFromCurrentUser || (!isReportTopmostSplitNavigator() && !Navigation.getReportRHPActiveRoute())) { + const shouldIgnoreActionOutsideActiveReport = !isFromCurrentUser || (!isReportTopmostSplitNavigator() && !Navigation.getReportRHPActiveRoute()); + if (shouldIgnoreActionOutsideActiveReport) { + return; + } + // A realtime echo can represent an edit or deletion, whereas local payload-less + // notifications from money requests still need the usual live-tail behavior. + const isRealtimeEventWithoutAction = !action && source === 'realtime'; + if (isRealtimeEventWithoutAction) { return; } @@ -128,39 +180,38 @@ function useReportActionsNewActionLiveTail({ (hasNewerActions || !!linkedReportActionID || (!!unreadMarkerReportActionID && !hasNewestReportAction)); if (shouldJumpToLiveTail) { - if (liveTailJumpRef.current.stage === 'idle') { - liveTailJumpRef.current = {stage: 'open_report'}; - openReportAtLiveTail(); - } + requestLiveTailJump(); return; } // Release the old pagination anchor so offline sends appear without waiting for openReport. - if (isOffline && linkedReportActionID && action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { + const shouldReleaseOfflineLink = isOffline && linkedReportActionID && action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; + if (shouldReleaseOfflineLink) { liveTailJumpRef.current = {stage: 'await_prune'}; setTreatAsNoPaginationAnchor(true); navigation.setParams({reportActionID: ''}); } - const index = sortedVisibleReportActions.findIndex((item) => item.reportActionID === action?.reportActionID); + const index = renderedVisibleReportActions.findIndex((item) => item.reportActionID === action?.reportActionID); if (action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { - if (index > 0) { - // Only the scrollToBottom() branches should arm the pending scroll-to-bottom flush. Arming it here too - // would queue a competing scrollToBottom() that fights the scrollToIndex() jump to the report preview. + setIsFloatingMessageCounterVisible(false); + const isPreviewBeforeNewestAction = index >= 0 && index < renderedVisibleReportActions.length - 1; + if (isPreviewBeforeNewestAction) { setTimeout(() => { reportScrollManager.scrollToIndex(index); }, 100); } else { - setIsFloatingMessageCounterVisible(false); reportScrollManager.scrollToBottom(); - setIsScrollToBottomEnabled(true); } if (action?.reportActionID) { setActionIdToHighlight(action.reportActionID); } } else { setIsFloatingMessageCounterVisible(false); - reportScrollManager.scrollToBottom(); - setIsScrollToBottomEnabled(true); + if (action?.reportActionID) { + setPendingScrollActionID(action.reportActionID); + } else { + setShouldScrollToBottom(true); + } } }, }); @@ -210,7 +261,9 @@ function useReportActionsNewActionLiveTail({ if (liveTailJumpRef.current.stage !== 'await_scroll') { return; } - if (!hasNewestReportAction) { + // Offline jumps can only reach the newest cached page, which may not contain the newest server message. + const shouldWaitForLiveTail = !treatAsNoPaginationAnchor || (!isOffline && !hasNewestReportAction); + if (shouldWaitForLiveTail) { return; } @@ -218,9 +271,9 @@ function useReportActionsNewActionLiveTail({ setIsFloatingMessageCounterVisible(false); // Defer so this effect does not synchronously chain a second render from setState (eslint react-hooks/set-state-in-effect). queueMicrotask(() => { - setIsScrollToBottomEnabled(true); + setShouldScrollToBottom(true); }); - }, [hasNewestReportAction, treatAsNoPaginationAnchor, setIsFloatingMessageCounterVisible]); + }, [hasNewestReportAction, isOffline, treatAsNoPaginationAnchor, setIsFloatingMessageCounterVisible, setShouldScrollToBottom]); useEffect(() => { // Why are we doing this, when in the cleanup of the useEffect we are already calling the unsubscribe function? @@ -250,10 +303,10 @@ function useReportActionsNewActionLiveTail({ }, [reportID]); return { - isScrollToBottomEnabled, - setIsScrollToBottomEnabled, + isScrollToBottomEnabled: isScrollToBottomEnabled || isPendingScrollActionRendered, + setIsScrollToBottomEnabled: setShouldScrollToBottom, completeLiveTailPruneAfterScrollToBottom, - openReportAtLiveTail, + requestLiveTailJump, }; } diff --git a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts index 7c9ed46aabe4..94fc9ba22f18 100644 --- a/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts +++ b/src/pages/inbox/report/useReportUnreadMessageScrollTracking.ts @@ -142,6 +142,11 @@ export default function useReportUnreadMessageScrollTracking({ } ref.current.previousViewableItems = viewableItems; + // Initial positioning reports provisional rows behind the cover. Replay the latest viewport when tracking resumes. + if (shouldDisablePillTracking) { + return; + } + const viewableIndexes = viewableItems.map((viewableItem) => viewableItem.index).filter((value) => typeof value === 'number'); if (viewableIndexes.length === 0) { @@ -168,12 +173,10 @@ export default function useReportUnreadMessageScrollTracking({ ref.current.onUnreadActionVisible(); } - // Track whether the action badge target is above the viewport (i.e., not visible and at a higher index in the inverted list) + // Track whether the action badge target is above the viewport. const badgeTargetIndex = ref.current.actionBadgeTargetIndex; if (badgeTargetIndex !== -1) { - // In an inverted list, higher indexes are "above" (older messages). The target is above the viewport - // when its index is greater than the max visible index. - // FlashList reports a snapshot taken up to 250ms earlier, which can predate the new row's measured layout, so prefer the live range + // Viewability snapshots can predate the new row's measured layout, so prefer the live range. const visibleRange = ref.current.getVisibleIndexRange?.(); const isAbove = isInverted ? badgeTargetIndex > (visibleRange?.endIndex ?? maxIndex) : badgeTargetIndex < (visibleRange?.startIndex ?? minIndex); setIsActionBadgeAboveViewport(isAbove); diff --git a/src/selectors/ReportMetaData.ts b/src/selectors/ReportMetaData.ts index 0bc8461f3cc8..bf7a633e916b 100644 --- a/src/selectors/ReportMetaData.ts +++ b/src/selectors/ReportMetaData.ts @@ -16,13 +16,25 @@ const reportActionsLoadingStateSelector = (loadingState: OnyxEntry, -): Pick | undefined => +): + | Pick< + ReportLoadingState, + | 'hasOnceLoadedReportActions' + | 'isLoadingInitialReportActions' + | 'isLoadingOlderReportActions' + | 'hasLoadingOlderReportActionsError' + | 'isLoadingNewerReportActions' + | 'hasLoadingNewerReportActionsError' + > + | undefined => loadingState ? { hasOnceLoadedReportActions: loadingState.hasOnceLoadedReportActions, isLoadingInitialReportActions: loadingState.isLoadingInitialReportActions, isLoadingOlderReportActions: loadingState.isLoadingOlderReportActions, hasLoadingOlderReportActionsError: loadingState.hasLoadingOlderReportActionsError, + isLoadingNewerReportActions: loadingState.isLoadingNewerReportActions, + hasLoadingNewerReportActionsError: loadingState.hasLoadingNewerReportActionsError, } : undefined; diff --git a/src/styles/utils/positioning.ts b/src/styles/utils/positioning.ts index dcdc6fedf79a..eb4a182c24f8 100644 --- a/src/styles/utils/positioning.ts +++ b/src/styles/utils/positioning.ts @@ -69,4 +69,7 @@ export default { bFull: { bottom: '100%', }, + tFull: { + top: '100%', + }, } satisfies Record; diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts index 383ac771e1ff..64ce4d9784de 100644 --- a/src/styles/utils/spacing.ts +++ b/src/styles/utils/spacing.ts @@ -767,6 +767,10 @@ export default { paddingTop: 40, }, + pt18: { + paddingTop: 72, + }, + pt20: { paddingTop: 80, }, diff --git a/tests/perf-test/ReportActionsList.perf-test.tsx b/tests/perf-test/ReportActionsList.perf-test.tsx index 734a60dde85b..4062aad07393 100644 --- a/tests/perf-test/ReportActionsList.perf-test.tsx +++ b/tests/perf-test/ReportActionsList.perf-test.tsx @@ -2,7 +2,6 @@ import {act, screen} from '@testing-library/react-native'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import type Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import {setHasRadio} from '@libs/NetworkState'; @@ -15,7 +14,9 @@ import ComposeProviders from '@src/components/ComposeProviders'; import {LocaleContextProvider} from '@src/components/LocaleContextProvider'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportAction, ReportActions} from '@src/types/onyx'; +import type {ReportActions} from '@src/types/onyx'; + +import type * as ReactNavigation from '@react-navigation/native'; import {NavigationContainer} from '@react-navigation/native'; import Onyx from 'react-native-onyx'; @@ -26,13 +27,11 @@ import * as TestHelper from '../utils/TestHelper'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; -const mockedNavigate = jest.fn(); - jest.mock('@react-navigation/native', () => { - const actualNav = jest.requireActual('@react-navigation/native'); + const actualNav = jest.requireActual('@react-navigation/native'); return { ...actualNav, - useRoute: () => mockedNavigate, + useRoute: () => ({key: 'report', name: 'Report', params: {reportID: '1'}}), useIsFocused: () => true, }; }); @@ -64,9 +63,20 @@ const signUpWithTestUser = () => { TestHelper.signInWithTestUser(TEST_USER_ACCOUNT_ID, TEST_USER_LOGIN); }; -const sortedReportActions = ReportTestUtils.getMockedSortedReportActions(500); -const reportActions: ReportActions = Object.fromEntries(sortedReportActions.map((action: ReportAction) => [action.reportActionID, action])); -const report = ReportTestUtils.createMockReport({reportID: REPORT_ID, lastVisibleActionCreated: sortedReportActions.at(0)?.created}); +function buildComment(index: number) { + const text = `Benchmark message ${index}`; + return ReportTestUtils.getFakeReportAction(index, { + actionName: index === 1 ? CONST.REPORT.ACTIONS.TYPE.CREATED : CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: TEST_USER_ACCOUNT_ID, + created: new Date(Date.UTC(2026, 0, 1, 0, 0, index)).toISOString().replace('T', ' ').replace('Z', ''), + isAttachmentOnly: false, + message: [{type: 'COMMENT', html: text, text}], + }); +} + +const actions = Array.from({length: 500}, (_unused, index) => buildComment(index + 1)); +const reportActions: ReportActions = Object.fromEntries(actions.map((action) => [action.reportActionID, action])); +const report = ReportTestUtils.createMockReport({reportID: REPORT_ID, lastVisibleActionCreated: actions.at(-1)?.created}); beforeEach(async () => { // Initialize the network key for OfflineWithFeedback @@ -81,6 +91,9 @@ beforeEach(async () => { // Seed the report under test: the report, its 500 actions, and a settled loading state. await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, report); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, reportActions); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${REPORT_ID}`, [ + [CONST.PAGINATION_START_ID, ...actions.map((action) => action.reportActionID).reverse(), CONST.PAGINATION_END_ID], + ]); await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, { isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true, @@ -119,10 +132,34 @@ function ReportActionsListWrapper() { ); } -test('[ReportActionsList] should render ReportActionsList with 500 reportActions stored', async () => { +// LegendList's behavior mock eagerly renders the supplied data, while the previous FlashList test used +// its virtualized implementation. Start a renderer-specific baseline instead of comparing those harnesses. +test('[ReportActionsList] should render LegendList with 500 reportActions stored', async () => { + // Given a settled report whose complete 500-action page is stored in Onyx. const scenario = async () => { + // When the report list mounts, then its final comment must actually render. await screen.findByTestId('report-actions-list'); + await screen.findByText('Benchmark message 500'); }; await waitForBatchedUpdates(); await measureRenders(, {scenario}); }); + +test('[ReportActionsList] should render a new comment in a report with 500 stored actions', async () => { + // Given a mounted report with 500 stored actions. + let run = 0; + const scenario = async () => { + await screen.findByTestId('report-actions-list'); + // When a new comment arrives, update the real Onyx collection and the report's latest timestamp. + // Reassure repeats this scenario, so every run must append a new action to the continuous chain. + const action = buildComment(501 + run++); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[action.reportActionID]: action}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {lastVisibleActionCreated: action.created}); + await waitForBatchedUpdates(); + }); + // Then the new comment renders before this measurement completes. + await screen.findByText(`Benchmark message ${action.reportActionID}`); + }; + await measureRenders(, {scenario}); +}); diff --git a/tests/perf-test/ReportActionsListRowWrites.perf-test.tsx b/tests/perf-test/ReportActionsListRowWrites.perf-test.tsx index 6779a76506a4..f50e0d52815a 100644 --- a/tests/perf-test/ReportActionsListRowWrites.perf-test.tsx +++ b/tests/perf-test/ReportActionsListRowWrites.perf-test.tsx @@ -5,6 +5,7 @@ import OnyxListItemProvider from '@components/OnyxListItemProvider'; import type Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import {setHasRadio} from '@libs/NetworkState'; +import {getSortedReportActionsForDisplay} from '@libs/ReportActionsUtils'; import {ActionListContext} from '@pages/inbox/ActionListContext'; import {ReactionListContext} from '@pages/inbox/ReactionListContext'; @@ -69,12 +70,15 @@ const signUpWithTestUser = () => { const sortedReportActions = ReportTestUtils.getMockedSortedReportActions(500); const reportActions: ReportActions = Object.fromEntries(sortedReportActions.map((action: ReportAction) => [action.reportActionID, action])); +const newestPageActionIDs = getSortedReportActionsForDisplay(reportActions, true, true) + .slice(0, 50) + .map((action) => action.reportActionID); const POLICY_ID = 'P1'; const report = ReportTestUtils.createMockReport({reportID: REPORT_ID, policyID: POLICY_ID, lastVisibleActionCreated: sortedReportActions.at(0)?.created}); const WRITES_PER_SCENARIO = 10; -// All mock actions share one timestamp, so the initial window is ordered by reportActionID as a string and starts at action 1, then 10.. -const VISIBLE_ACTION_ID = '10'; +// All mock actions share one timestamp, so the newest page starts with the lexicographically highest action ID. +const VISIBLE_ACTION_ID = '99'; beforeEach(async () => { global.fetch = TestHelper.getGlobalFetchMock(); @@ -98,6 +102,8 @@ beforeEach(async () => { outputCurrency: CONST.CURRENCY.USD, }); await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, reportActions); + // Keep all 500 cached actions, but bound the rendered page because LegendList's Jest mock renders every supplied row. + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${REPORT_ID}`, [[CONST.PAGINATION_START_ID, ...newestPageActionIDs]]); await Onyx.set(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, { isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true, @@ -136,7 +142,7 @@ function ReportActionsListWrapper() { ); } -async function measureWrites(write: (run: number, index: number) => Promise) { +async function measureWrites(write: (run: number, index: number) => Promise, verifyWrite?: (run: number, index: number) => Promise) { // Reassure runs the scenario several times against one seeded Onyx, so every run writes a value that doesn't exist yet let run = 0; const scenario = async () => { @@ -147,6 +153,7 @@ async function measureWrites(write: (run: number, index: number) => Promise Promise `2030-01-01 00:${String(run % 60).padStart(2, '0')}:${String(index).padStart(2, '0')}.000`; -test('[ReportActionsList] should re-render when one visible action is edited', async () => { +// The paginated LegendList fixture needs its own baseline instead of comparing against FlashList's virtualized fixture. +test('[ReportActionsList LegendList] should re-render when one visible action is edited', async () => { // Given 500 actions in the report, when the message of one visible action is edited repeatedly // Then reassure measures what an edit of one action costs the list - await measureWrites((run, index) => - Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[VISIBLE_ACTION_ID]: {message: [{type: 'TEXT', html: `edited ${run}-${index}`, text: `edited ${run}-${index}`}]}}), + await measureWrites( + (run, index) => + Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, { + [VISIBLE_ACTION_ID]: {message: [{type: 'TEXT', html: `edited ${run}-${index}`, text: `edited ${run}-${index}`}]}, + }), + (run, index) => screen.findByText(`edited ${run}-${index}`), ); }); -test('[ReportActionsList] should re-render when only a heartbeat field of the report changes', async () => { +test('[ReportActionsList LegendList] should re-render when only a heartbeat field of the report changes', async () => { // Given 500 actions in the report, when only lastReadTime of the report changes // Then reassure measures what a field the rows don't display costs the list await measureWrites((run, index) => Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {lastReadTime: getTime(run, index)})); }); -test('[ReportActionsList] should re-render when an unrelated report is written', async () => { +test('[ReportActionsList LegendList] should re-render when an unrelated report is written', async () => { // Given 500 actions in the report, when another report in the account is created or updated // Then reassure measures what a write outside the open report costs the list await measureWrites((run, index) => Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}unrelated${run}_${index}`, {reportID: `unrelated${run}_${index}`, reportName: 'Unrelated'})); }); -test('[ReportActionsList] should re-render when an unrelated transaction is written', async () => { +test('[ReportActionsList LegendList] should re-render when an unrelated transaction is written', async () => { // Given 500 actions in the report, when a transaction of another report changes // Then reassure measures what a write outside the open report costs the list await measureWrites((run, index) => Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}T1`, {transactionID: 'T1', reportID: 'unrelated', amount: run * 100 + index})); }); -test('[ReportActionsList] should re-render when the report name changes', async () => { +test('[ReportActionsList LegendList] should re-render when the report name changes', async () => { // Given 500 actions in the report, when a field every row reads through the stable report changes // Then reassure measures the cost of a write that legitimately re-renders all rows await measureWrites((run, index) => Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {reportName: `Renamed ${run}-${index}`})); diff --git a/tests/tooling/webProxy.test.ts b/tests/tooling/webProxy.test.ts new file mode 100644 index 000000000000..8a5768054513 --- /dev/null +++ b/tests/tooling/webProxy.test.ts @@ -0,0 +1,94 @@ +/** Exercises the development API proxy against a local upstream, including failed connections. */ +import {afterAll, beforeAll, describe, expect, mock, test} from 'bun:test'; + +import type {RequestOptions} from 'https'; + +import {once} from 'events'; +import http from 'http'; + +const createServer = http.createServer; +const request = http.request; +let upstream: http.Server; +let proxy: http.Server | undefined; +let origin: string; +let receivedUserAgent: string | string[] | undefined; + +beforeAll(async () => { + upstream = createServer((incoming, response) => { + if (incoming.url === '/reset') { + incoming.socket.destroy(); + return; + } + receivedUserAgent = incoming.headers['user-agent']; + response.end('upstream-ok'); + }); + upstream.listen(0, '127.0.0.1'); + await once(upstream, 'listening'); + const upstreamAddress = upstream.address(); + if (!upstreamAddress || typeof upstreamAddress === 'string') { + throw new Error('The local upstream must listen on a TCP port.'); + } + + const captureServer = (listener: http.RequestListener) => { + proxy = createServer(listener); + return proxy; + }; + mock.module('http', () => ({default: {createServer: captureServer}, createServer: captureServer})); + const forwardLocally = (options: RequestOptions) => request({...options, protocol: 'http:', hostname: '127.0.0.1', port: upstreamAddress.port}); + mock.module('https', () => ({default: {request: forwardLocally}, request: forwardLocally})); + mock.module('../../web/cloudflare-worker/src/developmentAPIProxy', () => ({API_PROXY_PORT: 0})); + // The proxy has its own listener; use an ephemeral port without changing another dev session. + process.env.USE_WEB_PROXY = 'true'; + await import('../../web/proxy'); + if (!proxy) { + throw new Error('The development API proxy did not start.'); + } + if (!proxy.listening) { + await once(proxy, 'listening'); + } + const address = proxy.address(); + if (!address || typeof address === 'string') { + throw new Error('The development API proxy must listen on a TCP port.'); + } + origin = `http://127.0.0.1:${address.port}`; +}); + +afterAll(async () => { + for (const server of [proxy, upstream]) { + if (!server) { + continue; + } + server.closeAllConnections(); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } + mock.restore(); +}); + +describe('web API proxy', () => { + test('identifies the proxy instead of forwarding the browser identity', async () => { + // Given a browser identity that must not describe the proxy connection to the backend. + const browserUserAgent = 'test-browser/1.0'; + + // When a real request passes through the proxy to a working local upstream. + const response = await fetch(`${origin}/api/Ping`, {headers: new Headers([['User-Agent', browserUserAgent]]), signal: AbortSignal.timeout(1000)}); + + // Then the proxy identifies itself and preserves the successful response. + expect(response.status).toBe(200); + expect(await response.text()).toBe('upstream-ok'); + expect(receivedUserAgent).toBe('Development-NewDot/1.0'); + }); + + test('finishes the downstream response when the upstream connection resets', async () => { + // Given an upstream that closes its socket before sending response headers. + const url = `${origin}/reset`; + + // When the browser requests a response through the real proxy handler. + const response = await fetch(url, {signal: AbortSignal.timeout(1000)}); + + // Then it receives a gateway error instead of waiting indefinitely. + expect(response.status).toBe(502); + expect(await response.text()).toBe(''); + }); +}); diff --git a/tests/ui/ComposerImperativeRefTest.tsx b/tests/ui/ComposerImperativeRefTest.tsx index aea955d2df89..add28bc779b6 100644 --- a/tests/ui/ComposerImperativeRefTest.tsx +++ b/tests/ui/ComposerImperativeRefTest.tsx @@ -49,6 +49,7 @@ import createMock from '../utils/createMock'; let mockShouldMountComposer = true; let mockShouldUseNativeComposer = false; +let mockComposerText = ''; let mockMarkdownInput: TextInput | (HTMLDivElement & Pick) | null = null; const WebComposer = jest.requireActual('@components/Composer/implementation/index.tsx').default; const {forceClearInput: clearWebInput} = jest.requireActual('@libs/ComponentUtils/index.ts'); @@ -141,7 +142,7 @@ jest.mock('@pages/inbox/report/useDebouncedSaveReportActionDraft', () => () => ( jest.mock('@pages/inbox/report/useDraftMessageVideoAttributeCache', () => () => {}); jest.mock('@pages/inbox/report/useShouldEditInComposer', () => () => false); jest.mock('@pages/inbox/report/ReportActionCompose/ComposerContext', () => ({ - useComposerText: () => '', + useComposerText: () => mockComposerText, useComposerActions: () => ({ setText: jest.fn< ReturnType['setText']>, @@ -248,6 +249,7 @@ describe('Composer imperative refs', () => { jest.clearAllMocks(); mockShouldMountComposer = true; mockShouldUseNativeComposer = false; + mockComposerText = ''; jest.mocked(useIsInLandscapeMode).mockReturnValue(false); jest.mocked(useAnimatedRef).mockImplementation(jest.requireActual('react-native-reanimated/src/hook/useAnimatedRef.ts').useAnimatedRef); jest.mocked(isMobileChrome).mockReturnValue(false); @@ -424,6 +426,34 @@ describe('Composer imperative refs', () => { expect(objectRef.current).toBeNull(); }); + it('focuses the existing composer when its initial loading gate opens', async () => { + // Given the input mounted under the report's loading skeleton, so its mount-time autofocus was skipped. + mockShouldUseNativeComposer = true; + mockComposerText = 'Restored draft'; + const inputFocus = jest.fn, Parameters>(); + mockMarkdownInput = createMock({focus: inputFocus, isFocused: () => false}); + const props = forwardingProps(React.createRef()); + const view = render( + , + {createNodeMock: () => mockMarkdownInput}, + ); + expect(inputFocus).not.toHaveBeenCalled(); + + // When the positioned messages appear, the existing input must focus without remounting or another navigation. + view.rerender( + , + ); + + // Then a restored draft can be edited as soon as the report is visible. + await waitFor(() => expect(inputFocus).toHaveBeenCalledTimes(1)); + }); + it('keeps the outer commands tied to the current input and safely handles an absent input', () => { // Given the outer handle exposes only the commands its consumers use. mockShouldUseNativeComposer = true; diff --git a/tests/ui/PaginationTest.tsx b/tests/ui/PaginationTest.tsx index 411f2277558e..633646cae71e 100644 --- a/tests/ui/PaginationTest.tsx +++ b/tests/ui/PaginationTest.tsx @@ -43,6 +43,12 @@ const LIST_CONTENT_SIZE = { width: 300, height: 600, }; +const LIST_END_OFFSET = LIST_CONTENT_SIZE.height - LIST_SIZE.height; +const PAGINATED_LIST_CONTENT_SIZE = { + ...LIST_CONTENT_SIZE, + height: LIST_CONTENT_SIZE.height * 2, +}; +const PAGINATED_LIST_END_OFFSET = PAGINATED_LIST_CONTENT_SIZE.height - LIST_SIZE.height; const TEN_MINUTES_AGO = subMinutes(new Date(), 10); const REPORT_ID = '1'; @@ -58,14 +64,14 @@ function getReportScreen(reportID = REPORT_ID) { return screen.getByTestId(`report-screen-${reportID}`); } -function scrollToOffset(offset: number) { +function scrollToOffset(offset: number, contentSize = LIST_CONTENT_SIZE) { const hintText = TestHelper.translateLocal('sidebarScreen.listOfChatMessages'); fireEvent.scroll(within(getReportScreen()).getByLabelText(hintText), { nativeEvent: { contentOffset: { y: offset, }, - contentSize: LIST_CONTENT_SIZE, + contentSize, layoutMeasurement: LIST_SIZE, }, }); @@ -84,7 +90,8 @@ function triggerListLayout(reportID?: string) { persist: () => {}, }); - fireEvent(within(report).getByTestId('report-actions-list'), 'onContentSizeChange', LIST_CONTENT_SIZE.width, LIST_CONTENT_SIZE.height); + const reportActionsList = within(report).getByTestId('report-actions-list'); + fireEvent(reportActionsList, 'onContentSizeChange', LIST_CONTENT_SIZE.width, LIST_CONTENT_SIZE.height); } function getReportActions(reportID?: string) { @@ -172,26 +179,31 @@ function mockGetOlderActions(messageCount: number) { }, ] : [], - hasOlderActions: comments['1'] != null, + hasOlderActions: !comments['1'], }; }); } -function mockGetNewerActions(messageCount: number) { - fetchMock.mockAPICommand('GetNewerActions', ({reportID, reportActionID}) => ({ - onyxData: - reportID === REPORT_ID - ? [ - { - onyxMethod: 'merge', - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, - // The API also returns the action that was requested with the reportActionID. - value: buildReportComments(messageCount + 1, reportActionID, true), - }, - ] - : [], - hasNewerActions: messageCount > 0, - })); +function mockGetNewerActions(...messageCounts: number[]) { + let callIndex = 0; + fetchMock.mockAPICommand('GetNewerActions', ({reportID, reportActionID}) => { + const messageCount = messageCounts[Math.min(callIndex, messageCounts.length - 1)]; + callIndex += 1; + return { + onyxData: + reportID === REPORT_ID + ? [ + { + onyxMethod: 'merge', + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, + // The API also returns the action that was requested with the reportActionID. + value: buildReportComments(messageCount + 1, reportActionID, true), + }, + ] + : [], + hasNewerActions: messageCount > 0, + }; + }); } async function fastSignInWithTestUser() { @@ -315,7 +327,7 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); // Scrolling here should not trigger a new network request. - scrollToOffset(LIST_CONTENT_SIZE.height); + scrollToOffset(LIST_END_OFFSET); await waitForBatchedUpdatesWithAct(); scrollToOffset(0); await waitForBatchedUpdatesWithAct(); @@ -338,8 +350,8 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 0); - // Scrolling here should trigger a new network request. - scrollToOffset(LIST_CONTENT_SIZE.height); + // When the visible viewport reaches the oldest loaded message, request the older page. + scrollToOffset(0); await waitForBatchedUpdatesWithAct(); TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 1); @@ -359,7 +371,7 @@ describe('Pagination', () => { it('opens a chat and load newer messages', async () => { mockOpenReport(5, '5'); - mockGetNewerActions(5); + mockGetNewerActions(5, 0); await signInAndGetApp(); await navigateToSidebarOption(COMMENT_LINKING_REPORT_ID); @@ -369,33 +381,27 @@ describe('Pagination', () => { await waitFor(() => { jest.requireMock('@react-navigation/native').triggerTransitionEnd(); }); - // Due to https://github.com/facebook/react-native/commit/3485e9ed871886b3e7408f90d623da5c018da493 - // we need to scroll too to trigger `onStartReached` which triggers other updates - scrollToOffset(0); // ReportScreen relies on the onLayout event to receive updates from onyx. triggerListLayout(); + scrollToOffset(LIST_END_OFFSET); + await waitForNetworkPromises(); + await waitForBatchedUpdatesWithAct(); + await waitFor(() => TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2)); await waitForNetworkPromises(); await waitForBatchedUpdatesWithAct(); - // Here we have 5 messages from the initial OpenReport and 5 from the initial GetNewerActions. + // The first newer page advances the cursor while the viewport remains at the boundary, so pagination + // requests the next page and stops when the backend reports that there are no more newer actions. expect(getReportActions()).toHaveLength(10); - - // Simulate the backend returning no new messages to simulate reaching the start of the chat. - mockGetNewerActions(0); - - // There is 1 extra call here because of the comment linking report. - - // Simulate the backend returning no new messages to simulate reaching the start of the chat. - mockGetNewerActions(0); TestHelper.expectAPICommandToHaveBeenCalled('OpenReport', 3); TestHelper.expectAPICommandToHaveBeenCalledWith('OpenReport', 1, {reportID: REPORT_ID, reportActionID: '5'}); TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); TestHelper.expectAPICommandToHaveBeenCalledWith('GetNewerActions', 0, {reportID: REPORT_ID, reportActionID: '5'}); // Simulate the maintainVisibleContentPosition scroll adjustment, so it is now possible to scroll down more. - scrollToOffset(500); + scrollToOffset(0, PAGINATED_LIST_CONTENT_SIZE); await waitForBatchedUpdatesWithAct(); - scrollToOffset(0); + scrollToOffset(PAGINATED_LIST_END_OFFSET, PAGINATED_LIST_CONTENT_SIZE); await waitForBatchedUpdatesWithAct(); // We now have 10 messages. 5 from the initial OpenReport and 5 from the GetNewerActions call. @@ -405,9 +411,9 @@ describe('Pagination', () => { TestHelper.expectAPICommandToHaveBeenCalled('GetOlderActions', 0); TestHelper.expectAPICommandToHaveBeenCalled('GetNewerActions', 2); - scrollToOffset(500); + scrollToOffset(0, PAGINATED_LIST_CONTENT_SIZE); await waitForBatchedUpdatesWithAct(); - scrollToOffset(0); + scrollToOffset(PAGINATED_LIST_END_OFFSET, PAGINATED_LIST_CONTENT_SIZE); await waitForBatchedUpdatesWithAct(); // When there are no newer actions, we don't want to trigger GetNewerActions again. diff --git a/tests/ui/ReportActionsListRowTest.tsx b/tests/ui/ReportActionsListRowTest.tsx index ba361d79fed8..767cbef907e8 100644 --- a/tests/ui/ReportActionsListRowTest.tsx +++ b/tests/ui/ReportActionsListRowTest.tsx @@ -29,6 +29,7 @@ const onShowPreviousMessages = () => {}; const buildRow = (index: number, reportAction: ReportAction) => ( { const actualNav = jest.requireActual('@react-navigation/native'); return { ...actualNav, // eslint-disable-next-line @typescript-eslint/no-unsafe-return useIsFocused: () => mockUseIsFocused(), - useRoute: () => ({key: 'Report-test', name: 'Report', params: {}}), + useRoute: jest.fn(), useNavigationState: jest.fn(), createNavigationContainerRef: () => ({ getState: () => jest.fn(), @@ -69,11 +79,6 @@ jest.mock('@react-navigation/native', () => { }; }); -jest.mock('@pages/inbox/ActionListContext', () => ({ - ...jest.requireActual('@pages/inbox/ActionListContext'), - useActionListRef: jest.fn(() => ({current: null})), -})); - jest.mock('@hooks/useNetwork', () => jest.fn()); jest.mock('@pages/inbox/report/AskConciergeWelcome', () => { const {View} = jest.requireActual('react-native'); @@ -92,6 +97,7 @@ jest.mock('@hooks/useParentReportAction', () => jest.fn()); jest.mock('@hooks/useIsInSidePanel', () => jest.fn()); jest.mock('@hooks/useSidePanelState', () => jest.fn()); jest.mock('@hooks/useReportTransactionsCollection', () => jest.fn()); +jest.mock('@components/LegendList/allowLegendListItemOverflow', () => jest.fn()); jest.mock('@pages/inbox/ConciergeChatHistoryContext', () => ({ useConciergeChatHistoryState: jest.fn(), useConciergeChatHistoryActions: jest.fn(), @@ -102,6 +108,7 @@ jest.mock('@pages/inbox/ConciergeDraftContext', () => ({ })); const mockUseNetwork = useNetwork as jest.MockedFunction; +const mockUseRoute = useRoute as jest.MockedFunction; const mockUseIsReportLoadPending = useIsReportLoadPending as jest.MockedFunction; const mockUseOnyx = useOnyx as jest.MockedFunction; const mockUseResponsiveLayout = useResponsiveLayout as jest.MockedFunction; @@ -115,9 +122,23 @@ const mockUseConciergeDraft = useConciergeDraft as jest.MockedFunction; const mockUseConciergeChatHistoryState = useConciergeChatHistoryState as jest.MockedFunction; const mockUseConciergeChatHistoryActions = useConciergeChatHistoryActions as jest.MockedFunction; - -function getMockReportLoadingState(selector: unknown, hasOnceLoadedReportActions = true) { - return selector === reportActionsListLoadingStateSelector ? {hasOnceLoadedReportActions, isLoadingInitialReportActions: false} : undefined; +const mockAllowLegendListItemOverflow = allowLegendListItemOverflow as jest.MockedFunction; + +let mockHasLoadingOlderReportActionsError = false; +let mockIsLoadingNewerReportActions = false; +let mockHasLoadingNewerReportActionsError = false; + +function getMockReportLoadingState(selector: unknown, hasOnceLoadedReportActions = true, isLoadingInitialReportActions = false, isLoadingOlderReportActions = false) { + return selector === reportActionsListLoadingStateSelector + ? { + hasOnceLoadedReportActions, + isLoadingInitialReportActions, + isLoadingOlderReportActions, + hasLoadingOlderReportActionsError: mockHasLoadingOlderReportActionsError, + isLoadingNewerReportActions: mockIsLoadingNewerReportActions, + hasLoadingNewerReportActionsError: mockHasLoadingNewerReportActionsError, + } + : undefined; } const defaultPaginatedReportActionsResult: ReturnType = { @@ -147,10 +168,15 @@ const defaultSidePanelState: ReturnType = { jest.mock('@hooks/useCopySelectionHelper', () => jest.fn()); jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn()); +const mockLoadOlderChats = jest.fn(); jest.mock('@hooks/useLoadReportActions', () => - jest.fn(() => ({ - loadOlderChats: jest.fn(), + jest.fn(({reportActions}: {reportActions: OnyxTypes.ReportAction[]}) => ({ + loadOlderChats: mockLoadOlderChats, loadNewerChats: jest.fn(), + currentReportOldestActionID: reportActions.at(-1)?.reportActionID, + oldestReportActionsRequestCursor: reportActions.at(-1)?.reportActionID, + newestReportActionsRequestCursor: reportActions.at(0)?.reportActionID, + canLoadNewerChats: true, })), ); jest.mock('@hooks/usePrevious', () => jest.fn()); @@ -158,31 +184,74 @@ jest.mock('@hooks/usePrevious', () => jest.fn()); const mockUseCurrentUserPersonalDetails = useCurrentUserPersonalDetails as jest.MockedFunction; // We mount the public ReportActionsList (the skeleton guard + its content) and observe what the content -// feeds the list via InvertedFlashList's `data`. The heavy scroll/marker hooks have their own unit tests, +// feeds chronological data directly to LegendList. The heavy scroll/marker hooks have their own unit tests, // so they are stubbed here to isolate the skeleton logic. Because the guard only mounts the content when // the skeleton is not showing, these stubs double as a probe for dormancy: while a skeleton renders the // content is never mounted, so useMarkAsRead/useReportActionsScroll are never called. -jest.mock('@components/FlashList/InvertedFlashList', () => jest.fn(() => null)); +const mockLegendListMount = jest.fn(); +const mockLegendListUnmount = jest.fn(); +const mockLegendScrollToEnd = jest.fn(); +const mockLegendScrollToIndex = jest.fn(); +const mockLegendPositionByKey = jest.fn(() => 1500); +const mockLegendGetState = jest.fn(() => ({ + positionByKey: mockLegendPositionByKey, + start: 2, + end: 4, + isAtEnd: false, + contentLength: 1000, + scrollLength: 500, + scroll: 500, +})); +const mockLegendGetScrollableNode = jest.fn(() => ({scrollTop: 0})); +let mockShouldCallLegendListOnLoad = true; +jest.mock('@legendapp/list/react-native', () => { + const reactModule = jest.requireActual('react'); + const MockLegendListContent = reactModule.forwardRef<{scrollToEnd: typeof mockLegendScrollToEnd}, {onLoad?: () => void; onReady?: () => void}>(({onLoad, onReady}, ref) => { + reactModule.useImperativeHandle( + ref, + () => ({scrollToEnd: mockLegendScrollToEnd, scrollToIndex: mockLegendScrollToIndex, getState: mockLegendGetState, getScrollableNode: mockLegendGetScrollableNode}), + [], + ); + reactModule.useEffect(() => { + mockLegendListMount(); + if (mockShouldCallLegendListOnLoad) { + onLoad?.(); + onReady?.(); + } + return () => { + mockLegendListUnmount(); + }; + }, []); + return null; + }); + return { + LegendList: jest.fn((props: {onLoad?: () => void; onReady?: () => void; ref?: React.Ref<{scrollToEnd: typeof mockLegendScrollToEnd}>}) => + reactModule.createElement(MockLegendListContent, props), + ), + }; +}); jest.mock('@hooks/useUnreadMarker', () => jest.fn(() => ({unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1}))); jest.mock('@hooks/useMarkAsRead', () => jest.fn(() => ({markNewestActionAsRead: jest.fn(), completeSkippedMarkAsRead: jest.fn()}))); jest.mock('@hooks/useReportActionsScroll', () => jest.fn(() => ({ listRef: {current: null}, + trackLinkedMessageScroll: jest.fn(), trackVerticalScrolling: jest.fn(), onViewableItemsChanged: jest.fn(), isFloatingMessageCounterVisible: false, isActionBadgeAboveViewport: false, scrollToBottomAndMarkReportAsRead: jest.fn(), scrollToActionBadgeTarget: jest.fn(), - flushPendingScrollToBottom: jest.fn(), shouldBeAlignedToTop: false, - shouldFocusToTopOnMount: false, - initialScrollKey: undefined, - shouldAutoscrollToBottom: false, + initialScrollIndex: undefined, + initialScrollIndexParams: undefined, onLoad: jest.fn(), + onItemSizeChanged: jest.fn(), + stopLinkedMessagePositioning: jest.fn(), })), ); jest.mock('@pages/inbox/report/ReportActionsList/ReportActionsListFloatingMessageCounter', () => jest.fn(() => null)); +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest.fn(() => false)); jest.mock('@pages/inbox/report/ReportActionsListPaddingView', () => { const reactModule = jest.requireActual('react'); return jest.fn(({children}: {children: React.ReactNode}) => reactModule.createElement(reactModule.Fragment, null, children)); @@ -193,19 +262,47 @@ jest.mock('@pages/inbox/report/ReportActionsListItemRenderer', () => jest.fn(() jest.mock('@pages/inbox/report/CollapsedSystemMessages', () => jest.fn(() => null)); jest.mock('@pages/inbox/report/ShowPreviousMessagesButton', () => jest.fn(() => null)); -type MockInvertedFlashListProps = { +type MockLegendListProps = { + alignItemsAtEnd?: boolean; + contentContainerStyle?: StyleProp; data?: OnyxTypes.ReportAction[]; + drawDistance?: number; + // eslint-disable-next-line @typescript-eslint/naming-convention -- The mock must preserve LegendList's public prop name. + experimental_hideItemsUntilMeasured?: boolean; extraData?: unknown; + getItemType?: (item: OnyxTypes.ReportAction) => string; + initialScrollAtEnd?: boolean; + maintainScrollAtEnd?: {animated: boolean; on: {dataChange: boolean}} | false; + maintainScrollAtEndThreshold?: number; + maintainVisibleContentPosition?: LegendListProps['maintainVisibleContentPosition']; + onLayout?: (event: LayoutChangeEvent) => void; + onLoad?: () => void; + onReady?: () => void; + onScrollBeginDrag?: () => void; + estimatedHeaderSize?: number; + ListHeaderComponent?: React.ReactNode; + ListFooterComponent?: React.ReactNode; + onContentSizeChange?: (width: number, height: number) => void; + onViewableItemsChanged?: (info: OnViewableItemsChangedInfo) => void; + recycleItems?: boolean; renderItem?: (info: {item: OnyxTypes.ReportAction; index: number}) => React.ReactElement | null; - onContentSizeChange?: () => void; + renderScrollComponent?: RenderActionSheetAwareScrollViewComponent; + scrollEventThrottle?: number; + onScroll?: (event: { + nativeEvent: { + contentOffset: {x: number; y: number}; + contentSize: {height: number; width: number}; + layoutMeasurement: {height: number; width: number}; + }; + }) => void; }; -const mockInvertedFlashList: jest.MockedFunction<(props: MockInvertedFlashListProps) => null> = jest.requireMock('@components/FlashList/InvertedFlashList'); +const {LegendList: mockLegendList} = jest.requireMock<{LegendList: jest.MockedFunction<(props: MockLegendListProps) => null>}>('@legendapp/list/react-native'); const mockReportActionItemCreated: jest.Mock = jest.requireMock('@pages/inbox/report/ReportActionItemCreated'); -/** Returns the report actions the body fed into the (mocked) inverted list on its latest render. */ -const getCapturedVisibleActions = (): OnyxTypes.ReportAction[] | undefined => mockInvertedFlashList.mock.calls.at(-1)?.at(0)?.data; -const getCapturedListProps = (): MockInvertedFlashListProps | undefined => mockInvertedFlashList.mock.calls.at(-1)?.at(0); +/** Returns the chronological report actions the body fed into the mocked LegendList on its latest render. */ +const getCapturedVisibleActions = (): OnyxTypes.ReportAction[] | undefined => mockLegendList.mock.calls.at(-1)?.at(0)?.data; +const getCapturedListProps = (): MockLegendListProps | undefined => mockLegendList.mock.calls.at(-1)?.at(0); const findRenderedElement = (node: React.ReactNode, type: React.ElementType): {props: Props} | undefined => { if (!jest.isMockFunction(type)) { @@ -220,10 +317,27 @@ const findRenderedElement = (node: React.ReactNode, type: React.ElementT return props ? {props} : undefined; }; +/** Finds a pagination indicator in a list header or footer element without rendering it, so the mounted list stays intact. */ +function findPaginationLoadingIndicator(node: React.ReactNode): React.ReactElement | undefined { + if (!React.isValidElement<{children?: React.ReactNode}>(node)) { + return undefined; + } + if (node.type === ReportActionsPaginationLoadingIndicator) { + return node as React.ReactElement; + } + for (const child of React.Children.toArray(node.props.children)) { + const loadingIndicator = findPaginationLoadingIndicator(child); + if (loadingIndicator) { + return loadingIndicator; + } + } + return undefined; +} + const getRenderedReportActionsListItemProps = ( reportAction: OnyxTypes.ReportAction, index = 0, -): {shouldDisableContextMenuForConciergeDraft?: boolean; isLatestConciergeFeedbackAction?: boolean; displayAsGroup?: boolean} => { +): {shouldDisableContextMenuForConciergeDraft?: boolean; isLatestConciergeFeedbackAction?: boolean; shouldDisplayNewMarker?: boolean; displayAsGroup?: boolean} => { const props = findRenderedElement>( getCapturedListProps()?.renderItem?.({item: reportAction, index}), ReportActionsListItemRenderer, @@ -236,10 +350,19 @@ const getRenderedReportActionsListItemProps = ( return props; }; +// Reads the row's Show History handler without rendering, because callers press it inside act(). +const getShowHistoryPress = (action: OnyxTypes.ReportAction, index: number): (() => void) | undefined => { + const row = getCapturedListProps()?.renderItem?.({item: action, index}); + return React.isValidElement<{onShowPreviousMessages?: () => void}>(row) ? row.props.onShowPreviousMessages : undefined; +}; + const mockUseMarkAsRead: jest.Mock = jest.requireMock('@hooks/useMarkAsRead'); const mockUseUnreadMarker: jest.Mock = jest.requireMock('@hooks/useUnreadMarker'); const mockUseReportActionsScroll: jest.MockedFunction = jest.requireMock('@hooks/useReportActionsScroll'); const mockMarkOpenReportEnd: jest.Mock = jest.requireMock('@libs/telemetry/markOpenReportEnd'); +let mockHasOnceLoadedReportActions = true; +let mockIsLoadingInitialReportActions = false; +let mockIsLoadingOlderReportActions = false; jest.mock('@libs/actions/Report', () => ({ updateLoadingInitialReportAction: jest.fn(), @@ -282,6 +405,19 @@ const mockReportActions: OnyxTypes.ReportAction[] = [ }, ]; +const olderMockReportAction: OnyxTypes.ReportAction = { + reportActionID: '0', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + created: '2022-12-31', + actorAccountID: 125, + message: [{type: 'COMMENT', html: 'Older message', text: 'Older message'}], + originalMessage: {}, + shouldShow: true, + person: [{type: 'TEXT', style: 'strong', text: 'Older User'}], + pendingAction: null, + errors: {}, +}; + const renderReportActionsList = (props: {reportID?: string} = {}) => { const reportID = props.reportID ?? mockReport.reportID; return render(); @@ -322,8 +458,17 @@ describe('ReportActionsList (body)', () => { beforeEach(() => { jest.clearAllMocks(); - mockUseIsReportLoadPending.mockReturnValue(false); + mockUseIsFocused.mockReturnValue(false); + mockHasOnceLoadedReportActions = true; + mockIsLoadingInitialReportActions = false; + mockIsLoadingOlderReportActions = false; + mockHasLoadingOlderReportActionsError = false; + mockIsLoadingNewerReportActions = false; + mockHasLoadingNewerReportActionsError = false; + mockShouldCallLegendListOnLoad = true; mockUseUnreadMarker.mockReturnValue({unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1}); + mockUseIsReportLoadPending.mockReturnValue(false); + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {}}); mockUseCurrentUserPersonalDetails.mockReturnValue({ accountID: 100, @@ -373,7 +518,12 @@ describe('ReportActionsList (body)', () => { mockUseConciergeChatHistoryState.mockReturnValue({showFullHistory: false, isHistoryCollapsedByUser: false}); mockUseConciergeChatHistoryActions.mockReturnValue({setShowFullHistory: jest.fn(), collapseHistory: jest.fn()}); - mockUseOnyx.mockImplementation(getMockOnyxValue); + mockUseOnyx.mockImplementation((key, options) => { + if (key.includes('reportLoadingState')) { + return [getMockReportLoadingState(options?.selector, mockHasOnceLoadedReportActions, mockIsLoadingInitialReportActions, mockIsLoadingOlderReportActions), {status: 'loaded'}]; + } + return getMockOnyxValue(key, options); + }); }); afterEach(async () => { @@ -381,6 +531,1126 @@ describe('ReportActionsList (body)', () => { await Onyx.clear(); }); + it('publishes measured positions, the live visible range, and the scroll element through the action-list adapter', () => { + // Given a mounted LegendList with measured rows and its own scroll element. + mockUseNetwork.mockReturnValue({isOffline: false}); + const registerListRef = jest.fn(); + const contextValue = {scrollOffsetRef: {current: 0}, getScrollOffset: () => 0, getListRef: () => null, registerListRef}; + render( + // The shared context intentionally combines stable refs with their accessors. + // eslint-disable-next-line rulesdir/context-provider-split-values + + + , + ); + + // When linked positioning reads the registered adapter rather than the raw LegendList ref. + const adapter = registerListRef.mock.calls.at(-1)?.at(0)?.current; + const position = adapter?.getState?.()?.positionByKey('linked-action'); + const scrollElement = adapter?.getScrollableNode?.(); + const visibleRange = adapter?.computeVisibleIndices?.(); + + // Then measurement recovery and web input cancellation can reach the real list. + expect(getCapturedListProps()?.maintainVisibleContentPosition).toEqual({data: true, anchor: 'end'}); + expect(position).toBe(1500); + expect(mockLegendPositionByKey).toHaveBeenCalledWith('linked-action'); + expect(scrollElement).toEqual({scrollTop: 0}); + expect(mockLegendGetScrollableNode).toHaveBeenCalledTimes(1); + expect(visibleRange).toEqual({startIndex: 2, endIndex: 4}); + }); + + it('corrects the initial end position after the latest row has been measured', async () => { + // Given a report opens at its latest action and the virtualized list finishes its initial placement. + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + // When the last row is taller than the estimate, its feedback controls must not remain below the viewport. + // Then the list makes one final end scroll after the layout is ready. + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + }); + + it('keeps the native scroll-view throttle supplied by LegendList', () => { + // Given a report list using the action-sheet-aware native scroller. + mockUseNetwork.mockReturnValue({isOffline: false}); + const platform = jest.replaceProperty(Platform, 'OS', 'android'); + try { + renderReportActionsList(); + const listProps = getCapturedListProps(); + + // When LegendList requests native scroll events on every frame. + const scrollView = listProps?.renderScrollComponent?.({scrollEventThrottle: 0}); + + // Then the wrapper preserves them while LegendList separately throttles the app callback. + expect(scrollView?.props.scrollEventThrottle).toBe(0); + expect(listProps?.scrollEventThrottle).toBe(16); + } finally { + platform.restore(); + } + }); + + it.each([800, 1000])('refreshes the newest-message offset without following successive viewport layouts at %i pixels', async (height) => { + // Given a reader 600 pixels from the end before the viewport grows. + mockUseNetwork.mockReturnValue({isOffline: false}); + const originalState = mockLegendGetState(); + const scrollOffsetRef = {current: 600}; + const contextValue = {scrollOffsetRef, getScrollOffset: () => scrollOffsetRef.current, getListRef: () => null, registerListRef: jest.fn()}; + render( + // The shared context intentionally combines stable refs with their accessors. + // eslint-disable-next-line rulesdir/context-provider-split-values + + + , + ); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalled()); + mockLegendScrollToEnd.mockClear(); + const frames: FrameRequestCallback[] = []; + const requestFrame = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => frames.push(callback)); + try { + act(() => { + const listProps = getCapturedListProps(); + listProps?.onScrollBeginDrag?.(); + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 900}, + contentSize: {height: 2000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + }); + + // When successive layouts resize the viewport without new content or scroll events. + for (const viewportHeight of [height, height + 50]) { + mockLegendGetState.mockReturnValue({...originalState, contentLength: 2000, scrollLength: viewportHeight, scroll: 900}); + act(() => getCapturedListProps()?.onLayout?.(createMock({nativeEvent: {layout: {x: 0, y: 0, width: 300, height: viewportHeight}}}))); + + // Then a future incoming message uses the current pixel offset without resizing itself starting a scroll. + expect(scrollOffsetRef.current).toBe(2000 - viewportHeight - 900); + expect(frames).toHaveLength(0); + } + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + expect(getCapturedListProps()?.maintainScrollAtEnd).toBe(false); + } finally { + requestFrame.mockRestore(); + mockLegendGetState.mockReturnValue(originalState); + } + }); + + it('keeps following the newest action when the keyboard shrinks the viewport', async () => { + // Given the reader is following the newest action before opening the keyboard. + mockUseNetwork.mockReturnValue({isOffline: false}); + const originalState = mockLegendGetState(); + renderReportActionsList(); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalled()); + mockLegendScrollToEnd.mockClear(); + try { + // When the smaller viewport temporarily leaves the newest action 300 pixels below it. + mockLegendGetState.mockReturnValue({...originalState, contentLength: 1000, scrollLength: 200, scroll: 500}); + act(() => getCapturedListProps()?.onLayout?.(createMock({nativeEvent: {layout: {x: 0, y: 0, width: 300, height: 200}}}))); + + // Then resizing keeps the existing end-following intent. + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + } finally { + mockLegendGetState.mockReturnValue(originalState); + } + }); + + it('keeps the latest message visible when its measured height grows after opening', async () => { + // Given the chat has opened at the end with a measured viewport. + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + mockLegendScrollToEnd.mockClear(); + const listProps = getCapturedListProps(); + act(() => { + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 500}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + }); + + // When a late feedback row makes the content taller without a user scroll. + act(() => { + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 500}, + contentSize: {height: 1025, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + listProps?.onContentSizeChange?.(300, 1025); + }); + + // Then the last 25 pixels are brought into view. + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + }); + + it('follows content growth when the reader is 50 pixels above the newest action', async () => { + // Given a reader still within the existing 250-pixel follow distance. + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalled()); + mockLegendScrollToEnd.mockClear(); + act(() => { + const listProps = getCapturedListProps(); + listProps?.onScrollBeginDrag?.(); + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 450}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + }); + + // When a new message or streamed text increases the content height. + act(() => getCapturedListProps()?.onContentSizeChange?.(300, 1025)); + + // Then it follows the new content even though the reader is more than 1% from the end. + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + }); + + it('does not pull the chat back down when the reader has scrolled away', async () => { + // Given the chat opened at the end and the reader scrolled upward. + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + mockLegendScrollToEnd.mockClear(); + const listProps = getCapturedListProps(); + act(() => { + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 500}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 200}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + listProps?.onContentSizeChange?.(300, 1025); + }); + + // When content grows, the reader's position stays where they left it. + await act( + async () => + new Promise((resolve) => { + setTimeout(resolve, 30); + }), + ); + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + }); + + it('resumes resize following after scrolling from a linked action to the end', async () => { + // Given the chat opens at a linked action, which must keep its initial position. + mockUseNetwork.mockReturnValue({isOffline: false}); + const originalImplementation = mockUseReportActionsScroll.getMockImplementation(); + const defaultScrollState = mockUseReportActionsScroll(createMock[0]>({})); + mockUseReportActionsScroll.mockReturnValue({...defaultScrollState, initialScrollIndex: 0}); + try { + renderReportActionsList(); + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + + // When the reader reaches the end and the latest row grows afterward. + act(() => { + const listProps = getCapturedListProps(); + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 500}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + listProps?.onContentSizeChange?.(300, 1025); + }); + + // Then the latest content remains visible even though the route still contains a linked action. + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + } finally { + mockUseReportActionsScroll.mockImplementation(originalImplementation); + } + }); + + it.each([ + {isAtEnd: true, shouldFollowEnd: true}, + {isAtEnd: false, shouldFollowEnd: false}, + ])('follows late row growth after opening at an unread marker only when it lands at the end ($isAtEnd)', async ({isAtEnd, shouldFollowEnd}) => { + // Given the chat opens at an unread marker, which LegendList clamps to the end when the marker is near the newest action. + mockUseNetwork.mockReturnValue({isOffline: false}); + const originalImplementation = mockUseReportActionsScroll.getMockImplementation(); + const defaultScrollState = mockUseReportActionsScroll(createMock[0]>({})); + mockUseReportActionsScroll.mockReturnValue({...defaultScrollState, initialScrollIndex: 1}); + mockLegendGetState.mockReturnValue({...mockLegendGetState(), isAtEnd}); + try { + renderReportActionsList(); + + // When rows above the viewport are measured after the list is ready and change the content height. + act(() => { + getCapturedListProps()?.onContentSizeChange?.(300, 1025); + }); + await act( + async () => + new Promise((resolve) => { + setTimeout(resolve, 30); + }), + ); + + // Then a list that landed at the end keeps the newest messages in view, and one showing older unread messages stays put. + if (shouldFollowEnd) { + expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false}); + } else { + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + } + } finally { + mockUseReportActionsScroll.mockImplementation(originalImplementation); + mockLegendGetState.mockReturnValue({...mockLegendGetState(), isAtEnd: false}); + } + }); + + it('keeps an unread marker in view when the list settles close to the end', async () => { + // Given the chat opens at an unread marker and the list settles 100 pixels above the newest action without the reader scrolling. + mockUseNetwork.mockReturnValue({isOffline: false}); + const originalImplementation = mockUseReportActionsScroll.getMockImplementation(); + const defaultScrollState = mockUseReportActionsScroll(createMock[0]>({})); + mockUseReportActionsScroll.mockReturnValue({...defaultScrollState, initialScrollIndex: 1}); + mockLegendGetState.mockReturnValue({...mockLegendGetState(), isAtEnd: false}); + try { + renderReportActionsList(); + act(() => { + getCapturedListProps()?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 400}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + }); + + // When rows below the marker are measured and make the content taller. + act(() => getCapturedListProps()?.onContentSizeChange?.(300, 1025)); + await act( + async () => + new Promise((resolve) => { + setTimeout(resolve, 30); + }), + ); + + // Then the list stays at the marker instead of scrolling it out of view. + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + } finally { + mockUseReportActionsScroll.mockImplementation(originalImplementation); + } + }); + + it('cancels a queued end correction as soon as the reader starts dragging', async () => { + // Given removing the latest message has queued a layout correction at the end. + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalled()); + mockLegendScrollToEnd.mockClear(); + const animationFrames: FrameRequestCallback[] = []; + const requestAnimationFrameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { + animationFrames.push(callback); + return animationFrames.length; + }); + try { + const listProps = getCapturedListProps(); + // When the user touches the list before the next scroll metrics arrive. + act(() => { + listProps?.onContentSizeChange?.(300, 900); + listProps?.onScrollBeginDrag?.(); + for (const callback of animationFrames) { + callback(0); + } + }); + + // Then even a callback already dispatched by the frame scheduler respects the gesture. + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + } finally { + requestAnimationFrameSpy.mockRestore(); + } + }); + + it('keeps linked positioning active through automatic scroll metrics until the reader drags', () => { + // Given a measured linked target can cause automatic scroll compensation before its preview settles. + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + const scrollHookResult = mockUseReportActionsScroll.mock.results.at(-1)?.value as ReturnType; + const stopLinkedPositioning = scrollHookResult.stopLinkedMessagePositioning; + const listProps = getCapturedListProps(); + + // When layout compensation moves the list without a user gesture. + act(() => { + for (const offset of [200, 100]) { + listProps?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: offset}, + contentSize: {height: 2000, width: 300}, + layoutMeasurement: {height: 100, width: 300}, + }, + }); + } + }); + + // Then later preview measurements can still correct the target, until the reader takes over. + expect(stopLinkedPositioning).not.toHaveBeenCalled(); + act(() => listProps?.onScrollBeginDrag?.()); + expect(stopLinkedPositioning).toHaveBeenCalledTimes(1); + }); + + it('does not duplicate the composer spacing inside the chronological list', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + expect(StyleSheet.flatten(getCapturedListProps()?.contentContainerStyle)?.paddingBottom).toBe(0); + }); + + it('invalidates a recycled row when the unread marker changes on a wide layout', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseUnreadMarker.mockReturnValue({unreadMarkerReportActionID: '2', unreadMarkerReportActionIndex: 0}); + renderReportActionsList(); + + expect(getCapturedListProps()?.extraData).toEqual(expect.arrayContaining(['2'])); + const markerAction = getCapturedVisibleActions()?.find((action) => action.reportActionID === '2'); + if (!markerAction) { + throw new Error('Expected unread marker action fixture'); + } + expect(getRenderedReportActionsListItemProps(markerAction, 1).shouldDisplayNewMarker).toBe(true); + }); + + it('does not follow the end of a page that still has newer actions to load', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasNewerActions: true, + }); + renderReportActionsList(); + + expect(getCapturedListProps()?.maintainScrollAtEnd).toBe(false); + expect(getCapturedListProps()?.maintainVisibleContentPosition).toEqual({data: true, anchor: 'end'}); + }); + + it('remounts the list when the initial report actions finish hydrating', async () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + const view = renderReportActionsList(); + + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); + mockLegendScrollToEnd.mockClear(); + + mockHasOnceLoadedReportActions = true; + // The mocked Onyx hook does not own state, so changing its return value cannot schedule the + // rerender that the real Onyx subscription causes. Change a prop to trigger that render. + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(mockLegendListMount).toHaveBeenCalledTimes(2); + expect(mockLegendListUnmount).toHaveBeenCalledTimes(1); + await waitFor(() => expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false})); + }); + + it('keeps the initial viewport covered until the hydrated LegendList finishes rendering it', async () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseIsFocused.mockReturnValue(true); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = true; + mockShouldCallLegendListOnLoad = false; + const view = renderReportActionsList(); + + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + + act(() => { + getCapturedListProps()?.onReady?.(); + }); + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(mockMarkOpenReportEnd).not.toHaveBeenCalledWith(mockReport.reportID, mockReport, {warm: true}); + + mockHasOnceLoadedReportActions = true; + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + + act(() => { + getCapturedListProps()?.onReady?.(); + }); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + expect(mockMarkOpenReportEnd).toHaveBeenCalledWith(mockReport.reportID, mockReport, {warm: true}); + }); + + it('resumes initial scroll tracking after layout and scrolling are ready', () => { + // Given a report with its action list ready to mount. + mockShouldCallLegendListOnLoad = false; + renderReportActionsList(); + const scrollHookResult = mockUseReportActionsScroll.mock.results.at(-1)?.value as {onLoad?: jest.Mock} | undefined; + const onLoad = scrollHookResult?.onLoad; + expect(onLoad).not.toHaveBeenCalled(); + + // When LegendList reports that layout and initial scrolling are complete. + act(() => getCapturedListProps()?.onReady?.()); + + // Then the scroll hook receives that event to finish initial positioning. + expect(onLoad).toHaveBeenCalledTimes(1); + }); + + it('covers a previously hydrated report until LegendList finishes its initial layout', () => { + // Given a report that was already loaded before this list mounted + mockUseNetwork.mockReturnValue({isOffline: false}); + mockShouldCallLegendListOnLoad = false; + + // When the user opens the report again + renderReportActionsList(); + + // Then the cover hides LegendList's empty initial viewport until its actions are ready + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + + act(() => getCapturedListProps()?.onLoad?.()); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + + act(() => getCapturedListProps()?.onReady?.()); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + + it('covers cached offline messages until the list is positioned without waiting for OpenReport', () => { + // Given cached messages and an initial request that cannot finish while offline. + mockUseNetwork.mockReturnValue({isOffline: true}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = true; + mockShouldCallLegendListOnLoad = false; + + // When the report opens before LegendList finishes positioning its cached messages. + renderReportActionsList(); + + // Then the skeleton hides the unfinished viewport until the list is ready, regardless of the queued request. + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + act(() => getCapturedListProps()?.onLoad?.()); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + act(() => getCapturedListProps()?.onReady?.()); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + + it('preserves the visible offline list while reconnecting and loading its first server page', async () => { + // Given cached messages have already been positioned while their OpenReport cannot finish offline. + mockUseNetwork.mockReturnValue({isOffline: true}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = true; + let isAppLoadPending = true; + mockUseOnyx.mockImplementation((key, options) => { + if (key === ONYXKEYS.PERSISTED_REQUESTS || key === ONYXKEYS.PERSISTED_ONGOING_REQUESTS) { + return [isAppLoadPending, {status: 'loaded'}]; + } + return getMockOnyxValue(key, options); + }); + const view = render(); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + + // When connectivity returns, both the queued request and its eventual response must preserve that viewport. + mockShouldCallLegendListOnLoad = false; + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseIsReportLoadPending.mockReturnValue(true); + view.rerender(); + await waitForBatchedUpdatesWithAct(); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); + + mockHasOnceLoadedReportActions = true; + mockIsLoadingInitialReportActions = false; + mockUseIsReportLoadPending.mockReturnValue(false); + isAppLoadPending = false; + view.rerender(); + await waitForBatchedUpdatesWithAct(); + + // Then no replacement list resets the reader's scroll position or flashes another loading cover. + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + + it('keeps the positioned list mounted when sending a message clears the linked action', async () => { + // Given a linked message has finished its initial positioning. + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {reportActionID: '2'}}); + const view = renderReportActionsList(); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + + // When the live-tail jump clears the link, a new mount would have to position again. + mockShouldCallLegendListOnLoad = false; + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {reportActionID: ''}}); + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the existing list stays visible without another initial skeleton. + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + + it('renders the skeleton below a linked action inside its row while newer rows are measured', () => { + // Given a chat opened at a linked action while LegendList still hides the newer rows below it. + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {reportActionID: '2'}}); + const originalImplementation = mockUseReportActionsScroll.getMockImplementation(); + const defaultScrollState = mockUseReportActionsScroll(createMock[0]>({})); + mockUseReportActionsScroll.mockReturnValue({...defaultScrollState, shouldShowSkeletonBelowLinkedAction: true, hideSkeletonBelowLinkedAction: jest.fn()}); + const renderCell = (item: OnyxTypes.ReportAction, index: number) => { + const cell = getCapturedListProps()?.renderItem?.({item, index}); + if (!React.isValidElement(cell)) { + throw new Error('Expected renderItem to return an element'); + } + render(cell); + }; + try { + renderReportActionsList(); + const [createdAction, linkedAction] = mockReportActions; + if (!createdAction || !linkedAction) { + throw new Error('Expected two report actions'); + } + + // When LegendList renders the linked row. + renderCell(linkedAction, 1); + + // Then the row carries the skeleton, so newer rows added after it cannot push the skeleton away. + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + + // When LegendList renders the row above it. + renderCell(createdAction, 0); + + // Then that row has no skeleton, because only the linked row borders the area where the newer rows appear. + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + } finally { + mockUseReportActionsScroll.mockImplementation(originalImplementation); + } + }); + + it.each(['2', 'another-action'])('positions a newly opened link after the previous link was cleared: %s', async (reportActionID) => { + // Given a positioned linked list is kept mounted when returning to its latest messages. + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {reportActionID: '2'}}); + const view = renderReportActionsList(); + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {reportActionID: ''}}); + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + + // When the reader opens a link, even if it targets the same action again. + mockShouldCallLegendListOnLoad = false; + mockUseRoute.mockReturnValue({key: 'report', name: 'Report', params: {reportActionID}}); + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then fresh positioning is covered until the new list is ready. + expect(mockLegendListMount).toHaveBeenCalledTimes(2); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + act(() => getCapturedListProps()?.onReady?.()); + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + + it('releases the initial viewport cover after a terminal OpenReport failure', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = false; + mockShouldCallLegendListOnLoad = false; + renderReportActionsList(); + + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + + act(() => { + getCapturedListProps()?.onReady?.(); + }); + + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + + it('keeps the initial viewport covered while OpenReport is queued despite stale stored loading state', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = false; + mockUseIsReportLoadPending.mockReturnValue(true); + mockShouldCallLegendListOnLoad = false; + const view = renderReportActionsList(); + + act(() => { + getCapturedListProps()?.onReady?.(); + }); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + + mockUseIsReportLoadPending.mockReturnValue(false); + view.rerender( + , + ); + + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + }); + + it('keeps the initial actions visible until the hydrated page is complete', async () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = true; + const groupingSpy = jest.spyOn(ReportActionsUtils, 'isConsecutiveActionMadeByPreviousActor'); + const view = renderReportActionsList(); + + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length); + + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [...mockReportActions, olderMockReportAction], + }); + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length); + expect(getCapturedVisibleActions()?.some((action) => action.reportActionID === olderMockReportAction.reportActionID)).toBe(false); + + const snapshotActions = getCapturedVisibleActions(); + const oldestSnapshotAction = snapshotActions?.at(0); + if (!oldestSnapshotAction || !snapshotActions) { + throw new Error('Expected the initial action snapshot to remain visible'); + } + getRenderedReportActionsListItemProps(oldestSnapshotAction); + expect(groupingSpy).toHaveBeenLastCalledWith(snapshotActions.toReversed(), snapshotActions.length - 1, false); + groupingSpy.mockRestore(); + + mockHasOnceLoadedReportActions = true; + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(getCapturedVisibleActions()).toHaveLength(mockReportActions.length + 1); + expect(getCapturedVisibleActions()?.some((action) => action.reportActionID === olderMockReportAction.reportActionID)).toBe(true); + }); + + it.each([ + {isOffline: true, isLoadingInitialReportActions: true}, + {isOffline: false, isLoadingInitialReportActions: false}, + ])('keeps actions live after a first load cannot complete: %p', async ({isOffline, isLoadingInitialReportActions}) => { + // Given cached actions with no successful OpenReport response + mockUseNetwork.mockReturnValue({isOffline}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = isLoadingInitialReportActions; + const view = renderReportActionsList(); + + // When an optimistic action is added while offline or after a terminal load failure + const existingAction = mockReportActions.at(1); + if (!existingAction) { + throw new Error('Expected a cached report action'); + } + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [ + { + ...existingAction, + reportActionID: 'new-comment', + created: '2023-01-03', + }, + ...mockReportActions, + ], + }); + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + // Then the new action appears without waiting for hydration + expect(getCapturedVisibleActions()?.at(-1)?.reportActionID).toBe('new-comment'); + }); + + it('keeps offline additions visible when reconnecting during the initial load', async () => { + // Given a cached report whose initial request is still pending. + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = true; + const view = renderReportActionsList(); + const existingAction = mockReportActions.at(1); + if (!existingAction) { + throw new Error('Expected a cached report action'); + } + const renderList = () => + view.rerender( + , + ); + + // When an action is added offline and the connection returns before hydration. + mockUseNetwork.mockReturnValue({isOffline: true}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [{...existingAction, reportActionID: 'offline-comment', created: '2023-01-03'}, ...mockReportActions], + }); + renderList(); + await waitForBatchedUpdatesWithAct(); + expect(getCapturedVisibleActions()?.at(-1)?.reportActionID).toBe('offline-comment'); + mockUseNetwork.mockReturnValue({isOffline: false}); + renderList(); + await waitForBatchedUpdatesWithAct(); + + // Then the list does not revert to the earlier cached snapshot. + expect(getCapturedVisibleActions()?.at(-1)?.reportActionID).toBe('offline-comment'); + }); + + it('keeps a hydrated list mounted when its RAM-only loading entry briefly regresses', async () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + const view = renderReportActionsList(); + + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + + mockHasOnceLoadedReportActions = false; + mockIsLoadingInitialReportActions = true; + view.rerender( + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockLegendListUnmount).not.toHaveBeenCalled(); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + }); + + it('limits the render buffer and enables item recycling', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + const listProps = getCapturedListProps(); + + expect(listProps?.drawDistance).toBe(1500); + expect(listProps?.recycleItems).toBe(true); + expect(listProps?.experimental_hideItemsUntilMeasured).toBe(true); + }); + + it('allows visible report action menus to overflow their LegendList item containers', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + const hiddenAction = mockReportActions.at(0); + const visibleAction = mockReportActions.at(1); + if (!hiddenAction || !visibleAction) { + throw new Error('Expected report action fixtures'); + } + + act(() => { + getCapturedListProps()?.onViewableItemsChanged?.({ + changed: [ + {containerId: 1, index: 1, isViewable: true, item: visibleAction, key: '1'}, + {containerId: 0, index: 0, isViewable: false, item: hiddenAction, key: '0'}, + ], + end: 1, + endBuffered: 1, + start: 0, + startBuffered: 0, + viewableItems: [], + }); + }); + + expect(mockAllowLegendListItemOverflow).toHaveBeenCalledTimes(1); + expect(mockAllowLegendListItemOverflow).toHaveBeenCalledWith(expect.objectContaining({scrollToEnd: mockLegendScrollToEnd}), 1); + }); + + it('groups comments by layout characteristics for measurement estimates', () => { + mockUseNetwork.mockReturnValue({isOffline: false}); + renderReportActionsList(); + + const getItemType = getCapturedListProps()?.getItemType; + const comment = mockReportActions.at(1); + if (!comment) { + throw new Error('Expected comment report action fixture'); + } + + expect(getItemType?.(comment)).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-short`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'medium-comment', + message: [{type: 'COMMENT', html: 'Medium comment', text: 'a'.repeat(320)}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-medium`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'long-comment', + message: [{type: 'COMMENT', html: 'Long comment', text: 'a'.repeat(1200)}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-long`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'extra-long-comment', + message: [{type: 'COMMENT', html: 'Extra long comment', text: 'a'.repeat(1201)}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-extra-long`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'attachment', + isAttachmentOnly: true, + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-attachment`); + expect( + getItemType?.({ + ...comment, + reportActionID: 'link-preview', + linkMetadata: [{url: 'https://example.com'}], + }), + ).toBe(`${CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}-link-preview-short`); + }); + + it('loads each older page once while the reader stays near the oldest message', async () => { + // Given a chat with older history and the reader scrolled to the oldest loaded message + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasOlderActions: true, + }); + const originalState = mockLegendGetState(); + mockLegendGetState.mockReturnValue({...originalState, scroll: 0}); + const view = renderReportActionsList(); + const createScrollEvent = (offset: number) => ({ + nativeEvent: { + contentOffset: {x: 0, y: offset}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + + try { + // When scroll events keep arriving near the start, including after the request finishes without new rows + act(() => { + getCapturedListProps()?.onScroll?.(createScrollEvent(0)); + getCapturedListProps()?.onScroll?.(createScrollEvent(0)); + }); + mockIsLoadingOlderReportActions = true; + view.rerender( + , + ); + mockIsLoadingOlderReportActions = false; + view.rerender( + , + ); + act(() => { + getCapturedListProps()?.onScroll?.(createScrollEvent(0)); + }); + + // Then the same page is requested only once + await waitFor(() => expect(mockLoadOlderChats).toHaveBeenCalledTimes(1)); + + // When the older page arrives and the reader is still near the start + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [...mockReportActions, olderMockReportAction], + hasOlderActions: true, + }); + view.rerender( + , + ); + act(() => { + getCapturedListProps()?.onScroll?.(createScrollEvent(0)); + }); + + // Then the next page is requested from the new oldest message + await waitFor(() => expect(mockLoadOlderChats).toHaveBeenCalledTimes(2)); + } finally { + mockLegendGetState.mockReturnValue(originalState); + } + }); + + describe('pagination loading indicators', () => { + it('shows loading indicators only while page requests are active', () => { + // Given a chat with history in both directions and no active requests + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasOlderActions: true, + hasNewerActions: true, + }); + const view = renderReportActionsList(); + + // Then neither edge shows an indicator or reserves space for one + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)).toBeUndefined(); + expect(getCapturedListProps()?.estimatedHeaderSize).toBe(0); + + // When older and newer pages start loading + mockIsLoadingOlderReportActions = true; + mockIsLoadingNewerReportActions = true; + view.rerender( + , + ); + + // Then each edge shows its indicator and the older one is part of the header estimate + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)?.props.direction).toBe('older'); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)?.props.direction).toBe('newer'); + expect(getCapturedListProps()?.estimatedHeaderSize).toBe(CONST.PAGINATION_LOADING_INDICATOR_HEIGHT); + expect(getCapturedListProps()?.maintainVisibleContentPosition).toEqual({data: true, anchor: 'end'}); + + // When both requests fail + mockHasLoadingOlderReportActionsError = true; + mockHasLoadingNewerReportActionsError = true; + view.rerender( + , + ); + + // Then the indicators disappear instead of spinning forever + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)).toBeUndefined(); + }); + + it('removes an exhausted edge indicator and hides loading UI offline', () => { + // Given both edges loading + mockUseNetwork.mockReturnValue({isOffline: false}); + mockIsLoadingOlderReportActions = true; + mockIsLoadingNewerReportActions = true; + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasOlderActions: true, + hasNewerActions: true, + }); + const view = renderReportActionsList(); + + // When the report has no more older history + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasOlderActions: false, + hasNewerActions: true, + }); + view.rerender( + , + ); + + // Then only the newer edge keeps its indicator + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)?.props.direction).toBe('newer'); + + // When the device goes offline + mockUseNetwork.mockReturnValue({isOffline: true}); + view.rerender( + , + ); + + // Then no loading indicator is shown, because no request can finish + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListHeaderComponent)).toBeUndefined(); + expect(findPaginationLoadingIndicator(getCapturedListProps()?.ListFooterComponent)).toBeUndefined(); + }); + }); + + it('does not load older pages from scroll events before the list is positioned', () => { + // Given a list whose initial scroll has not landed yet, so it still reports an offset near the top. + const originalState = mockLegendGetState(); + mockLegendGetState.mockReturnValue({...originalState, scroll: 0, scrollLength: 500, contentLength: 1000}); + try { + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: mockReportActions, + hasOlderActions: true, + }); + mockShouldCallLegendListOnLoad = false; + renderReportActionsList(); + const createScrollEvent = (offset: number) => ({ + nativeEvent: { + contentOffset: {x: 0, y: offset}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + + // When a scroll event arrives before LegendList is ready. + act(() => { + getCapturedListProps()?.onScroll?.(createScrollEvent(0)); + }); + + // Then no older page is requested, because it would be inserted above the target while the list positions it. + expect(mockLoadOlderChats).not.toHaveBeenCalled(); + + // When the list is ready and the reader is still near the top. + act(() => getCapturedListProps()?.onReady?.()); + act(() => { + getCapturedListProps()?.onScroll?.(createScrollEvent(0)); + }); + + // Then the older page loads as usual. + expect(mockLoadOlderChats).toHaveBeenCalledTimes(1); + } finally { + mockLegendGetState.mockReturnValue(originalState); + } + }); + it('leaves counter copy subscriptions in the counter wrapper while forwarding report and navigation props', () => { // Given loaded list content with its counter mocked, copy-only reads must not remain in the parent or model hooks. const reportWithPolicy = {...mockReport, policyID: 'counter-policy'}; @@ -394,7 +1664,7 @@ describe('ReportActionsList (body)', () => { renderReportActionsList(); // Then the list is mounted, but none of the counter's copy subscriptions have leaked above the mocked wrapper. - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const subscribedKeys = mockUseOnyx.mock.calls.map(([key]) => key); expect(subscribedKeys).not.toContain(`${ONYXKEYS.COLLECTION.POLICY}${reportWithPolicy.policyID}`); expect(subscribedKeys).not.toContain(ONYXKEYS.COLLECTION.RULE); @@ -435,35 +1705,55 @@ describe('ReportActionsList (body)', () => { mockUseMarkAsRead.mockReturnValue({markNewestActionAsRead, completeSkippedMarkAsRead: jest.fn()}); const scrollToBottom = jest.fn(); const scrollToIndex = jest.fn(); - const openReportAtLiveTail = jest.fn(); + const requestLiveTailJump = jest.fn(); const activeRouteSpy = jest.spyOn(Navigation, 'getReportRHPActiveRoute').mockReturnValue(''); const navigateSpy = jest.spyOn(Navigation, 'navigate').mockImplementation(() => {}); const scrollManagerSpy = jest.spyOn(ReportScrollManager, 'default').mockReturnValue(createMock>({scrollToBottom, scrollToIndex})); - const trackingSpy = jest - .spyOn(ReportUnreadMessageScrollTracking, 'default') - .mockReturnValue(createMock>({setIsFloatingMessageCounterVisible: jest.fn()})); - const liveTailSpy = jest - .spyOn(ReportActionsNewActionLiveTail, 'default') - .mockReturnValue(createMock>({openReportAtLiveTail})); + const trackingSpy = jest.spyOn(ReportUnreadMessageScrollTracking, 'default').mockReturnValue( + createMock>({ + isFloatingMessageCounterVisible: false, + setIsFloatingMessageCounterVisible: jest.fn(), + isActionBadgeAboveViewport: false, + setIsActionBadgeAboveViewport: jest.fn(), + trackVerticalScrolling: jest.fn(), + onViewableItemsChanged: jest.fn(), + updatePillVisibility: jest.fn(), + }), + ); + const liveTailSpy = jest.spyOn(ReportActionsNewActionLiveTail, 'default').mockReturnValue( + createMock>({ + isScrollToBottomEnabled: false, + setIsScrollToBottomEnabled: jest.fn(), + completeLiveTailPruneAfterScrollToBottom: jest.fn(), + requestLiveTailJump, + }), + ); const defaultScrollImplementation = mockUseReportActionsScroll.getMockImplementation(); mockUseReportActionsScroll.mockImplementation(jest.requireActual<{default: typeof useReportActionsScroll}>('@hooks/useReportActionsScroll').default); + let view: ReturnType | undefined; try { // When the user clicks the counter or Latest with the actual scroll hook mounted - renderReportActionsList(); + view = renderReportActionsList(); const scrollResult = mockUseReportActionsScroll.mock.results.at(-1)?.value as ReturnType | undefined; act(() => scrollResult?.scrollToBottomAndMarkReportAsRead()); // Then only a loaded latest page is marked read; a cached survey alone cannot prove freshness expect(getCapturedVisibleActions()).not.toContain(survey); expect(markNewestActionAsRead).toHaveBeenCalledTimes(hasNewestPage ? 1 : 0); - expect(openReportAtLiveTail).toHaveBeenCalledTimes(!hasNewestPage && !hasUnreadMarker ? 1 : 0); + const shouldRequestLiveTailJump = !hasNewestPage && !hasUnreadMarker; + expect(requestLiveTailJump).toHaveBeenCalledTimes(shouldRequestLiveTailJump ? 1 : 0); if (hasUnreadMarker) { - expect(scrollToIndex).toHaveBeenCalledWith(0); - } else { + expect(scrollToIndex).toHaveBeenCalledWith(0, {viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + } else if (hasNewestPage) { expect(scrollToBottom).toHaveBeenCalled(); + } else { + // The live-tail jump scrolls once the newest page has loaded, not at the end of the old page. + expect(scrollToBottom).not.toHaveBeenCalled(); } } finally { + // Unmount first, so deferred list updates cannot render with the restored hooks. + view?.unmount(); scrollManagerSpy.mockRestore(); trackingSpy.mockRestore(); liveTailSpy.mockRestore(); @@ -510,9 +1800,10 @@ describe('ReportActionsList (body)', () => { ); // Then New belongs to the unread reply, not the older read comment - expect(getCapturedVisibleActions()).not.toContain(survey); + const visibleActions = getCapturedVisibleActions(); + expect(visibleActions).not.toContain(survey); expect(mockUseReportActionsScroll.mock.lastCall?.[0].unreadMarkerReportActionID).toBe(offlineReply.reportActionID); - expect(mockUseReportActionsScroll.mock.lastCall?.[0].unreadMarkerReportActionIndex).toBe(0); + expect(mockUseReportActionsScroll.mock.lastCall?.[0].unreadMarkerReportActionIndex).toBe(visibleActions?.indexOf(offlineReply)); } finally { focusSpy.mockRestore(); networkSpy.mockRestore(); @@ -680,9 +1971,9 @@ describe('ReportActionsList (body)', () => { // When the standard report list constructs its summary renderReportActionsList(); - const collapsedAnchor = getCapturedListProps()?.renderItem?.({item: getSystemAction(0), index: 0}); + const collapsedAnchor = getCapturedListProps()?.renderItem?.({item: getSystemAction(1), index: 2}); const summary = findRenderedElement>(collapsedAnchor, CollapsedSystemMessages); - expect(getCapturedSystemActionIDs()).toEqual(['system-newer', 'chat-boundary']); + expect(getCapturedSystemActionIDs()).toEqual(['chat-boundary', 'system-older']); expect(getRenderedReportActionsListItemProps(getSystemAction(2), 1)).toMatchObject({displayAsGroup: false}); // Editing uses the displayed row order after the hidden update, not canonical indices. expect(jest.mocked(useScrollToEditingReportAction).mock.calls.at(-1)?.at(0)?.visibleReportActions).toBe(getCapturedVisibleActions()); @@ -691,10 +1982,10 @@ describe('ReportActionsList (body)', () => { expect(summary?.props).toMatchObject({count: isWaitingOnBankAccount ? 2 : 3, earliestReportAction: getSystemAction(1)}); act(() => summary?.props.onPress()); expect(getCapturedVisibleActions()?.map((action) => action.reportActionID)).toEqual( - isWaitingOnBankAccount ? ['system-newer', 'system-older', 'chat-boundary', 'created'] : ['system-newer', 'expense-payment', 'system-older', 'chat-boundary', 'created'], + isWaitingOnBankAccount ? ['created', 'chat-boundary', 'system-older', 'system-newer'] : ['created', 'chat-boundary', 'system-older', 'expense-payment', 'system-newer'], ); - expect(getRenderedReportActionsListItemProps(getSystemAction(0), 0)).toMatchObject({displayAsGroup: true}); - expect(findRenderedElement(getCapturedListProps()?.renderItem?.({item: getSystemAction(0), index: 0}), CollapsedSystemMessages)).toBeUndefined(); + expect(getRenderedReportActionsListItemProps(getSystemAction(0), isWaitingOnBankAccount ? 3 : 4)).toMatchObject({displayAsGroup: true}); + expect(findRenderedElement(getCapturedListProps()?.renderItem?.({item: getSystemAction(1), index: 2}), CollapsedSystemMessages)).toBeUndefined(); }); it('does not create a summary from one visible update and a hidden payment', () => { @@ -713,22 +2004,38 @@ describe('ReportActionsList (body)', () => { // When the visible list is built, only the real update remains, with its normal header renderReportActionsList(); - expect(getCapturedVisibleActions()?.map((action) => action.reportActionID)).toEqual(['system-newer', 'created']); - const item = getCapturedListProps()?.renderItem?.({item: getSystemAction(0), index: 0}); + expect(getCapturedVisibleActions()?.map((action) => action.reportActionID)).toEqual(['created', 'system-newer']); + const item = getCapturedListProps()?.renderItem?.({item: getSystemAction(0), index: 1}); expect(findRenderedElement(item, CollapsedSystemMessages)).toBeUndefined(); - expect(getRenderedReportActionsListItemProps(getSystemAction(0), 0)).toMatchObject({displayAsGroup: false}); + expect(getRenderedReportActionsListItemProps(getSystemAction(0), 1)).toMatchObject({displayAsGroup: false}); expect(findRenderedElement>(item, ReportActionsListItemRenderer)?.props.isFirstVisibleReportAction).toBe(true); }); + it('keeps the sender header after a collapsed system-message run', () => { + // Given the same sender posts a chat message after a run of system updates. + const newerMessage = {...getSystemAction(2), reportActionID: 'chat-after-summary', created: '2023-01-01 00:04:00.000'}; + mockReport.type = CONST.REPORT.TYPE.EXPENSE; + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUsePaginatedReportActions.mockReturnValue({...defaultPaginatedReportActionsResult, reportActions: [newerMessage, ...systemActions]}); + + // When the run is displayed as one chronological summary row. + renderReportActionsList(); + const summary = getCapturedListProps()?.renderItem?.({item: getSystemAction(1), index: 2}); + + // Then the following chat message keeps its own sender header. + expect(findRenderedElement(summary, CollapsedSystemMessages)).toBeDefined(); + expect(getRenderedReportActionsListItemProps(newerMessage, 3)).toMatchObject({displayAsGroup: false}); + }); + it('maps an unread run member to the collapsed summary row', () => { - mockUseUnreadMarker.mockReturnValue({unreadMarkerReportActionID: 'system-older', unreadMarkerReportActionIndex: 1}); + mockUseUnreadMarker.mockReturnValue({unreadMarkerReportActionID: 'system-newer', unreadMarkerReportActionIndex: 0}); renderSystemActions(CONST.REPORT.TYPE.EXPENSE); - expect(mockUseReportActionsScroll.mock.calls.at(-1)?.at(0)).toMatchObject({unreadMarkerReportActionIndex: 0}); - expect(mockUseReportActionsScroll.mock.calls.at(-1)?.at(0)?.reportActionIDToDisplayIndex?.get('system-older')).toBe(0); - const collapsedAnchor = getCapturedListProps()?.renderItem?.({item: getSystemAction(0), index: 0}); + expect(mockUseReportActionsScroll.mock.calls.at(-1)?.at(0)).toMatchObject({unreadMarkerReportActionIndex: 2}); + expect(mockUseReportActionsScroll.mock.calls.at(-1)?.at(0)?.reportActionIDToDisplayIndex?.get('system-newer')).toBe(2); + const collapsedAnchor = getCapturedListProps()?.renderItem?.({item: getSystemAction(1), index: 2}); const summary = findRenderedElement>(collapsedAnchor, CollapsedSystemMessages); - expect(summary?.props.unreadMarkerReportActionID).toBe('system-older'); + expect(summary?.props.unreadMarkerReportActionID).toBe('system-newer'); }); it.each([true, false])('keeps the first unread marker with its persisted or synthesized CREATED header (persisted: %s)', (hasPersistedCreatedAction) => { @@ -748,20 +2055,20 @@ describe('ReportActionsList (body)', () => { // When the unread pair is collapsed, scrolling still targets the summary renderReportActionsList(); - const collapsedAnchor = getCapturedListProps()?.renderItem?.({item: getSystemAction(0), index: 0}); + const collapsedAnchor = getCapturedListProps()?.renderItem?.({item: getSystemAction(1), index: 1}); const summary = findRenderedElement>(collapsedAnchor, CollapsedSystemMessages); // Then the visible CREATED header owns NEW in both cases, without changing the collapsed scroll target expect(summary).toBeDefined(); expect(summary?.props.unreadMarkerReportActionID).toBeUndefined(); - expect(mockUseReportActionsScroll.mock.calls.at(-1)?.at(0)).toMatchObject({unreadMarkerReportActionIndex: 0}); - expect(mockUseReportActionsScroll.mock.calls.at(-1)?.at(0)?.reportActionIDToDisplayIndex?.get('system-older')).toBe(0); + expect(mockUseReportActionsScroll.mock.calls.at(-1)?.at(0)).toMatchObject({unreadMarkerReportActionIndex: 1}); + expect(mockUseReportActionsScroll.mock.calls.at(-1)?.at(0)?.reportActionIDToDisplayIndex?.get('system-older')).toBe(1); const headerAction = getCapturedVisibleActions()?.find((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); expect(headerAction).toBeDefined(); if (!headerAction) { throw new Error('Expected expense pagination to preserve or synthesize a CREATED header'); } - const header = getCapturedListProps()?.renderItem?.({item: headerAction, index: 1}); + const header = getCapturedListProps()?.renderItem?.({item: headerAction, index: 0}); expect(findRenderedElement>(header, ReportActionsListItemRenderer)?.props).toMatchObject({ shouldHideThreadDividerLine: true, shouldUseThreadDividerLine: true, @@ -771,27 +2078,26 @@ describe('ReportActionsList (body)', () => { it('does not collapse passive actions in ordinary chat reports', () => { renderSystemActions(CONST.REPORT.TYPE.CHAT); - expect(getCapturedSystemActionIDs()).toEqual(['system-newer', 'system-older', 'chat-boundary']); + expect(getCapturedSystemActionIDs()).toEqual(['chat-boundary', 'system-older', 'system-newer']); }); - it('mounts the off-screen oldest update and gives only that row a manual focus request in the inverted list', () => { - // Given a newest-first group whose collapsed anchor is its newest member - const scrollToIndex = jest.fn(); - jest.mocked(useActionListRef).mockReturnValue(createMock>({current: {scrollToIndex}})); + it('mounts the off-screen oldest update and gives only that row a manual focus request in the chronological list', () => { + // Given a chronological group whose collapsed anchor is its oldest member + const scrollToIndex = mockLegendScrollToIndex; const frames: FrameRequestCallback[] = []; const frameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => frames.push(callback)); try { renderSystemActions(CONST.REPORT.TYPE.EXPENSE); - const anchor = getCapturedListProps()?.renderItem?.({item: getSystemAction(0), index: 0}); + const anchor = getCapturedListProps()?.renderItem?.({item: getSystemAction(1), index: 2}); const summary = findRenderedElement>(anchor, CollapsedSystemMessages); expect(summary).toBeDefined(); // When the user presses the summary act(() => summary?.props.onPress()); - // Then the oldest revealed message owns focus, independent of the inverted data index - const oldest = getCapturedListProps()?.renderItem?.({item: getSystemAction(1), index: 1}); - const newest = getCapturedListProps()?.renderItem?.({item: getSystemAction(0), index: 0}); + // Then the oldest revealed message owns focus at its chronological data index + const oldest = getCapturedListProps()?.renderItem?.({item: getSystemAction(1), index: 2}); + const newest = getCapturedListProps()?.renderItem?.({item: getSystemAction(0), index: 3}); expect(findRenderedElement>(oldest, ReportActionsListItemRenderer)?.props.onExpandedSystemMessageReady).toEqual( expect.any(Function), ); @@ -803,15 +2109,14 @@ describe('ReportActionsList (body)', () => { callback(0); } }); - expect(scrollToIndex).toHaveBeenCalledWith({index: 1, animated: false, viewPosition: 0.5}); + expect(scrollToIndex).toHaveBeenCalledWith({index: 2, animated: false, viewPosition: 0.5}); } finally { frameSpy.mockRestore(); - jest.mocked(useActionListRef).mockReturnValue({current: null}); } }); it('keeps the unread target aligned with a synthetic draft in ordinary chats', () => { - // Given an unread action whose rendered index shifts when a newer draft is inserted. + // Given an unread action followed by a newer synthetic draft. mockUseUnreadMarker.mockReturnValue({unreadMarkerReportActionID: 'system-older', unreadMarkerReportActionIndex: 1}); mockUseConciergeDraft.mockReturnValue({ draftReportAction: {...getSystemAction(2), reportActionID: 'draft-before-unread', created: '2023-01-01 00:04:00.000'}, @@ -822,9 +2127,10 @@ describe('ReportActionsList (body)', () => { // When an ordinary chat uses the same list without system-message collapsing. renderSystemActions(CONST.REPORT.TYPE.CHAT); - // Then scrolling uses the rendered index rather than the canonical index from before the draft. - expect(getCapturedVisibleActions()?.at(2)?.reportActionID).toBe('system-older'); - expect(mockUseReportActionsScroll.mock.calls.at(-1)?.at(0)).toMatchObject({unreadMarkerReportActionIndex: 2}); + // Then the newer draft leaves the chronological unread index unchanged. + expect(getCapturedVisibleActions()?.at(1)?.reportActionID).toBe('system-older'); + expect(getCapturedVisibleActions()?.at(-1)?.reportActionID).toBe('draft-before-unread'); + expect(mockUseReportActionsScroll.mock.calls.at(-1)?.at(0)).toMatchObject({unreadMarkerReportActionIndex: 1}); }); }); @@ -1098,10 +2404,9 @@ describe('ReportActionsList (body)', () => { // When survey filtering changes the rendered array without inserting a temporary reply. renderReportActionsList(); - // Then only the saved reply remains, and no synthetic autoscroll or retained reply is needed. + // Then only the saved reply remains, and no retained reply is needed. expect(getCapturedVisibleActions()?.filter((action) => action.reportActionID === savedReply.reportActionID)).toHaveLength(1); expect(getCapturedVisibleActions()).not.toContain(survey); - expect(mockUseReportActionsScroll.mock.calls.at(-1)?.at(0)?.draftAutoScrollKey).toBe(''); expect(clearDraft).toHaveBeenCalledTimes(1); }); @@ -1401,6 +2706,65 @@ describe('ReportActionsList (body)', () => { }); describe('Open-report telemetry', () => { + beforeEach(() => mockUseIsFocused.mockReturnValue(true)); + + it('ends the warm span when the positioned content replaces its cover', () => { + // Given cached messages whose initial list layout is still covered. + mockUseNetwork.mockReturnValue({isOffline: false}); + mockShouldCallLegendListOnLoad = false; + renderReportActionsList(); + act(() => getCapturedListProps()?.onLayout?.(createMock({nativeEvent: {layout: {x: 0, y: 0, width: 300, height: 500}}}))); + expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + expect(mockMarkOpenReportEnd).not.toHaveBeenCalledWith(mockReport.reportID, mockReport, {warm: true}); + + // When LegendList finishes positioning and the cover is removed. + act(() => getCapturedListProps()?.onReady?.()); + + // Then telemetry records visible content once, rather than the earlier hidden layout. + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + expect(mockMarkOpenReportEnd).toHaveBeenCalledWith(mockReport.reportID, mockReport, {warm: true}); + act(() => getCapturedListProps()?.onLayout?.(createMock({nativeEvent: {layout: {x: 0, y: 0, width: 300, height: 400}}}))); + expect(mockMarkOpenReportEnd).toHaveBeenCalledTimes(1); + }); + + it('does not wait for a background OpenReport request when reopening a hydrated report', () => { + // Given a report loaded earlier in this app session with a refresh still in flight. + mockUseNetwork.mockReturnValue({isOffline: false}); + mockHasOnceLoadedReportActions = true; + mockIsLoadingInitialReportActions = true; + mockUseIsReportLoadPending.mockReturnValue(true); + mockShouldCallLegendListOnLoad = false; + renderReportActionsList(); + + // When the cached list finishes positioning, before that request completes. + act(() => getCapturedListProps()?.onReady?.()); + + // Then the cover clears and visible-content telemetry ends immediately. + expect(screen.queryByTestId('ReportActionsSkeletonCover')).toBeNull(); + expect(mockMarkOpenReportEnd).toHaveBeenCalledWith(mockReport.reportID, mockReport, {warm: true}); + }); + + it('ends a new warm span when an already-positioned report regains focus', () => { + // Given a positioned report that remains mounted while another screen has focus. + mockUseNetwork.mockReturnValue({isOffline: false}); + mockUseIsFocused.mockReturnValue(false); + const view = renderReportActionsList(); + expect(mockMarkOpenReportEnd).not.toHaveBeenCalled(); + + // When navigation returns without another list layout or OpenReport response. + mockUseIsFocused.mockReturnValue(true); + view.rerender( + , + ); + + // Then the visible cached content completes the new navigation span. + expect(mockLegendListMount).toHaveBeenCalledTimes(1); + expect(mockMarkOpenReportEnd).toHaveBeenCalledWith(mockReport.reportID, mockReport, {warm: true}); + }); + it('fires markOpenReportEnd with warm:false while the initial skeleton shows', () => { mockUseNetwork.mockReturnValue({isOffline: false}); @@ -1450,25 +2814,10 @@ describe('ReportActionsList (body)', () => { }); }); - describe('Pending scroll-to-bottom', () => { - it('flushes a pending scroll-to-bottom when the list content size changes', () => { - // Given the list is rendered - mockUseNetwork.mockReturnValue({isOffline: false}); - renderReportActionsList(); - - // When the list content size changes, as it does once FlashList lays out a newly sent comment. In an - // aligned-to-top report this is the only list event that follows, because onLayout does not fire. - getCapturedListProps()?.onContentSizeChange?.(); - - // Then the pending scroll-to-bottom is flushed, so the list lands on the new comment - const scrollHookResult = mockUseReportActionsScroll.mock.results.at(-1)?.value as {flushPendingScrollToBottom: jest.Mock}; - expect(scrollHookResult.flushPendingScrollToBottom).toHaveBeenCalledTimes(1); - }); - }); - describe('Concierge Side Panel', () => { const CONCIERGE_REPORT_ID = '123'; const CURRENT_USER_ACCOUNT_ID = 100; + const SESSION_START = '2024-06-01 12:00:00.000'; // Actions created before the current session const oldReportActions: OnyxTypes.ReportAction[] = [ @@ -1539,10 +2888,10 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); expect(passedActions?.length).toBeGreaterThanOrEqual(1); - expect(passedActions?.at(0)?.reportActionID).toBe(CONST.CONCIERGE_GREETING_ACTION_ID); + expect(passedActions?.some((action) => action.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(true); }); it('should not show welcome state when not in side panel', () => { @@ -1606,7 +2955,197 @@ describe('ReportActionsList (body)', () => { // Welcome should not be shown since user has sent a message expect(mockReportActionItemCreated).not.toHaveBeenCalled(); // ReportActionsList should be rendered with filtered actions - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); + }); + it('keeps a short Concierge session at its visible tail when Show History reveals older actions', () => { + // Given a short session at its latest action, with history filtered out. + setupConciergeMocks(); + mockUseIsInSidePanel.mockReturnValue(true); + mockUseSidePanelState.mockReturnValue({...defaultSidePanelState, sessionStartTime: SESSION_START}); + mockUsePaginatedReportActions.mockReturnValue({...defaultPaginatedReportActionsResult, reportActions: oldReportActions, hasOlderActions: false}); + const animationFrames: FrameRequestCallback[] = []; + const requestAnimationFrameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { + animationFrames.push(callback); + return animationFrames.length; + }); + + try { + const view = renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); + const actions = getCapturedVisibleActions(); + const createdIndex = actions?.findIndex((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) ?? -1; + const createdAction = actions?.at(createdIndex); + if (!createdAction) { + throw new Error('Expected the Concierge created action'); + } + + // When the reader reveals the older history. + act(() => getShowHistoryPress(createdAction, createdIndex)?.()); + view.rerender( + , + ); + act(() => { + for (const callback of animationFrames) { + callback(0); + } + }); + + // Then all actions are available while the latest session stays in view. + expect(getCapturedVisibleActions()?.some((action) => action.reportActionID === 'old-msg-1')).toBe(true); + expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false}); + } finally { + requestAnimationFrameSpy.mockRestore(); + } + }); + + it.each([false, true])('only follows a delayed Show History page while the reader stays at the end (scrolls away: %s)', (scrollsAway) => { + // Given a Concierge session with more history to load after the initially revealed actions. + setupConciergeMocks(); + mockUseIsInSidePanel.mockReturnValue(true); + mockUseSidePanelState.mockReturnValue({...defaultSidePanelState, sessionStartTime: SESSION_START}); + mockUsePaginatedReportActions.mockReturnValue({...defaultPaginatedReportActionsResult, reportActions: oldReportActions, hasOlderActions: true}); + const animationFrames: FrameRequestCallback[] = []; + const requestAnimationFrameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { + animationFrames.push(callback); + return animationFrames.length; + }); + const flushAnimationFrames = () => { + act(() => { + for (const callback of animationFrames.splice(0)) { + callback(0); + } + }); + }; + + try { + const view = renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); + const actions = getCapturedVisibleActions(); + const createdIndex = actions?.findIndex((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) ?? -1; + const createdAction = actions?.at(createdIndex); + if (!createdAction) { + throw new Error('Expected the Concierge created action'); + } + + // When Show History reveals the loaded actions and an older page arrives afterward. + act(() => getShowHistoryPress(createdAction, createdIndex)?.()); + view.rerender( + , + ); + flushAnimationFrames(); + mockLegendScrollToEnd.mockClear(); + + if (scrollsAway) { + act(() => { + getCapturedListProps()?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 500}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + getCapturedListProps()?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 200}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + }); + } + + const oldUserAction = oldReportActions.at(1); + if (!oldUserAction) { + throw new Error('Expected the older Concierge action'); + } + const olderAction = {...oldUserAction, reportActionID: 'older-user-msg', created: '2022-06-15 10:00:00.000'}; + mockUsePaginatedReportActions.mockReturnValue({...defaultPaginatedReportActionsResult, reportActions: [...oldReportActions, olderAction], hasOlderActions: false}); + view.rerender( + , + ); + flushAnimationFrames(); + + // Then loading the page respects the reader's choice to stay at the end or read history. + expect(getCapturedVisibleActions()?.some((action) => action.reportActionID === 'older-user-msg')).toBe(true); + if (scrollsAway) { + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + } else { + expect(mockLegendScrollToEnd).toHaveBeenCalledWith({animated: false}); + } + } finally { + requestAnimationFrameSpy.mockRestore(); + } + }); + + it('does not pull a slightly scrolled Concierge chat to the bottom when Show History is pressed', () => { + // Given the reader is above the latest message but still within the generic action-visible threshold. + setupConciergeMocks(); + mockUseIsInSidePanel.mockReturnValue(true); + mockUseSidePanelState.mockReturnValue({...defaultSidePanelState, sessionStartTime: SESSION_START}); + mockUsePaginatedReportActions.mockReturnValue({...defaultPaginatedReportActionsResult, reportActions: oldReportActions, hasOlderActions: false}); + const animationFrames: FrameRequestCallback[] = []; + const requestAnimationFrameSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback) => { + animationFrames.push(callback); + return animationFrames.length; + }); + + try { + const view = renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); + act(() => { + for (const callback of animationFrames.splice(0)) { + callback(0); + } + }); + mockLegendScrollToEnd.mockClear(); + act(() => { + getCapturedListProps()?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 500}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + getCapturedListProps()?.onScroll?.({ + nativeEvent: { + contentOffset: {x: 0, y: 400}, + contentSize: {height: 1000, width: 300}, + layoutMeasurement: {height: 500, width: 300}, + }, + }); + }); + const actions = getCapturedVisibleActions(); + const createdIndex = actions?.findIndex((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) ?? -1; + const createdAction = actions?.at(createdIndex); + if (!createdAction) { + throw new Error('Expected the Concierge created action'); + } + + // When the reader reveals older history. + act(() => getShowHistoryPress(createdAction, createdIndex)?.()); + view.rerender( + , + ); + act(() => { + for (const callback of animationFrames.splice(0)) { + callback(0); + } + }); + + // Then the reader's offset is left to LegendList's visible-content preservation. + expect(mockLegendScrollToEnd).not.toHaveBeenCalled(); + } finally { + requestAnimationFrameSpy.mockRestore(); + } }); }); @@ -1653,7 +3192,7 @@ describe('ReportActionsList (body)', () => { }, ]; - const setupMainDMConciergeMocks = (showFullHistory = false) => { + const setupMainDMConciergeMocks = (showFullHistory = false, hasOnceLoadedReportActions = true) => { jest.spyOn(ReportActionsUtils, 'shouldReportActionBeVisible').mockReturnValue(true); mockUseNetwork.mockReturnValue({isOffline: false}); mockUseIsInSidePanel.mockReturnValue(false); @@ -1672,7 +3211,7 @@ describe('ReportActionsList (body)', () => { return [false, {status: 'loaded'}]; } if (key.includes('reportLoadingState')) { - return [getMockReportLoadingState(options?.selector), {status: 'loaded'}]; + return [getMockReportLoadingState(options?.selector, hasOnceLoadedReportActions, !hasOnceLoadedReportActions), {status: 'loaded'}]; } if (key === `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${CONCIERGE_REPORT_ID}` && options?.selector) { const reportActions = Object.fromEntries(oldReportActions.map((action) => [action.reportActionID, action])); @@ -1709,7 +3248,7 @@ describe('ReportActionsList (body)', () => { // Then the welcome screen shows and the earlier conversation stays behind `View chat history` expect(screen.getByTestId('AskConciergeWelcome')).toBeTruthy(); - expect(mockInvertedFlashList).not.toHaveBeenCalled(); + expect(mockLegendList).not.toHaveBeenCalled(); }); it('should show the whole conversation without a greeting once the history is expanded', () => { @@ -1725,11 +3264,48 @@ describe('ReportActionsList (body)', () => { renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); // Then every earlier message shows, because only the side panel collapses its history - expect(mockInvertedFlashList).toHaveBeenCalled(); + expect(mockLegendList).toHaveBeenCalled(); const passedActions = getCapturedVisibleActions(); expect(passedActions?.some((a) => a.reportActionID === CONST.CONCIERGE_GREETING_ACTION_ID)).toBe(false); expect(passedActions?.some((a) => a.reportActionID === 'old-user-msg')).toBe(true); expect(passedActions?.some((a) => a.reportActionID === 'old-concierge-msg')).toBe(true); }); + it('should cover cached actions until the refreshed report finishes hydrating', () => { + // Given a page refresh: hasOnceLoadedReportActions is RAM-only and resets to false, + // but report actions persist in Onyx cache. Keep the cached list covered until OpenReport + // finishes so the final hydrated viewport is the first report content the user sees. + setupMainDMConciergeMocks(true, false); + + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: oldReportActions, + hasOlderActions: false, + }); + + // When the chat opens before OpenReport returns. + renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); + + // Then the initial viewport stays covered. + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(mockLegendList).toHaveBeenCalled(); + }); + + it('should show a skeleton on a cold load when hasOnceLoadedReportActions is false and there are no cached actions', () => { + // Given an expanded Concierge chat with no cached actions or completed initial load. + setupMainDMConciergeMocks(true, false); + + mockUsePaginatedReportActions.mockReturnValue({ + ...defaultPaginatedReportActionsResult, + reportActions: [], + hasOlderActions: false, + }); + + // When the chat opens before OpenReport returns. + renderReportActionsList({reportID: CONCIERGE_REPORT_ID}); + + // Then the initial viewport stays covered. + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(mockLegendList).toHaveBeenCalled(); + }); }); }); diff --git a/tests/ui/SkeletonBelowLinkedActionTest.tsx b/tests/ui/SkeletonBelowLinkedActionTest.tsx new file mode 100644 index 000000000000..50870fc6c122 --- /dev/null +++ b/tests/ui/SkeletonBelowLinkedActionTest.tsx @@ -0,0 +1,115 @@ +import {act, render, screen} from '@testing-library/react-native'; + +import {ActionListContext} from '@pages/inbox/ActionListContext'; +import SkeletonBelowLinkedAction from '@pages/inbox/report/ReportActionsList/SkeletonBelowLinkedAction'; + +import React from 'react'; + +jest.mock('@hooks/useNetwork', () => jest.fn(() => ({isOffline: false}))); + +const LINKED_ACTION_INDEX = 4; +const pendingFrames: FrameRequestCallback[] = []; +const renderedRowIndices = new Set(); +const measuredRowSizes = new Map(); + +// Queue animation frames so each measurement check runs only when the test flushes it. +jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => pendingFrames.push(callback)); +jest.spyOn(global, 'cancelAnimationFrame').mockImplementation(() => {}); + +function flushFrames() { + act(() => { + for (const callback of pendingFrames.splice(0)) { + callback(0); + } + }); +} + +// Built via a function so the value isn't an inline literal the context-split lint rule would flag; these are all refs/accessors with no re-render concern. +function buildActionListContextValue() { + return { + scrollOffsetRef: {current: 0}, + getScrollOffset: () => 0, + registerListRef: () => {}, + getListRef: () => ({ + current: { + scrollToIndex: jest.fn(), + scrollToEnd: jest.fn(), + scrollToOffset: jest.fn(), + getState: () => ({ + positionByKey: () => undefined, + sizeAtIndex: (index: number) => measuredRowSizes.get(index), + elementAtIndex: (index: number) => (renderedRowIndices.has(index) ? {} : undefined), + }), + }, + }), + }; +} + +function renderSkeleton(hasNewerRows: boolean, onNewerRowMeasured: () => void) { + return render( + + + , + ); +} + +describe('SkeletonBelowLinkedAction', () => { + beforeEach(() => { + pendingFrames.length = 0; + renderedRowIndices.clear(); + measuredRowSizes.clear(); + }); + + it('stays until LegendList measures the first row below the linked action', () => { + // Given a linked action whose newer page has not loaded yet. + const onNewerRowMeasured = jest.fn(); + const view = renderSkeleton(false, onNewerRowMeasured); + flushFrames(); + + // Then the skeleton covers the area, and nothing is checked before rows exist below the link. + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(onNewerRowMeasured).not.toHaveBeenCalled(); + + // When the newer page adds a row that LegendList has rendered but not measured, so it is still hidden. + renderedRowIndices.add(LINKED_ACTION_INDEX + 1); + view.rerender( + + + , + ); + flushFrames(); + + // Then the skeleton stays, because removing it would leave the area blank until the row is revealed. + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(onNewerRowMeasured).not.toHaveBeenCalled(); + + // When LegendList measures the row, which also reveals it. + measuredRowSizes.set(LINKED_ACTION_INDEX + 1, 80); + flushFrames(); + + // Then the skeleton hides at once, because the revealed row now fills the area, and it calls back so the list stops rendering it. + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + expect(onNewerRowMeasured).toHaveBeenCalledTimes(1); + }); + + it('goes away when the first newer row is off screen', () => { + // Given newer rows below the link, but the first one has no container because the reader scrolled far away. + const onNewerRowMeasured = jest.fn(); + renderSkeleton(true, onNewerRowMeasured); + + // When the check runs. + flushFrames(); + + // Then the skeleton goes away, because LegendList will not render that row until it comes back into range. + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + expect(onNewerRowMeasured).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/ActionListContextProviderTest.tsx b/tests/unit/ActionListContextProviderTest.tsx index d255306e5cdc..ad60c4902f6b 100644 --- a/tests/unit/ActionListContextProviderTest.tsx +++ b/tests/unit/ActionListContextProviderTest.tsx @@ -1,14 +1,41 @@ import {act, renderHook} from '@testing-library/react-native'; -import {ActionListContextProvider, useActionListContext} from '@pages/inbox/ActionListContext'; +import {ActionListContextProvider, useActionListContext, useActionListReady, useActionListRef} from '@pages/inbox/ActionListContext'; import type ActionListRefType from '@pages/inbox/ActionListTypes'; +function TestList() { + useActionListRef(); + return null; +} + /** * `ActionListContextProvider` owns a private holder and exposes `registerListRef(ref)` / `getListRef()` * plus the scroll refs so each list owns its ref locally while handlers resolve it at call time. * The tests exercise the public surface (the provider + the `useActionListContext` consumer hook). */ describe('ActionListContextProvider', () => { + it('resets readiness when the positioned list is replaced', () => { + // Given a mounted report whose list has finished its initial positioning. + let listKey = 'first'; + const {result, rerender} = renderHook(() => useActionListReady(), { + wrapper: ({children}) => ( + + {children} + + + ), + }); + act(() => result.current.setIsReady(true)); + expect(result.current.isReady).toBe(true); + + // When another list registers with the same provider, it needs its own initial layout. + listKey = 'second'; + rerender({}); + + // Then the new list starts covered and publishes its ref before any scroll handler runs. + expect(result.current.isReady).toBe(false); + }); + it('getListRef() returns null before any ref is registered', () => { const {result} = renderHook(() => useActionListContext(), {wrapper: ActionListContextProvider}); diff --git a/tests/unit/LegendListItemOverflowTest.ts b/tests/unit/LegendListItemOverflowTest.ts new file mode 100644 index 000000000000..d6559318af66 --- /dev/null +++ b/tests/unit/LegendListItemOverflowTest.ts @@ -0,0 +1,35 @@ +import allowLegendListItemOverflow from '@components/LegendList/allowLegendListItemOverflow'; + +import type {LegendListRef} from '@legendapp/list/react-native'; + +import createMock from '../utils/createMock'; + +describe('allowLegendListItemOverflow', () => { + it('removes paint containment from a web item container', () => { + const style = {contain: 'paint layout style'}; + const list = createMock({ + getState: () => + createMock>({ + elementAtIndex: () => ({style}), + }), + }); + + allowLegendListItemOverflow(list, 3); + expect(style.contain).toBe('layout style'); + }); + + it('does nothing for a native item container', () => { + const setNativeProps = jest.fn(); + const itemContainer = {setNativeProps}; + const list = createMock({ + getState: () => + createMock>({ + elementAtIndex: () => itemContainer, + }), + }); + + allowLegendListItemOverflow(list, 3); + expect(itemContainer).toEqual({setNativeProps}); + expect(setNativeProps).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/ReportActionsListContextTest.ts b/tests/unit/ReportActionsListContextTest.ts index fe5b75acacca..0af245ef91a8 100644 --- a/tests/unit/ReportActionsListContextTest.ts +++ b/tests/unit/ReportActionsListContextTest.ts @@ -43,6 +43,7 @@ function createReadinessSignals(overrides: Partial { + it('shows the loading skeleton while OpenReport is pending for actions without a loaded page', () => { + // Given a read chat whose only cached actions came without a page, e.g. from OpenApp after a cache clear + const signals = createReadinessSignals({ + report: readReport, + isMissingReportActions: false, + reportActionsLength: 3, + hasReportActionPages: false, + isReportLoadPending: true, + }); + + // When the skeleton state is computed while its OpenReport is pending + const state = computeReportActionsSkeletonState(signals); + + // Then the skeleton shows, so the list never opens at scattered older messages that jump away on load + expect(state.shouldShowLoadingSkeleton).toBe(true); + }); + + it('shows the loading skeleton for actions without a loaded page before OpenReport is queued', () => { + // Given a read chat with unpaged cached actions whose screen has not queued OpenReport yet + const signals = createReadinessSignals({ + report: readReport, + isMissingReportActions: false, + reportActionsLength: 3, + hasReportActionPages: false, + isReportLoadPending: false, + isLoadingInitialReportActions: undefined, + }); + + // When the skeleton state is computed on that first render + const state = computeReportActionsSkeletonState(signals); + + // Then the skeleton already shows, so the list does not mount, mark the chat as read and remount without its unread marker + expect(state.shouldShowLoadingSkeleton).toBe(true); + }); + + it('shows cached actions without a loaded page for an optimistic report', () => { + // Given an optimistic chat that only has local actions and no page yet + const signals = createReadinessSignals({ + report: {...readReport, pendingFields: {createReport: 'add'}}, + isMissingReportActions: false, + reportActionsLength: 1, + hasReportActionPages: false, + isReportLoadPending: true, + }); + + // When the skeleton state is computed while its OpenReport is pending + const state = computeReportActionsSkeletonState(signals); + + // Then the local actions stay visible because they are all there is to show + expect(state.shouldShowLoadingSkeleton).toBe(false); + }); + + it('shows cached actions with a loaded page while OpenReport is pending', () => { + // Given a read chat with a page of cached actions from an earlier visit + const signals = createReadinessSignals({ + report: readReport, + isMissingReportActions: false, + reportActionsLength: 3, + isReportLoadPending: true, + }); + + // When the skeleton state is computed while its OpenReport refreshes the chat + const state = computeReportActionsSkeletonState(signals); + + // Then the cached page shows right away + expect(state.shouldShowLoadingSkeleton).toBe(false); + }); + it('shows the loading skeleton for a pending OpenReport matching this report', () => { const state = computeReportActionsSkeletonState( createReadinessSignals({ diff --git a/tests/unit/ReportActionsListThresholdTest.tsx b/tests/unit/ReportActionsListThresholdTest.tsx index ba7c5238ab7f..8857ed995216 100644 --- a/tests/unit/ReportActionsListThresholdTest.tsx +++ b/tests/unit/ReportActionsListThresholdTest.tsx @@ -30,38 +30,47 @@ import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatch const THRESHOLD = CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD; -type ScrollEvent = {nativeEvent: {contentOffset: {y: number}}}; +type ScrollEvent = { + nativeEvent: { + contentOffset: {x: number; y: number}; + contentSize: {height: number; width: number}; + layoutMeasurement: {height: number; width: number}; + }; +}; type CapturedListProps = { - maintainVisibleContentPosition?: {disabled: boolean}; onScroll?: (event: ScrollEvent) => void; }; -// Capture the props the list is rendered with so we can observe `maintainVisibleContentPosition`, whose -// `disabled` flag is `!(hasScrolledOverThreshold || shouldFocusToTopOnMount)`. With no deep-link the latter -// is false, so `!disabled` mirrors the boolean under test. let capturedListProps: CapturedListProps = {}; -// Every value the maintain-visible-content-position flag has held (`!disabled`), in render order. `[0]` is the -// value on the list's very first render — the property that matters, since it must be right before any effect runs. -let mockMvcpHistory: Array = []; - -// `!disabled` from the captured `maintainVisibleContentPosition`, or `undefined` before the list first renders. -function isMvcpEnabled() { - const config = capturedListProps.maintainVisibleContentPosition; - return config ? !config.disabled : undefined; -} +jest.mock('@hooks/useUnreadMarker', () => jest.fn(() => ({unreadMarkerReportActionID: null}))); +jest.mock('@hooks/useMarkAsRead', () => jest.fn(() => ({markNewestActionAsRead: jest.fn(), completeSkippedMarkAsRead: jest.fn()}))); +const mockUseUnreadMarker: jest.Mock = jest.requireMock('@hooks/useUnreadMarker'); +const mockUseMarkAsRead: jest.Mock = jest.requireMock('@hooks/useMarkAsRead'); -jest.mock('@components/FlashList/InvertedFlashList', () => { +jest.mock('@legendapp/list/react-native', () => { const {forwardRef} = jest.requireActual('react'); return { - __esModule: true, - default: forwardRef((props) => { + // The second parameter is intentionally unused; forwardRef requires it to avoid a React development warning. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + LegendList: forwardRef((props, ref) => { capturedListProps = props; - mockMvcpHistory.push(props.maintainVisibleContentPosition ? !props.maintainVisibleContentPosition.disabled : undefined); return null; }), }; }); +function createScrollEvent(distanceFromBottom: number): ScrollEvent { + const contentHeight = 1000; + const viewportHeight = 500; + return { + nativeEvent: { + contentOffset: {x: 0, y: contentHeight - viewportHeight - distanceFromBottom}, + contentSize: {height: contentHeight, width: 300}, + layoutMeasurement: {height: viewportHeight, width: 300}, + }, + }; +} + jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { @@ -111,13 +120,14 @@ async function renderList(initialOffset: number) { , ); - await waitFor(() => expect(capturedListProps.maintainVisibleContentPosition).toBeDefined()); + await waitFor(() => expect(capturedListProps.onScroll).toBeDefined()); return utils; } beforeEach(async () => { capturedListProps = {}; - mockMvcpHistory = []; + mockUseUnreadMarker.mockClear(); + mockUseMarkAsRead.mockClear(); setHasRadio(true); wrapOnyxWithWaitForBatchedUpdates(Onyx); await act(async () => { @@ -142,34 +152,43 @@ afterEach(async () => { await waitForBatchedUpdates(); }); -describe('ReportActionsList hasScrolledOverThreshold', () => { - it('enables maintainVisibleContentPosition on first render when mounted while scrolled past the threshold', async () => { +describe('ReportActionsList visible-action threshold', () => { + it('seeds unread marker and mark-as-read state from a saved offset past the threshold', async () => { + // Given a report restored away from the newest action. await renderList(THRESHOLD + 50); - // Must be true on the FIRST render, not merely after an effect settles — deferring this to an effect - // would let the mount-time mark-as-read path observe a wrong `isScrolledToEnd`. - expect(mockMvcpHistory.at(0)).toBe(true); - expect(isMvcpEnabled()).toBe(true); + // Then neither consumer treats the report as scrolled to the end before onScroll fires. + expect(mockUseUnreadMarker).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledOverThreshold: true})); + expect(mockUseMarkAsRead).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledToEnd: false})); }); - it('leaves maintainVisibleContentPosition off on first render when mounted at the bottom (offset below threshold)', async () => { + it('seeds both consumers as being at the newest action when the saved offset is zero', async () => { + // Given a report restored at its newest action. await renderList(0); - expect(isMvcpEnabled()).toBe(false); + // Then both consumers receive the bottom state immediately. + expect(mockUseUnreadMarker).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledOverThreshold: false})); + expect(mockUseMarkAsRead).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledToEnd: true})); }); - it('flips the flag as the user scrolls across the threshold', async () => { + it('updates both consumers when scrolling across the threshold', async () => { + // Given a report initially at the newest action. await renderList(0); - expect(isMvcpEnabled()).toBe(false); + // When the reader scrolls away from the newest action. act(() => { - capturedListProps.onScroll?.({nativeEvent: {contentOffset: {y: THRESHOLD + 50}}}); + capturedListProps.onScroll?.(createScrollEvent(THRESHOLD + 50)); }); - expect(isMvcpEnabled()).toBe(true); + // Then the unread marker sees the reader as away and mark-as-read pauses. + expect(mockUseUnreadMarker).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledOverThreshold: true})); + expect(mockUseMarkAsRead).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledToEnd: false})); + // When the reader returns to the newest action. act(() => { - capturedListProps.onScroll?.({nativeEvent: {contentOffset: {y: 0}}}); + capturedListProps.onScroll?.(createScrollEvent(0)); }); - expect(isMvcpEnabled()).toBe(false); + // Then both consumers see the bottom state again. + expect(mockUseUnreadMarker).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledOverThreshold: false})); + expect(mockUseMarkAsRead).toHaveBeenLastCalledWith(expect.objectContaining({isScrolledToEnd: true})); }); }); diff --git a/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx b/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx new file mode 100644 index 000000000000..8550c11433c5 --- /dev/null +++ b/tests/unit/ReportActionsPaginationLoadingIndicatorTest.tsx @@ -0,0 +1,66 @@ +import {render, renderHook, screen} from '@testing-library/react-native'; + +import useThemeStyles from '@hooks/useThemeStyles'; + +import ReportActionsPaginationLoadingIndicator from '@pages/inbox/report/ReportActionsPaginationLoadingIndicator'; + +import CONST from '@src/CONST'; + +import type {ComponentType} from 'react'; +import type {ViewProps} from 'react-native'; + +import React from 'react'; +import {StyleSheet} from 'react-native'; + +jest.mock('@components/ActivityIndicator', () => ({ + __esModule: true, + default: ({testID}: {testID?: string}) => { + const {View: MockView} = jest.requireActual<{View: ComponentType}>('react-native'); + return ; + }, +})); + +const OLDER_TEST_ID = 'report-actions-pagination-older'; +const NEWER_TEST_ID = 'report-actions-pagination-newer'; + +describe('ReportActionsPaginationLoadingIndicator', () => { + it('renders only a spinner with generous vertical padding', () => { + // Given a themed pagination indicator at the older edge. + const {result: styles} = renderHook(useThemeStyles); + // When the indicator is rendered. + const view = render(); + + // Then only the padded spinner is visible. + expect(StyleSheet.flatten(screen.getByTestId(OLDER_TEST_ID, {includeHiddenElements: true}).props.style)).toEqual( + expect.objectContaining({ + alignItems: 'center', + height: CONST.PAGINATION_LOADING_INDICATOR_HEIGHT, + justifyContent: 'center', + paddingBottom: styles.current.pb6.paddingBottom, + paddingTop: styles.current.pt18.paddingTop, + }), + ); + expect(CONST.PAGINATION_LOADING_INDICATOR_HEIGHT).toBeGreaterThan(styles.current.pt18.paddingTop + styles.current.pb6.paddingBottom); + expect(screen.getByTestId(`${OLDER_TEST_ID}-spinner`, {includeHiddenElements: true})).toBeOnTheScreen(); + expect(screen.queryByTestId(`${OLDER_TEST_ID}-skeleton`, {includeHiddenElements: true})).toBeNull(); + + view.rerender(); + + expect(screen.getByTestId(`${NEWER_TEST_ID}-spinner`, {includeHiddenElements: true})).toBeOnTheScreen(); + }); + + it('keeps pagination loading UI out of interaction and accessibility', () => { + // Given pagination is loading at the newer edge. + // When its indicator is rendered. + render(); + + // Then the decorative spinner cannot receive focus or touches. + expect(screen.getByTestId(NEWER_TEST_ID, {includeHiddenElements: true}).props).toEqual( + expect.objectContaining({ + accessibilityElementsHidden: true, + importantForAccessibility: 'no-hide-descendants', + pointerEvents: 'none', + }), + ); + }); +}); diff --git a/tests/unit/ReportUtilsTest.ts b/tests/unit/ReportUtilsTest.ts index d7dfcd06c22f..016f9426019a 100644 --- a/tests/unit/ReportUtilsTest.ts +++ b/tests/unit/ReportUtilsTest.ts @@ -176,6 +176,7 @@ import { getWhisperDisplayNames, getWorkspaceIcon, getWorkspaceNameUpdatedMessage, + navigateToLinkedReportAction, hasActionWithErrorsForTransaction, hasEmptyReportsForPolicy, hasExpensifyGuidesEmails, @@ -369,6 +370,7 @@ jest.mock('@libs/Log', () => ({ })); jest.mock('@libs/Navigation/Navigation', () => ({ + goBack: jest.fn(), setNavigationActionToMicrotaskQueue: jest.fn(), navigate: jest.fn(), getActiveRoute: jest.fn(() => 'mock-route'), @@ -627,6 +629,35 @@ describe('ReportUtils', () => { }); beforeEach(() => IntlStore.load(CONST.LOCALES.DEFAULT).then(waitForBatchedUpdates)); + describe('navigateToLinkedReportAction', () => { + it('returns to the linked parent action using cached visibility', () => { + // Given the thread's parent action is visible in the cached report, including while offline. + const parentReport = {...createRandomReport(1), reportID: '1234'}; + const parentAction = {...createRandomReportAction(1), reportActionID: '5678', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}; + jest.mocked(Navigation.goBack).mockClear(); + + // When the reader taps the parent message, navigation must carry its link back to the parent. + navigateToLinkedReportAction({report: parentReport, reportAction: parentAction, shouldDisplayNewMarker: false}, false, true, {'1234': {'5678': true}}); + + // Then the parent can position and highlight the message instead of restoring an unrelated viewport. + expect(Navigation.goBack).toHaveBeenLastCalledWith(ROUTES.REPORT_WITH_ID.getRoute('1234', '5678')); + }); + + it('does not link a parent action hidden from the reader', () => { + // Given a cached parent action that the visibility rules exclude. + const parentReport = {...createRandomReport(1), reportID: '1234'}; + const parentAction = {...createRandomReportAction(1), reportActionID: '5678', actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}; + jest.mocked(Navigation.goBack).mockClear(); + + // When the reader returns from its thread. + navigateToLinkedReportAction({report: parentReport, reportAction: parentAction, shouldDisplayNewMarker: false}, false, true, {'1234': {'5678': false}}); + + // Then the ordinary report remains reachable without targeting a message it cannot display. + expect(Navigation.goBack).toHaveBeenCalledTimes(1); + expect(Navigation.goBack).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('1234')); + }); + }); + describe('getIOUReportActionDisplayMessage', () => { const iouReportID = '1234567890'; const policyID = 332; diff --git a/tests/unit/hooks/useShouldSkipComposerAutoFocus.test.tsx b/tests/unit/hooks/useShouldSkipComposerAutoFocus.test.tsx index 4cdbca8c30e9..2f4d1944f74e 100644 --- a/tests/unit/hooks/useShouldSkipComposerAutoFocus.test.tsx +++ b/tests/unit/hooks/useShouldSkipComposerAutoFocus.test.tsx @@ -1,5 +1,6 @@ import {act, renderHook} from '@testing-library/react-native'; +import {ActionListContextProvider, useActionListReady} from '@pages/inbox/ActionListContext'; import useShouldSkipComposerAutoFocus from '@pages/inbox/report/ReportActionCompose/useShouldSkipComposerAutoFocus'; import CONST from '@src/CONST'; @@ -76,4 +77,16 @@ describe('useShouldSkipComposerAutoFocus', () => { // Then the composer auto-focuses as usual expect(result.current).toBe(false); }); + + it('waits for the message viewport to appear before allowing automatic focus', () => { + // Given a report whose composer mounts while its messages are still covered by the skeleton. + const {result} = renderHook(() => ({skipAutoFocus: useShouldSkipComposerAutoFocus(chatReport), readiness: useActionListReady()}), {wrapper: ActionListContextProvider}); + expect(result.current.skipAutoFocus).toBe(true); + + // When the list reports its positioned viewport is visible. + act(() => result.current.readiness.setIsReady(true)); + + // Then automatic focus can resume, including for a draft restored before the list was ready. + expect(result.current.skipAutoFocus).toBe(false); + }); }); diff --git a/tests/unit/shouldFollowActionBadgeTargetTest.ts b/tests/unit/shouldFollowActionBadgeTargetTest.ts index eb84959a583f..1f159dd81367 100644 --- a/tests/unit/shouldFollowActionBadgeTargetTest.ts +++ b/tests/unit/shouldFollowActionBadgeTargetTest.ts @@ -3,17 +3,17 @@ import shouldFollowActionBadgeTarget from '@pages/inbox/report/shouldFollowActio const BASE_PARAMS = { actionTargetReportActionID: '200', prevActionTargetReportActionID: '100', - actionBadgeTargetIndex: 2, + actionBadgeTargetIndex: 7, prevActionBadgeTargetIndex: 5, }; describe('shouldFollowActionBadgeTarget', () => { - it('follows the target when it advances to a newer (lower-index) preview', () => { + it('follows the target when it advances to a newer (higher-index) preview', () => { expect(shouldFollowActionBadgeTarget(BASE_PARAMS)).toBe(true); }); - it('does not follow when the target moves to an older (higher-index) preview, e.g. while paginating', () => { - expect(shouldFollowActionBadgeTarget({...BASE_PARAMS, actionBadgeTargetIndex: 7})).toBe(false); + it('does not follow when the target moves to an older (lower-index) preview, e.g. while paginating', () => { + expect(shouldFollowActionBadgeTarget({...BASE_PARAMS, actionBadgeTargetIndex: 2})).toBe(false); }); it('does not follow when the target index is unchanged', () => { diff --git a/tests/unit/useFollowActionBadgeTargetTest.ts b/tests/unit/useFollowActionBadgeTargetTest.ts index 42a0750c160e..12772cc78196 100644 --- a/tests/unit/useFollowActionBadgeTargetTest.ts +++ b/tests/unit/useFollowActionBadgeTargetTest.ts @@ -40,11 +40,12 @@ describe('useFollowActionBadgeTarget', () => { }); it('follows a newer badge target when the previous target is hidden inside a collapsed run', () => { + // Given the previous target is represented by an older summary row in the chronological list. const scrollToActionBadgeTarget = jest.fn(); - const renderedVisibleReportActions = [makeAction('current'), makeAction('summary')]; + const renderedVisibleReportActions = [makeAction('summary'), makeAction('current')]; const reportActionIDToDisplayIndex = new Map([ - ['current', 0], - ['previous', 1], + ['previous', 0], + ['current', 1], ]); const {rerender} = renderHook( ({actionTargetReportActionID, actionBadgeTargetIndex}: {actionTargetReportActionID: string; actionBadgeTargetIndex: number}) => @@ -56,11 +57,13 @@ describe('useFollowActionBadgeTarget', () => { reportActionIDToDisplayIndex, scrollToActionBadgeTarget, }), - {initialProps: {actionTargetReportActionID: 'previous', actionBadgeTargetIndex: 1}}, + {initialProps: {actionTargetReportActionID: 'previous', actionBadgeTargetIndex: 0}}, ); - rerender({actionTargetReportActionID: 'current', actionBadgeTargetIndex: 0}); + // When the badge advances to a newer action outside the collapsed run. + rerender({actionTargetReportActionID: 'current', actionBadgeTargetIndex: 1}); + // Then the list follows the new target using the hidden action's mapped position. expect(scrollToActionBadgeTarget).toHaveBeenCalledTimes(1); }); }); diff --git a/tests/unit/useMoneyRequestReportScrollTest.tsx b/tests/unit/useMoneyRequestReportScrollTest.tsx index 8c2e0270e4e3..95cad9fd3073 100644 --- a/tests/unit/useMoneyRequestReportScrollTest.tsx +++ b/tests/unit/useMoneyRequestReportScrollTest.tsx @@ -31,13 +31,12 @@ const BOTTOM_OFFSET_AT_TOP = LIST_CONTENT_HEIGHT - LIST_LAYOUT_HEIGHT; const LAST_ITEM_INDEX = 4; const mockScrollToIndex = jest.fn(); -const mockScrollToEnd = jest.fn(); +const mockScrollToBottom = jest.fn(); jest.mock('@hooks/useReportScrollManager', () => ({ __esModule: true, default: () => ({ scrollToIndex: mockScrollToIndex, - scrollToEnd: mockScrollToEnd, - scrollToBottom: jest.fn(), + scrollToBottom: mockScrollToBottom, scrollToOffset: jest.fn(), }), })); @@ -559,7 +558,7 @@ describe('useMoneyRequestReportScroll', () => { }); // Then the list reveals the indicator, which lives in the footer below the row a last-item jump targets - expect(mockScrollToEnd).toHaveBeenCalledTimes(1); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); it('should not scroll for the thinking indicator while the list is away from the bottom', async () => { @@ -576,7 +575,7 @@ describe('useMoneyRequestReportScroll', () => { }); // Then the reading position is untouched - expect(mockScrollToEnd).not.toHaveBeenCalled(); + expect(mockScrollToBottom).not.toHaveBeenCalled(); expect(result.current.isFloatingMessageCounterVisible).toBe(false); }); @@ -589,7 +588,7 @@ describe('useMoneyRequestReportScroll', () => { act(() => { jest.advanceTimersByTime(100); }); - expect(mockScrollToEnd).toHaveBeenCalledTimes(1); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); // When the thinking label changes, which re-renders the list many times per run rerender(buildParams()); @@ -600,7 +599,7 @@ describe('useMoneyRequestReportScroll', () => { }); // Then the viewport is not yanked down again - expect(mockScrollToEnd).toHaveBeenCalledTimes(1); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); it('should scroll again for a new thinking-indicator run', async () => { @@ -612,7 +611,7 @@ describe('useMoneyRequestReportScroll', () => { act(() => { jest.advanceTimersByTime(100); }); - expect(mockScrollToEnd).toHaveBeenCalledTimes(1); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); // When that run ends and another one starts mockCandidateAgentIDs = []; @@ -626,7 +625,7 @@ describe('useMoneyRequestReportScroll', () => { }); // Then the new run's indicator is revealed too - expect(mockScrollToEnd).toHaveBeenCalledTimes(2); + expect(mockScrollToBottom).toHaveBeenCalledTimes(2); }); }); diff --git a/tests/unit/useReportActionsNewActionLiveTailTest.ts b/tests/unit/useReportActionsNewActionLiveTailTest.ts index 30824469f62b..e0ce58270b07 100644 --- a/tests/unit/useReportActionsNewActionLiveTailTest.ts +++ b/tests/unit/useReportActionsNewActionLiveTailTest.ts @@ -1,4 +1,4 @@ -import {act, renderHook} from '@testing-library/react-native'; +import {act, renderHook, waitFor} from '@testing-library/react-native'; import {FALLBACK_NAVIGATION_CONTEXT_VALUE} from '@components/withNavigationFallback'; @@ -17,7 +17,7 @@ const mockNavigationSetParams = jest.fn(); const mockGlobalSetParams = jest.fn(); const mockOpenReport = jest.fn(); const mockLogAlert = jest.fn(); -let newActionHandler: ((isFromCurrentUser: boolean, action?: ReportAction) => void) | undefined; +let newActionHandler: ((isFromCurrentUser: boolean, action: ReportAction | undefined, source: 'local' | 'realtime') => void) | undefined; jest.mock('@libs/Log', () => ({ __esModule: true, @@ -78,7 +78,7 @@ jest.mock('@libs/actions/Report', () => ({ })); jest.mock('@libs/actions/Report/reportActionSubscribers', () => ({ - subscribeToNewActionEvent: (_reportID: string, callback: (isFromCurrentUser: boolean, action?: ReportAction) => void) => { + subscribeToNewActionEvent: (_reportID: string, callback: (isFromCurrentUser: boolean, action: ReportAction | undefined, source: 'local' | 'realtime') => void) => { newActionHandler = callback; return jest.fn(); }, @@ -89,7 +89,6 @@ type HookParams = Parameters[0]; const reportScrollManager = { scrollToBottom: jest.fn(), scrollToIndex: jest.fn(), - scrollToEnd: jest.fn(), scrollToOffset: jest.fn(), }; @@ -105,7 +104,7 @@ function buildParams(overrides: Partial = {}): HookParams { hasNewerActions: true, linkedReportActionID: undefined, hasNewestReportAction: false, - sortedVisibleReportActions: [], + renderedVisibleReportActions: [], sortedAllReportActionsForPagination: [], reportActionPages: undefined, setTreatAsNoPaginationAnchor: jest.fn(), @@ -125,15 +124,191 @@ describe('useReportActionsNewActionLiveTail', () => { mockIsInSidePanel = false; }); + it.each([false, true])('reaches the cached newest window offline when in side panel is %s', async (isInSidePanel) => { + // Given an offline report whose newest cached page does not contain the newest server message. + mockIsInSidePanel = isInSidePanel; + const linkedAction = getFakeReportAction(2); + const newestCachedAction = getFakeReportAction(3); + const params = buildParams({ + isOffline: true, + linkedReportActionID: linkedAction.reportActionID, + renderedVisibleReportActions: [linkedAction], + sortedAllReportActionsForPagination: [newestCachedAction, linkedAction], + reportActionPages: [[newestCachedAction.reportActionID], [linkedAction.reportActionID]], + }); + const {result, rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: params}); + + // When Latest messages is pressed, release the anchor locally instead of waiting for OpenReport. + act(() => result.current.requestLiveTailJump()); + expect(mockOpenReport).not.toHaveBeenCalled(); + expect(params.setTreatAsNoPaginationAnchor).toHaveBeenCalledWith(true); + expect(mockNavigationSetParams).toHaveBeenCalledTimes(isInSidePanel ? 0 : 1); + expect(mockGlobalSetParams).not.toHaveBeenCalled(); + expect(result.current.isScrollToBottomEnabled).toBe(false); + + // Then request the scroll after the cached newest window enters the list. + const newestCachedParams = {...params, linkedReportActionID: '', treatAsNoPaginationAnchor: true, renderedVisibleReportActions: [newestCachedAction]}; + rerender(newestCachedParams); + await waitFor(() => expect(result.current.isScrollToBottomEnabled).toBe(true)); + act(() => { + result.current.setIsScrollToBottomEnabled(false); + result.current.completeLiveTailPruneAfterScrollToBottom(); + }); + expect(params.setTreatAsNoPaginationAnchor).toHaveBeenLastCalledWith(false); + + // When the reader scrolls away and presses Latest messages again, the completed jump must not block it. + rerender({...newestCachedParams, treatAsNoPaginationAnchor: false}); + act(() => result.current.requestLiveTailJump()); + expect(result.current.isScrollToBottomEnabled).toBe(false); + expect(params.setTreatAsNoPaginationAnchor).toHaveBeenLastCalledWith(true); + rerender(newestCachedParams); + + // Then another local scroll is requested without ever needing the uncached server message. + await waitFor(() => expect(result.current.isScrollToBottomEnabled).toBe(true)); + }); + + it('waits for the newest page before requesting the Latest messages scroll', async () => { + // Given a historical page, with its linked action still anchoring pagination. + const params = buildParams({linkedReportActionID: 'old-action'}); + const {result, rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: params}); + + // When Latest messages is pressed repeatedly while its request is pending. + act(() => { + result.current.requestLiveTailJump(); + result.current.requestLiveTailJump(); + }); + await waitForBatchedUpdates(); + expect(mockOpenReport).toHaveBeenCalledTimes(1); + expect(result.current.isScrollToBottomEnabled).toBe(false); + expect(mockNavigationSetParams).not.toHaveBeenCalled(); + + // Then completing the request releases that report's anchor, but still waits for the newest row to render. + const loadedParams = {...params, prevIsLoadingInitialReportActions: true, reportLoadingState: {isLoadingInitialReportActions: false}}; + rerender(loadedParams); + expect(mockNavigationSetParams).toHaveBeenCalledWith({reportActionID: ''}); + expect(mockGlobalSetParams).not.toHaveBeenCalled(); + expect(result.current.isScrollToBottomEnabled).toBe(false); + + rerender({...loadedParams, linkedReportActionID: '', treatAsNoPaginationAnchor: true, hasNewestReportAction: true}); + await act(async () => Promise.resolve()); + expect(result.current.isScrollToBottomEnabled).toBe(true); + act(() => { + result.current.setIsScrollToBottomEnabled(false); + result.current.completeLiveTailPruneAfterScrollToBottom(); + }); + rerender({...loadedParams, linkedReportActionID: '', hasNewestReportAction: true}); + expect(result.current.isScrollToBottomEnabled).toBe(false); + expect(mockOpenReport).toHaveBeenCalledTimes(1); + }); + + it('requests one post-render scroll once the sent comment is rendered', () => { + const action = getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}); + const {result, rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), { + initialProps: buildParams({hasNewerActions: false, hasNewestReportAction: true}), + }); + + act(() => { + newActionHandler?.(true, action, 'local'); + }); + + expect(result.current.isScrollToBottomEnabled).toBe(false); + expect(reportScrollManager.scrollToBottom).not.toHaveBeenCalled(); + + rerender(buildParams({hasNewerActions: false, hasNewestReportAction: true, renderedVisibleReportActions: [action]})); + + expect(result.current.isScrollToBottomEnabled).toBe(true); + }); + + it('clears a pending sent-comment scroll when switching reports', () => { + // Given a sent action that has not entered the rendered page yet. + const action = getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}); + const params = buildParams({hasNewerActions: false, hasNewestReportAction: true}); + const {result, rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: params}); + act(() => newActionHandler?.(true, action, 'local')); + + // When the reader switches reports and returns before the timeout, with the row now loaded. + rerender({...params, reportID: '2'}); + rerender({...params, renderedVisibleReportActions: [action]}); + + // Then the old send request does not reclaim the viewport. + expect(result.current.isScrollToBottomEnabled).toBe(false); + }); + + it('does not scroll for a payload-less realtime echo', () => { + const {result} = renderHook(() => useReportActionsNewActionLiveTail(buildParams({hasNewerActions: false, hasNewestReportAction: true}))); + + act(() => { + newActionHandler?.(true, undefined, 'realtime'); + }); + + expect(result.current.isScrollToBottomEnabled).toBe(false); + expect(mockOpenReport).not.toHaveBeenCalled(); + }); + + it('keeps the live-tail jump for a local action without a report action payload', async () => { + // Given a report showing an older page + renderHook(() => useReportActionsNewActionLiveTail(buildParams())); + + // When a local money request sends a payload-less notification + act(() => { + newActionHandler?.(true, undefined, 'local'); + }); + await waitForBatchedUpdates(); + + // Then the report opens its latest page + expect(mockOpenReport).toHaveBeenCalledTimes(1); + }); + + it('expires a sent-comment scroll request when the action never enters the list', () => { + // Given a sent action that is filtered out or fails to appear + jest.useFakeTimers(); + const action = getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}); + const {result, rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), { + initialProps: buildParams({hasNewerActions: false, hasNewestReportAction: true}), + }); + act(() => { + newActionHandler?.(true, action, 'local'); + }); + + // When the action finally appears well after the send attempt + act(() => { + jest.advanceTimersByTime(10000); + }); + rerender(buildParams({hasNewerActions: false, hasNewestReportAction: true, renderedVisibleReportActions: [action]})); + + // Then the stale request does not pull the reader to the bottom + expect(result.current.isScrollToBottomEnabled).toBe(false); + jest.useRealTimers(); + }); + + it('does not queue a bottom scroll that would compete with a report-preview target', () => { + const preview = getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW}); + const {result} = renderHook(() => useReportActionsNewActionLiveTail(buildParams({hasNewerActions: false, hasNewestReportAction: true, renderedVisibleReportActions: [preview]}))); + + act(() => { + newActionHandler?.(true, preview, 'local'); + }); + + expect(reportScrollManager.scrollToBottom).toHaveBeenCalledTimes(1); + expect(result.current.isScrollToBottomEnabled).toBe(false); + }); + it('shows the cached newest window when sending offline from a linked action', () => { - // Given an older linked action while offline + // Given an older linked action while offline. const params = buildParams({isOffline: true, linkedReportActionID: '2'}); - const {result} = renderHook(() => useReportActionsNewActionLiveTail(params)); - // When the current user sends a comment - act(() => newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}))); - // Then reveal the cached live tail and release the override after scrolling + const action = getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}); + const {result, rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: params}); + + // When the current user sends a comment, release the old pagination anchor without a request. + act(() => newActionHandler?.(true, action, 'local')); expect(params.setTreatAsNoPaginationAnchor).toHaveBeenLastCalledWith(true); expect(mockNavigationSetParams.mock.calls).toEqual([[{reportActionID: ''}]]); + expect(mockOpenReport).not.toHaveBeenCalled(); + expect(result.current.isScrollToBottomEnabled).toBe(false); + + // Then scroll only after the chronological list contains the sent comment, and release the override afterwards. + rerender({...params, linkedReportActionID: '', treatAsNoPaginationAnchor: true, hasNewestReportAction: true, renderedVisibleReportActions: [action]}); + expect(result.current.isScrollToBottomEnabled).toBe(true); act(() => result.current.completeLiveTailPruneAfterScrollToBottom()); expect(params.setTreatAsNoPaginationAnchor).toHaveBeenLastCalledWith(false); }); @@ -152,7 +327,7 @@ describe('useReportActionsNewActionLiveTail', () => { // When the current user sends a comment while the newest actions are not loaded act(() => { - newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}), 'local'); }); await waitForBatchedUpdates(); @@ -169,14 +344,15 @@ describe('useReportActionsNewActionLiveTail', () => { ); }); - it('returns the openReport helper that the "Latest messages" pill reuses', async () => { - // Given the hook for report 1. The pill in useReportActionsScroll calls this helper instead of reading the inputs itself. + it('returns the live-tail request that the "Latest messages" pill reuses', async () => { + // Given the hook for report 1. The pill in useReportActionsScroll calls this request instead of reading the openReport inputs itself. const {result} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams()}); - // When the pill calls the returned helper - await act(async () => { - await result.current.openReportAtLiveTail(); + // When the pill requests the live tail + act(() => { + result.current.requestLiveTailJump(); }); + await waitForBatchedUpdates(); // Then openReport runs once, for this report expect(mockOpenReport).toHaveBeenCalledTimes(1); @@ -187,7 +363,7 @@ describe('useReportActionsNewActionLiveTail', () => { const {rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams()}); act(() => { - newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}), 'local'); }); await waitForBatchedUpdates(); @@ -211,7 +387,7 @@ describe('useReportActionsNewActionLiveTail', () => { const {rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams({setTreatAsNoPaginationAnchor})}); act(() => { - newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}), 'local'); }); expect(() => @@ -233,7 +409,7 @@ describe('useReportActionsNewActionLiveTail', () => { const {rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), {initialProps: buildParams({setTreatAsNoPaginationAnchor})}); act(() => { - newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}), 'local'); }); rerender( @@ -252,20 +428,22 @@ describe('useReportActionsNewActionLiveTail', () => { it('scrolls straight to the bottom when an unread marker is set but the newest actions are already loaded', async () => { // Given a report scrolled up with an unread marker from another user's comment, and the newest actions already loaded const setIsFloatingMessageCounterVisible = jest.fn(); - const {result} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), { + const action = getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}); + const {result, rerender} = renderHook((props: HookParams) => useReportActionsNewActionLiveTail(props), { initialProps: buildParams({unreadMarkerReportActionID: '2', hasNewerActions: false, hasNewestReportAction: true, setIsFloatingMessageCounterVisible}), }); // When the current user sends a comment act(() => { - newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + newActionHandler?.(true, action, 'local'); }); await waitForBatchedUpdates(); - // Then the list scrolls to the bottom directly, without an openReport round trip + // Then the caller receives a scroll request once the optimistic row renders, without an openReport round trip. expect(mockOpenReport).not.toHaveBeenCalled(); expect(setIsFloatingMessageCounterVisible).toHaveBeenCalledWith(false); - expect(reportScrollManager.scrollToBottom).toHaveBeenCalledTimes(1); + expect(result.current.isScrollToBottomEnabled).toBe(false); + rerender(buildParams({unreadMarkerReportActionID: '2', hasNewerActions: false, hasNewestReportAction: true, renderedVisibleReportActions: [action]})); expect(result.current.isScrollToBottomEnabled).toBe(true); }); @@ -277,7 +455,7 @@ describe('useReportActionsNewActionLiveTail', () => { // When the current user sends a comment act(() => { - newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT})); + newActionHandler?.(true, getFakeReportAction(1, {actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}), 'local'); }); await waitForBatchedUpdates(); diff --git a/tests/unit/useReportActionsPaginationScrollTest.tsx b/tests/unit/useReportActionsPaginationScrollTest.tsx new file mode 100644 index 000000000000..5ac857c6b0dd --- /dev/null +++ b/tests/unit/useReportActionsPaginationScrollTest.tsx @@ -0,0 +1,287 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useReportActionsPaginationScroll, {REPORT_ACTIONS_PAGINATION_THRESHOLD} from '@hooks/useReportActionsPaginationScroll'; + +const VIEWPORT_HEIGHT = 500; +const NEWER_PAGINATION_EXTENT = 168; +const OLDER_PAGINATION_EXTENT = 128; +const CONTENT_HEIGHT = 3000; +const BOUNDARY_DISTANCE = VIEWPORT_HEIGHT * 0.25; + +const mockLoadOlderActions = jest.fn(); +const mockLoadNewerActions = jest.fn(); +const mockAnimationFrames: FrameRequestCallback[] = []; +const mockTransitionCallbacks: Array<() => void> = []; +let mockIsSearchTopmostFullScreenRoute = false; +let mockTransitionsAreImmediate = false; + +jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => () => mockIsSearchTopmostFullScreenRoute); +jest.mock('@libs/Navigation/TransitionTracker', () => ({ + __esModule: true, + default: { + runAfterTransitions: ({callback}: {callback: () => void}) => { + if (mockTransitionsAreImmediate) { + callback(); + } else { + mockTransitionCallbacks.push(callback); + } + return {cancel: jest.fn()}; + }, + }, +})); + +let listMetrics = { + contentLength: CONTENT_HEIGHT, + scroll: 0, + scrollLength: VIEWPORT_HEIGHT, +}; + +// The hook reads only these three values from LegendList's much larger diagnostic state. +const listRef = { + current: { + getState: () => listMetrics, + }, +}; + +type HookParams = Parameters[0]; + +function buildParams(overrides: Partial = {}): HookParams { + return { + reportID: 'report-1', + linkedReportActionID: undefined, + listRef, + olderPaginationExtent: OLDER_PAGINATION_EXTENT, + newerPaginationExtent: NEWER_PAGINATION_EXTENT, + olderCursor: 'older-1', + newerCursor: 'newer-1', + hasOlderActions: true, + hasNewerActions: true, + isLoadingOlderReportActions: false, + isLoadingNewerReportActions: false, + hasLoadingOlderReportActionsError: false, + hasLoadingNewerReportActionsError: false, + isOffline: false, + canLoadOlder: true, + canLoadNewer: true, + loadOlderActions: mockLoadOlderActions, + loadNewerActions: mockLoadNewerActions, + ...overrides, + }; +} + +function setListMetrics(offset: number, contentHeight = CONTENT_HEIGHT, viewportHeight = VIEWPORT_HEIGHT) { + listMetrics = { + contentLength: contentHeight, + scroll: offset, + scrollLength: viewportHeight, + }; +} + +function flushAnimationFrames() { + while (mockAnimationFrames.length > 0) { + act(() => { + for (const callback of mockAnimationFrames.splice(0)) { + callback(0); + } + }); + } +} + +function flushTransitions() { + act(() => { + for (const callback of mockTransitionCallbacks.splice(0)) { + callback(); + } + }); +} + +describe('useReportActionsPaginationScroll', () => { + beforeAll(() => { + jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => { + mockAnimationFrames.push(callback); + return mockAnimationFrames.length; + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockAnimationFrames.length = 0; + mockTransitionCallbacks.length = 0; + mockIsSearchTopmostFullScreenRoute = false; + mockTransitionsAreImmediate = false; + listMetrics = { + contentLength: CONTENT_HEIGHT, + scroll: 0, + scrollLength: VIEWPORT_HEIGHT, + }; + }); + + it('triggers both directions at 25% of the real-message boundary after subtracting pagination extents', () => { + const {result} = renderHook(() => useReportActionsPaginationScroll(buildParams())); + const olderBoundaryOffset = OLDER_PAGINATION_EXTENT + BOUNDARY_DISTANCE; + const newerBoundaryOffset = CONTENT_HEIGHT - VIEWPORT_HEIGHT - NEWER_PAGINATION_EXTENT - BOUNDARY_DISTANCE; + + expect(REPORT_ACTIONS_PAGINATION_THRESHOLD).toBe(0.25); + + act(() => { + setListMetrics(olderBoundaryOffset + 1); + result.current.onScroll(); + setListMetrics(olderBoundaryOffset); + result.current.onScroll(); + }); + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + + act(() => { + setListMetrics(newerBoundaryOffset - 1); + result.current.onScroll(); + setListMetrics(newerBoundaryOffset); + result.current.onScroll(); + }); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(1); + }); + + it('deduplicates repeated boundary events for the same actual request cursors', () => { + const {result} = renderHook(() => useReportActionsPaginationScroll(buildParams())); + + act(() => { + setListMetrics(0); + result.current.onScroll(); + result.current.onScroll(); + setListMetrics(CONTENT_HEIGHT); + result.current.onScroll(); + result.current.onScroll(); + }); + + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(1); + }); + + it('rechecks stationary geometry after a cursor advances and after content size settles', () => { + const initialParams = buildParams({hasOlderActions: false}); + const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); + + setListMetrics(CONTENT_HEIGHT); + act(() => { + result.current.onScroll(); + }); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(1); + + rerender({...initialParams, newerCursor: 'newer-2'}); + flushAnimationFrames(); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(2); + + rerender({...initialParams, newerCursor: 'newer-3'}); + act(() => { + result.current.onContentSizeChange(); + }); + listMetrics = {...listMetrics, contentLength: CONTENT_HEIGHT + 100}; + flushAnimationFrames(); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(3); + }); + + it('uses current availability when a scheduled content-size check runs', () => { + const initialParams = buildParams({hasNewerActions: false}); + const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); + + setListMetrics(0); + act(() => { + result.current.onScroll(); + }); + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + + act(() => { + result.current.onContentSizeChange(); + }); + rerender({...initialParams, hasOlderActions: false, olderCursor: 'older-2'}); + flushAnimationFrames(); + + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + }); + + it('loads newer messages from Search when no transition is active', () => { + // Given Search is open and the transition tracker can invoke its callback immediately. + mockIsSearchTopmostFullScreenRoute = true; + mockTransitionsAreImmediate = true; + const {result} = renderHook(() => useReportActionsPaginationScroll(buildParams({hasOlderActions: false}))); + + // When the reader reaches the newer-message boundary. + setListMetrics(CONTENT_HEIGHT); + act(() => result.current.onScroll()); + flushAnimationFrames(); + + // Then the request starts once without reading a transition handle before it exists. + expect(mockLoadNewerActions).toHaveBeenCalledTimes(1); + }); + + it('cancels delayed Search requests when the pagination window changes or unmounts', () => { + mockIsSearchTopmostFullScreenRoute = true; + const initialParams = buildParams({hasOlderActions: false}); + const firstView = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); + + setListMetrics(CONTENT_HEIGHT); + act(() => { + firstView.result.current.onScroll(); + }); + expect(mockTransitionCallbacks).toHaveLength(1); + + firstView.rerender({...initialParams, linkedReportActionID: 'linked-2'}); + flushTransitions(); + flushAnimationFrames(); + expect(mockLoadNewerActions).not.toHaveBeenCalled(); + + mockTransitionCallbacks.length = 0; + const secondView = renderHook(() => useReportActionsPaginationScroll(initialParams)); + act(() => { + secondView.result.current.onScroll(); + }); + expect(mockTransitionCallbacks).toHaveLength(1); + + secondView.unmount(); + flushTransitions(); + flushAnimationFrames(); + expect(mockLoadNewerActions).not.toHaveBeenCalled(); + }); + + it('blocks loading and stationary error loops but permits a deliberate leave and reentry retry', () => { + const loadingParams = buildParams({isLoadingOlderReportActions: true, hasNewerActions: false}); + const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: loadingParams}); + + act(() => { + result.current.onScroll(); + }); + expect(mockLoadOlderActions).not.toHaveBeenCalled(); + + const failedParams = {...loadingParams, isLoadingOlderReportActions: false, hasLoadingOlderReportActionsError: true}; + rerender(failedParams); + flushAnimationFrames(); + expect(mockLoadOlderActions).not.toHaveBeenCalled(); + + act(() => { + setListMetrics(OLDER_PAGINATION_EXTENT + BOUNDARY_DISTANCE + 1); + result.current.onScroll(); + setListMetrics(0); + result.current.onScroll(); + }); + expect(mockLoadOlderActions).toHaveBeenCalledTimes(1); + }); + + it('resets request guards after reconnecting or changing the linked-action window', () => { + const initialParams = buildParams({hasOlderActions: false}); + const {result, rerender} = renderHook((params: HookParams) => useReportActionsPaginationScroll(params), {initialProps: initialParams}); + + setListMetrics(CONTENT_HEIGHT); + act(() => { + result.current.onScroll(); + }); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(1); + + rerender({...initialParams, isOffline: true}); + rerender(initialParams); + flushAnimationFrames(); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(2); + + rerender({...initialParams, linkedReportActionID: 'linked-2'}); + flushAnimationFrames(); + expect(mockLoadNewerActions).toHaveBeenCalledTimes(3); + }); +}); diff --git a/tests/unit/useReportActionsScrollTest.tsx b/tests/unit/useReportActionsScrollTest.tsx index 7eefd21d1443..a9a10560373d 100644 --- a/tests/unit/useReportActionsScrollTest.tsx +++ b/tests/unit/useReportActionsScrollTest.tsx @@ -13,8 +13,10 @@ import type {ReportAction} from '@src/types/onyx'; import type {ReactNode} from 'react'; import React from 'react'; +import {Platform} from 'react-native'; import Onyx from 'react-native-onyx'; +import createMock from '../utils/createMock'; import {createMockReport, getFakeReportAction} from '../utils/ReportTestUtils'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; @@ -34,12 +36,14 @@ jest.spyOn(global, 'requestAnimationFrame').mockImplementation((callback: FrameR // --- useReportScrollManager --- const mockScrollToBottom = jest.fn(); const mockScrollToIndex = jest.fn(); +let mockLinkedActionPosition = 40; +let mockListScroll: number | undefined; +let mockListHeaderSize = 0; jest.mock('@hooks/useReportScrollManager', () => ({ __esModule: true, default: () => ({ scrollToBottom: mockScrollToBottom, scrollToIndex: mockScrollToIndex, - scrollToEnd: jest.fn(), scrollToOffset: jest.fn(), }), })); @@ -49,6 +53,7 @@ const mockSetIsFloatingMessageCounterVisible = jest.fn(); const mockSetIsActionBadgeAboveViewport = jest.fn(); const mockTrackVerticalScrolling = jest.fn(); const mockOnViewableItemsChanged = jest.fn(); +const mockUpdatePillVisibility = jest.fn(); let mockIsFloatingMessageCounterVisible = false; let mockIsActionBadgeAboveViewport = false; let mockShouldDisablePillTracking: boolean | undefined; @@ -63,6 +68,7 @@ jest.mock('@pages/inbox/report/useReportUnreadMessageScrollTracking', () => ({ setIsActionBadgeAboveViewport: mockSetIsActionBadgeAboveViewport, trackVerticalScrolling: mockTrackVerticalScrolling, onViewableItemsChanged: mockOnViewableItemsChanged, + updatePillVisibility: mockUpdatePillVisibility, }; }, })); @@ -70,7 +76,7 @@ jest.mock('@pages/inbox/report/useReportUnreadMessageScrollTracking', () => ({ // --- useReportActionsNewActionLiveTail --- const mockSetIsScrollToBottomEnabled = jest.fn(); const mockCompleteLiveTailPrune = jest.fn(); -const mockOpenReportAtLiveTail = jest.fn(); +const mockRequestLiveTailJump = jest.fn(); let mockIsScrollToBottomEnabled = false; jest.mock('@pages/inbox/report/useReportActionsNewActionLiveTail', () => ({ __esModule: true, @@ -78,16 +84,10 @@ jest.mock('@pages/inbox/report/useReportActionsNewActionLiveTail', () => ({ isScrollToBottomEnabled: mockIsScrollToBottomEnabled, setIsScrollToBottomEnabled: mockSetIsScrollToBottomEnabled, completeLiveTailPruneAfterScrollToBottom: mockCompleteLiveTailPrune, - openReportAtLiveTail: mockOpenReportAtLiveTail, + requestLiveTailJump: mockRequestLiveTailJump, }), })); -// --- useScrollToEndOnNewMessageReceived --- -jest.mock('@hooks/useScrollToEndOnNewMessageReceived', () => ({ - __esModule: true, - default: jest.fn(), -})); - // --- TransitionTracker --- const mockTransitionCallbacks: Array<() => void> = []; jest.mock('@libs/Navigation/TransitionTracker', () => ({ @@ -95,7 +95,15 @@ jest.mock('@libs/Navigation/TransitionTracker', () => ({ default: { runAfterTransitions: jest.fn(({callback}: {callback: () => void}) => { mockTransitionCallbacks.push(callback); - return {cancel: jest.fn()}; + return { + cancel: jest.fn(() => { + const index = mockTransitionCallbacks.indexOf(callback); + if (index < 0) { + return; + } + mockTransitionCallbacks.splice(index, 1); + }), + }; }), }, })); @@ -119,11 +127,13 @@ jest.mock('@libs/Navigation/Navigation', () => ({ // --- react-navigation route --- let mockRouteParams: {reportActionID?: string; backTo?: string; shouldScrollToLatest?: string} = {}; +let mockIsFocused = true; jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { ...actualNav, useRoute: () => ({params: mockRouteParams}), + useIsFocused: () => mockIsFocused, }; }); @@ -177,6 +187,7 @@ function makeAction(reportActionID: string, overrides: Partial = { function buildParams(overrides: Partial = {}): ScrollParams { return { + listID: REPORT_ID, reportID: REPORT_ID, report: createMockReport({reportID: REPORT_ID}), transactionThreadReport: undefined, @@ -184,13 +195,11 @@ function buildParams(overrides: Partial = {}): ScrollParams { sortedVisibleReportActions: [makeAction('1')], renderedVisibleReportActions: [makeAction('1')], keyExtractor: (item: ReportAction) => item.reportActionID, - hasScrolledOverThreshold: false, markNewestActionAsRead: mockMarkNewestActionAsRead, completeSkippedMarkAsRead: mockCompleteSkippedMarkAsRead, unreadMarkerReportActionID: null, unreadMarkerReportActionIndex: -1, hasNewerActions: false, - draftAutoScrollKey: '', actionBadgeTargetIndex: -1, sortedAllReportActionsForPagination: [], treatAsNoPaginationAnchor: false, @@ -201,7 +210,19 @@ function buildParams(overrides: Partial = {}): ScrollParams { // Built via a function so the value isn't an inline literal the context-split lint rule would flag; these are all refs/accessors with no re-render concern. function buildActionListContextValue() { - return {scrollOffsetRef: mockScrollOffsetRef, getScrollOffset: () => mockScrollOffsetRef.current, registerListRef: () => {}, getListRef: () => null}; + return { + scrollOffsetRef: mockScrollOffsetRef, + getScrollOffset: () => mockScrollOffsetRef.current, + registerListRef: () => {}, + getListRef: () => ({ + current: { + scrollToIndex: mockScrollToIndex, + scrollToEnd: mockScrollToBottom, + scrollToOffset: jest.fn(), + getState: () => ({positionByKey: () => mockLinkedActionPosition, scroll: mockListScroll, contentLength: 5000, scrollLength: 600, headerSize: mockListHeaderSize}), + }, + }), + }; } function wrapper({children}: {children: ReactNode}) { @@ -216,16 +237,12 @@ async function renderScroll(overrides: Partial = {}) { function flushTransitions() { act(() => { - for (const callback of mockTransitionCallbacks) { + for (const callback of mockTransitionCallbacks.splice(0)) { callback(); } }); } -function setReportLoadingState(value: {isLoadingInitialReportActions?: boolean; hasOnceLoadedReportActions?: boolean}) { - return Onyx.merge(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${REPORT_ID}`, value); -} - describe('useReportActionsScroll', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); @@ -237,11 +254,18 @@ describe('useReportActionsScroll', () => { await waitForBatchedUpdates(); mockTransitionCallbacks.length = 0; mockRouteParams = {}; + mockLinkedActionPosition = 40; + mockListScroll = undefined; + mockListHeaderSize = 0; + mockIsFocused = true; mockReportRHPActiveRoute = undefined; mockIsFloatingMessageCounterVisible = false; mockIsActionBadgeAboveViewport = false; mockShouldDisablePillTracking = undefined; mockIsScrollToBottomEnabled = false; + mockSetIsScrollToBottomEnabled.mockImplementation((enabled: boolean) => { + mockIsScrollToBottomEnabled = enabled; + }); mockIsTransactionThread = false; mockIsSentMoneyReportAction = false; mockIsReportPreviewAction = false; @@ -261,9 +285,6 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll(); expect(result.current.shouldBeAlignedToTop).toBe(false); - expect(result.current.shouldFocusToTopOnMount).toBe(false); - expect(result.current.maintainVisibleContentPosition.disabled).toBe(true); - expect(result.current.maintainVisibleContentPosition.autoscrollToBottomThreshold).toBeUndefined(); }); it('is aligned to top and focuses to top on mount for a transaction thread report', async () => { @@ -272,8 +293,6 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll(); expect(result.current.shouldBeAlignedToTop).toBe(true); - expect(result.current.shouldFocusToTopOnMount).toBe(true); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); }); it('is aligned to top for a money request report', async () => { @@ -292,25 +311,136 @@ describe('useReportActionsScroll', () => { expect(result.current.shouldBeAlignedToTop).toBe(true); }); - it('uses the linked report action as the initial scroll key', async () => { + it('bottom-aligns a linked report action when it is the final item', async () => { mockRouteParams = {reportActionID: LINKED_ACTION_ID}; - const {result} = await renderScroll({sortedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]}); + const linkedAction = makeAction(LINKED_ACTION_ID); + const {result} = await renderScroll({sortedVisibleReportActions: [linkedAction], renderedVisibleReportActions: [linkedAction]}); - expect(result.current.initialScrollKey).toBe(LINKED_ACTION_ID); - expect(result.current.shouldFocusToTopOnMount).toBe(false); + expect(result.current.initialScrollIndex).toBe(0); + expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 1}); + expect(result.current.shouldShowNewerActionsSkeleton).toBe(false); }); - it('falls back to the unread marker action as the initial scroll key', async () => { + it('top-aligns a final linked report action above a skeleton until the newest page loads', async () => { + // Given OpenReport returned the linked action as the newest action of a historical page. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const linkedAction = makeAction(LINKED_ACTION_ID); + const {result, rerender} = await renderScroll({hasNewerActions: true, sortedVisibleReportActions: [linkedAction], renderedVisibleReportActions: [linkedAction]}); + + // Then it opens at the usual linked offset, with a skeleton holding the space for the newer page. + expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + expect(result.current.shouldShowNewerActionsSkeleton).toBe(true); + + // When an intermediate newer page loads below it. + const newerAction = makeAction('2'); + rerender(buildParams({hasNewerActions: true, sortedVisibleReportActions: [newerAction, linkedAction], renderedVisibleReportActions: [linkedAction, newerAction]})); + + // Then the skeleton stays, so the content below the link does not shrink while the new rows are sized. + expect(result.current.shouldShowNewerActionsSkeleton).toBe(true); + + // When the newest page loads. + rerender(buildParams({sortedVisibleReportActions: [newerAction, linkedAction], renderedVisibleReportActions: [linkedAction, newerAction]})); + + // Then the skeleton is removed. + expect(result.current.shouldShowNewerActionsSkeleton).toBe(false); + }); + + it('keeps the skeleton below a linked action until its row reports the first newer row as measured', async () => { + // Given a linked final action that opened above a skeleton for the newer page. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const linkedAction = makeAction(LINKED_ACTION_ID); + const {result, rerender} = await renderScroll({hasNewerActions: true, sortedVisibleReportActions: [linkedAction], renderedVisibleReportActions: [linkedAction]}); + expect(result.current.shouldShowSkeletonBelowLinkedAction).toBe(true); + + // When the newest page adds a row below the link. + const newerAction = makeAction('2'); + rerender(buildParams({sortedVisibleReportActions: [newerAction, linkedAction], renderedVisibleReportActions: [linkedAction, newerAction]})); + + // Then the linked row keeps its skeleton, because LegendList hides the new row until it is measured. + // The footer skeleton is gone, since nothing newer is left to load. + expect(result.current.shouldShowSkeletonBelowLinkedAction).toBe(true); + expect(result.current.shouldShowNewerActionsSkeleton).toBe(false); + + // When the skeleton reports that LegendList measured the row. + act(() => result.current.hideSkeletonBelowLinkedAction()); + + // Then the linked row drops its skeleton, because the measured row is visible and fills the space below the link. + expect(result.current.shouldShowSkeletonBelowLinkedAction).toBe(false); + }); + + it('prefers a linked action over a later unread marker', async () => { + // Given a linked action followed by an unread action in chronological order. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const actions = [makeAction(LINKED_ACTION_ID), makeAction(UNREAD_ACTION_ID)]; + + // When the list chooses its initial position. + const {result} = await renderScroll({ + sortedVisibleReportActions: actions.toReversed(), + renderedVisibleReportActions: actions, + unreadMarkerReportActionID: UNREAD_ACTION_ID, + unreadMarkerReportActionIndex: 1, + }); + + // Then the linked action is aligned below the header. + expect(result.current.initialScrollIndex).toBe(0); + expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + }); + + it('positions an unread marker at chronological index zero', async () => { + // Given the newest action is unread and can be taller than the viewport. + const unreadAction = makeAction(UNREAD_ACTION_ID); + // When opening the report at its unread boundary. const {result} = await renderScroll({ unreadMarkerReportActionID: UNREAD_ACTION_ID, - sortedVisibleReportActions: [makeAction(UNREAD_ACTION_ID)], + sortedVisibleReportActions: [unreadAction], + renderedVisibleReportActions: [unreadAction], }); - expect(result.current.initialScrollKey).toBe(UNREAD_ACTION_ID); + // Then the start of the action and its New marker remain visible. + expect(result.current.initialScrollIndex).toBe(0); + expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + }); + + it.each([undefined, ''])('opens at the unread boundary when the route link is %p', async (reportActionID) => { + // Given unread content before the newest message and no message targeted by the route. + mockRouteParams = {reportActionID}; + const actions = [makeAction('1'), makeAction(UNREAD_ACTION_ID), makeAction('2')]; + + // When reopening the chat, including after sending a message cleared its previous link. + const {result} = await renderScroll({ + sortedVisibleReportActions: actions.toReversed(), + renderedVisibleReportActions: actions, + unreadMarkerReportActionID: UNREAD_ACTION_ID, + unreadMarkerReportActionIndex: 1, + }); + + // Then initial placement preserves the unread boundary instead of starting at the newest message. + expect(result.current.initialScrollIndex).toBe(1); + expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + }); + + it('ignores unread positioning in a money request report after its link is cleared', async () => { + // Given a money request whose unread action follows its topmost action. + mockIsMoneyRequestReport = true; + mockRouteParams = {reportActionID: ''}; + const actions = [makeAction('1'), makeAction(UNREAD_ACTION_ID)]; + + // When opening the report without a message link, its report layout still owns the initial position. + const {result} = await renderScroll({ + sortedVisibleReportActions: actions.toReversed(), + renderedVisibleReportActions: actions, + unreadMarkerReportActionID: UNREAD_ACTION_ID, + unreadMarkerReportActionIndex: 1, + }); + + // Then unread content does not replace the report's required top alignment. + expect(result.current.initialScrollIndex).toBe(0); + expect(result.current.initialScrollIndexParams).toBeUndefined(); }); it('uses the rendered summary as the initial scroll target when the unread action is collapsed', async () => { + // Given the unread action is hidden behind a displayed summary row. const {result} = await renderScroll({ unreadMarkerReportActionID: UNREAD_ACTION_ID, reportActionIDToDisplayIndex: new Map([[UNREAD_ACTION_ID, 1]]), @@ -318,8 +448,9 @@ describe('useReportActionsScroll', () => { renderedVisibleReportActions: [makeAction('1'), makeAction(UNREAD_SUMMARY_ACTION_ID)], }); - expect(result.current.initialScrollKey).toBe(UNREAD_ACTION_ID); + // Then initial positioning uses the summary row in chronological order. expect(result.current.initialScrollIndex).toBe(1); + expect(result.current.initialScrollIndexParams).toEqual({viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); it('suppresses the initial scroll key for an aligned-to-top CREATED anchor action', async () => { @@ -330,9 +461,8 @@ describe('useReportActionsScroll', () => { sortedVisibleReportActions: [makeAction(LINKED_ACTION_ID, {actionName: CONST.REPORT.ACTIONS.TYPE.CREATED})], }); - expect(result.current.initialScrollKey).toBeUndefined(); - // No key + aligned-to-top → focus to top. - expect(result.current.shouldFocusToTopOnMount).toBe(true); + expect(result.current.initialScrollIndex).toBe(0); + expect(result.current.initialScrollIndexParams).toBeUndefined(); }); it('does not focus to top for a single-expense money request report opened from the X Replies link', async () => { @@ -343,7 +473,6 @@ describe('useReportActionsScroll', () => { // Still aligned to top so short reports keep their layout, but the mount position is the latest message. expect(result.current.shouldBeAlignedToTop).toBe(true); - expect(result.current.shouldFocusToTopOnMount).toBe(false); expect(result.current.initialScrollIndex).toBeUndefined(); }); @@ -354,7 +483,355 @@ describe('useReportActionsScroll', () => { const {result} = await renderScroll(); expect(result.current.shouldBeAlignedToTop).toBe(true); - expect(result.current.shouldFocusToTopOnMount).toBe(false); + }); + + it('does not clear the latest route parameter when it was never set', async () => { + // Given an ordinary report open without the X Replies flag. + mockRouteParams = {}; + + // When its scroll hook mounts. + await renderScroll(); + + // Then it leaves unrelated route parameters alone. + expect(mockSetParams).not.toHaveBeenCalledWith({shouldScrollToLatest: undefined}); + }); + }); + + describe('linked message positioning', () => { + it('does not reposition an Android link already aligned below the offline history header', async () => { + // Given the row position excludes an 800-point header that the scroll offset includes. + const platform = jest.replaceProperty(Platform, 'OS', 'android'); + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + mockLinkedActionPosition = 400; + mockListHeaderSize = 800; + mockListScroll = 1160; + const {result} = await renderScroll({renderedVisibleReportActions: [makeAction('123'), makeAction(LINKED_ACTION_ID), makeAction('999')]}); + + // When the positioned list becomes ready, it must not start a redundant native scroll. + act(() => result.current.onLoad()); + flushTransitions(); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + platform.restore(); + }); + + it('lets Android preserve an already aligned linked message when a preceding row changes height', async () => { + // Given Android has already positioned the link and is preserving it during measurement. + const platform = jest.replaceProperty(Platform, 'OS', 'android'); + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + mockLinkedActionPosition = 2690; + mockListScroll = 2650; + const {result} = await renderScroll({renderedVisibleReportActions: [makeAction('123'), makeAction(LINKED_ACTION_ID), makeAction('999')]}); + act(() => result.current.onLoad()); + flushTransitions(); + mockScrollToIndex.mockClear(); + + // When LegendList updates both the measured position and its pending native scroll adjustment. + mockLinkedActionPosition = 2460; + mockListScroll = 2420; + act(() => result.current.onItemSizeChanged({index: 0, previous: 500, size: 270})); + flushTransitions(); + + // Then another imperative scroll does not apply the same correction a second time. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + + // A later scroll event that really moves the link still receives a correction. + mockListScroll = 2190; + act(() => + result.current.trackLinkedMessageScroll( + createMock[0]>({ + nativeEvent: {contentOffset: {x: 0, y: 2190}, contentSize: {width: 300, height: 5000}, layoutMeasurement: {width: 300, height: 600}}, + }), + ), + ); + flushTransitions(); + expect(mockScrollToIndex).toHaveBeenCalledWith(1, {animated: false, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + platform.restore(); + }); + + it('restores a linked message moved out of view by a late native scroll adjustment', async () => { + // Given the linked message has already landed below a measured long predecessor. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const {result} = await renderScroll({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID), makeAction('999')]}); + act(() => result.current.onLoad()); + flushTransitions(); + mockScrollToIndex.mockClear(); + + // When a native layout adjustment moves its measured position beyond the viewport. + mockLinkedActionPosition = 1500; + const scrollEvent = createMock[0]>({ + nativeEvent: {contentOffset: {x: 0, y: 0}, contentSize: {width: 300, height: 2000}, layoutMeasurement: {width: 300, height: 600}}, + }); + act(() => result.current.trackLinkedMessageScroll(scrollEvent)); + flushTransitions(); + + // Then the linked position is restored even without another row size notification. + expect(mockScrollToIndex).toHaveBeenCalledWith(0, {animated: false, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + mockScrollToIndex.mockClear(); + act(() => { + result.current.stopLinkedMessagePositioning(); + result.current.trackLinkedMessageScroll(scrollEvent); + }); + flushTransitions(); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it.each([false, true])('preserves the previous viewport after returning from child screens, reader scrolled away: %s', async (didScrollAway) => { + // Given a linked parent stays mounted after its initial positioning, with or without a later manual scroll. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const actions = [makeAction(LINKED_ACTION_ID), makeAction('999')]; + const params = buildParams({sortedVisibleReportActions: actions.toReversed(), renderedVisibleReportActions: actions}); + const {result, rerender} = await renderScroll(params); + act(() => result.current.onLoad()); + flushTransitions(); + expect(mockScrollToIndex).toHaveBeenCalled(); + if (didScrollAway) { + act(() => result.current.stopLinkedMessagePositioning()); + } + mockScrollToIndex.mockClear(); + + // When the reader opens a thread, returns, then opens an attachment and returns again. + for (let visit = 0; visit < 2; visit++) { + mockIsFocused = false; + rerender(params); + mockIsFocused = true; + rerender(params); + flushTransitions(); + act(() => { + result.current.onItemSizeChanged({index: 0, previous: 80, size: 350}); + result.current.trackLinkedMessageScroll( + createMock[0]>({ + nativeEvent: {contentOffset: {x: 0, y: 900}, contentSize: {width: 300, height: 2000}, layoutMeasurement: {width: 300, height: 600}}, + }), + ); + }); + flushTransitions(); + + // Then neither focus nor late measurements reclaim the viewport from the reader. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + } + }); + + it('positions a new link when an initially unfocused report first becomes active', async () => { + // Given a linked report has loaded behind another screen without ever taking focus. + mockIsFocused = false; + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const params = buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID), makeAction('999')]}); + const {result, rerender} = await renderScroll(params); + act(() => result.current.onLoad()); + flushTransitions(); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + + // When the report becomes visible for the first time. + mockIsFocused = true; + rerender(params); + flushTransitions(); + + // Then the initial link still receives its first positioning pass. + expect(mockScrollToIndex).toHaveBeenCalledWith(0, { + animated: false, + viewPosition: 0, + viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET, + }); + }); + + it('positions a different link opened while the report is behind a child screen', async () => { + // Given a previously positioned linked report has lost focus to a child screen. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const params = buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID), makeAction(UNREAD_ACTION_ID), makeAction('999')]}); + const {result, rerender} = await renderScroll(params); + act(() => result.current.onLoad()); + flushTransitions(); + mockIsFocused = false; + rerender(params); + mockScrollToIndex.mockClear(); + + // When another message link targets the same report before it regains focus. + mockRouteParams = {reportActionID: UNREAD_ACTION_ID}; + rerender(params); + flushTransitions(); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + mockIsFocused = true; + rerender(params); + flushTransitions(); + + // Then the new navigation request still positions its own highlighted message. + expect(mockScrollToIndex).toHaveBeenCalledWith(1, {animated: false, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + }); + + it('repositions a linked PDF after its preview or preceding rows change height', async () => { + // Given the linked PDF is initially positioned using estimated row heights. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const actions = [makeAction('123'), makeAction(LINKED_ACTION_ID), makeAction('999')]; + const {result} = await renderScroll(buildParams({renderedVisibleReportActions: actions})); + act(() => result.current.onLoad()); + flushTransitions(); + mockScrollToIndex.mockClear(); + + // When the preview above it finishes measuring, then the PDF itself expands. + act(() => result.current.onItemSizeChanged({index: 0, previous: 80, size: 350})); + flushTransitions(); + expect(mockScrollToIndex).toHaveBeenCalledWith(1, {animated: false, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + mockScrollToIndex.mockClear(); + act(() => result.current.onItemSizeChanged({index: 1, previous: 80, size: 350})); + flushTransitions(); + + // Then the measured target is positioned again, keeping its highlight in view. + expect(mockScrollToIndex).toHaveBeenCalledWith(1, {animated: false, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + }); + + it('lets the reader scroll away before a pending preview correction runs', async () => { + // Given a preview resize has scheduled a linked-message correction. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const {result, rerender} = await renderScroll(buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]})); + act(() => result.current.onLoad()); + flushTransitions(); + mockScrollToIndex.mockClear(); + act(() => result.current.onItemSizeChanged({index: 0, previous: 80, size: 350})); + + // When the reader takes over scrolling before the navigation/layout callback runs. + act(() => result.current.stopLinkedMessagePositioning()); + flushTransitions(); + act(() => result.current.onItemSizeChanged({index: 0, previous: 350, size: 500})); + rerender(buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]})); + flushTransitions(); + + // Then later measurements and ordinary rerenders do not reclaim the linked position. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it.each(['latest', 'badge'] as const)('stops linked corrections after explicitly jumping to the %s target', async (destination) => { + // Given a linked message still has late preview measurements to process. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const {result} = await renderScroll(buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID), makeAction('999')], actionBadgeTargetIndex: 1})); + act(() => result.current.onLoad()); + flushTransitions(); + + // When the reader selects another scroll destination. + act(() => { + if (destination === 'latest') { + result.current.scrollToBottomAndMarkReportAsRead(); + return; + } + result.current.scrollToActionBadgeTarget(); + }); + mockScrollToIndex.mockClear(); + act(() => result.current.onItemSizeChanged({index: 0, previous: 80, size: 350})); + flushTransitions(); + + // Then a later measurement does not pull the list back to the old linked message. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('does not reposition for measurements below the linked message', async () => { + // Given the linked message is already positioned. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const {result} = await renderScroll(buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID), makeAction('999')]})); + act(() => result.current.onLoad()); + flushTransitions(); + mockScrollToIndex.mockClear(); + + // When only a later message changes height. + act(() => result.current.onItemSizeChanged({index: 1, previous: 80, size: 350})); + flushTransitions(); + + // Then the reader's viewport is left in place. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('leaves following a new reply to the list after positioning a linked final action', async () => { + // Given the linked final action has been positioned at the bottom. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const linkedAction = makeAction(LINKED_ACTION_ID); + const {result, rerender} = await renderScroll(buildParams({renderedVisibleReportActions: [linkedAction]})); + act(() => result.current.onLoad()); + flushTransitions(); + expect(mockScrollToIndex).toHaveBeenCalledWith(0, {animated: false, viewPosition: 1}); + mockScrollToIndex.mockClear(); + + // When a new reply arrives while normal end-following is active. + rerender(buildParams({renderedVisibleReportActions: [linkedAction, makeAction('999')]})); + flushTransitions(); + act(() => result.current.onItemSizeChanged({index: 0, previous: 80, size: 350})); + flushTransitions(); + + // Then the linked action does not reclaim the viewport from the new reply. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('cancels pending positioning when the report loses focus', async () => { + // Given a linked action has a correction waiting for navigation to finish. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const params = buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]}); + const {result, rerender} = await renderScroll(params); + act(() => result.current.onLoad()); + + // When another report takes focus before the callback runs. + mockIsFocused = false; + rerender(params); + flushTransitions(); + + // Then the background list is not scrolled by a stale navigation callback. + expect(mockScrollToIndex).not.toHaveBeenCalled(); + }); + + it('waits for the replacement list to be ready after hydration', async () => { + // Given the cached list is ready and its linked message has been positioned. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + const params = buildParams({renderedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]}); + const {result, rerender} = await renderScroll(params); + act(() => result.current.onLoad()); + flushTransitions(); + mockScrollToIndex.mockClear(); + + // When hydration replaces that list with a newly mounted instance. + rerender({...params, listID: `${REPORT_ID}:hydrated`}); + act(() => result.current.onItemSizeChanged({index: 0, previous: 80, size: 350})); + flushTransitions(); + expect(mockScrollToIndex).not.toHaveBeenCalled(); + act(() => result.current.onLoad()); + flushTransitions(); + + // Then the correction uses the replacement list only after it is ready. + expect(mockScrollToIndex).toHaveBeenCalledWith(0, {animated: false, viewPosition: 1}); + }); + }); + + describe('new-message following', () => { + it.each([ + {distance: 50, shouldFollow: true}, + {distance: 250, shouldFollow: false}, + {distance: 300, shouldFollow: false}, + ])('preserves the pixel threshold at $distance pixels from the newest action', async ({distance, shouldFollow}) => { + // Given a loaded latest page and the reader's current bottom-relative offset. + const {rerender} = await renderScroll(); + mockScrollOffsetRef.current = distance; + mockScrollToBottom.mockClear(); + const newestAction = makeAction('2'); + + // When another user's message is appended to the chronological list. + rerender(buildParams({sortedVisibleReportActions: [newestAction, makeAction('1')], renderedVisibleReportActions: [makeAction('1'), newestAction]})); + await waitForBatchedUpdatesWithAct(); + + // Then it follows only inside the same 250-pixel range used before the migration. + expect(mockScrollToBottom).toHaveBeenCalledTimes(shouldFollow ? 1 : 0); + }); + + it('does not follow a newer page loaded below a linked report preview', async () => { + // Given a linked report preview is the newest loaded action of a historical page, at the bottom of the list. + mockRouteParams = {reportActionID: LINKED_ACTION_ID}; + mockIsReportPreviewAction = true; + mockLastVisibleActionCreated = '2099-01-01 00:00:00.000'; + const linkedAction = makeAction(LINKED_ACTION_ID); + const {rerender} = await renderScroll(buildParams({hasNewerActions: true, sortedVisibleReportActions: [linkedAction], renderedVisibleReportActions: [linkedAction]})); + mockScrollToBottom.mockClear(); + + // When the next newer page, which also ends with a report preview, is merged below it. + const newerAction = makeAction('2'); + rerender(buildParams({hasNewerActions: true, sortedVisibleReportActions: [newerAction, linkedAction], renderedVisibleReportActions: [linkedAction, newerAction]})); + await waitForBatchedUpdatesWithAct(); + + // Then the page merge is not treated as a new message, so the list does not jump and request another page. + expect(mockScrollToBottom).not.toHaveBeenCalled(); }); }); @@ -370,34 +847,41 @@ describe('useReportActionsScroll', () => { expect(mockScrollToBottom).toHaveBeenCalledTimes(1); expect(mockMarkNewestActionAsRead).toHaveBeenCalledTimes(1); expect(mockNavigate).not.toHaveBeenCalled(); - expect(mockOpenReportAtLiveTail).not.toHaveBeenCalled(); + expect(mockRequestLiveTailJump).not.toHaveBeenCalled(); }); - it('navigates and opens the report when the newest action is not present', async () => { + it('waits for the latest page before scrolling when the newest action is not present', async () => { + // Given a historical page whose newest action has not been loaded. mockLastVisibleActionCreated = '2099-01-01 00:00:00.000'; const {result} = await renderScroll(); + // When the reader presses Latest messages. act(() => { result.current.scrollToBottomAndMarkReportAsRead(); }); - expect(mockNavigate).toHaveBeenCalledTimes(1); - expect(mockOpenReportAtLiveTail).toHaveBeenCalledTimes(1); - expect(mockScrollToBottom).toHaveBeenCalledTimes(1); + // Then the existing catch-up flow owns the eventual scroll, without scrolling the old page first. + expect(mockRequestLiveTailJump).toHaveBeenCalledTimes(1); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockScrollToBottom).not.toHaveBeenCalled(); expect(mockMarkNewestActionAsRead).not.toHaveBeenCalled(); }); it('does not navigate when a report RHP route is already active', async () => { + // Given a historical page with another report open in the right pane. mockLastVisibleActionCreated = '2099-01-01 00:00:00.000'; mockReportRHPActiveRoute = 'r/1/rhp'; const {result} = await renderScroll(); + // When the reader asks for the latest messages. act(() => { result.current.scrollToBottomAndMarkReportAsRead(); }); + // Then the catch-up request leaves the focused pane in place. expect(mockNavigate).not.toHaveBeenCalled(); - expect(mockOpenReportAtLiveTail).toHaveBeenCalledTimes(1); + expect(mockRequestLiveTailJump).toHaveBeenCalledTimes(1); + expect(mockScrollToBottom).not.toHaveBeenCalled(); }); }); @@ -417,7 +901,7 @@ describe('useReportActionsScroll', () => { result.current.scrollToActionBadgeTarget(); }); - expect(mockScrollToIndex).toHaveBeenCalledWith(5, {animated: true, viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + expect(mockScrollToIndex).toHaveBeenCalledWith(5, {animated: true, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); it('leaves the pills to viewability tracking when the composer is not expanded', async () => { @@ -447,16 +931,52 @@ describe('useReportActionsScroll', () => { // Then both pills are hidden directly because the target can never become viewable to hide them, and the list still scrolls expect(mockSetIsActionBadgeAboveViewport).toHaveBeenCalledWith(false); expect(mockSetIsFloatingMessageCounterVisible).toHaveBeenCalledWith(false); - expect(mockScrollToIndex).toHaveBeenCalledWith(5, {animated: true, viewPosition: 1, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); + expect(mockScrollToIndex).toHaveBeenCalledWith(5, {animated: true, viewPosition: 0, viewOffset: CONST.REPORT.ACTIONS.LINKED_MESSAGE_OFFSET}); }); }); describe('pill tracking', () => { + it('waits for the current list to finish positioning before tracking pills', async () => { + // Given a read chat whose initial scroll offsets are still estimates. + const params = buildParams(); + const {result, rerender} = await renderScroll(params); + expect(mockShouldDisablePillTracking).toBe(true); + + // When the initial viewport settles, its pill state can reflect the visible messages. + act(() => result.current.onLoad()); + expect(mockShouldDisablePillTracking).toBe(false); + + // Then a replacement list must settle independently of the previous list. + rerender({...params, listID: `${REPORT_ID}:hydrated`}); + expect(mockShouldDisablePillTracking).toBe(true); + act(() => result.current.onLoad()); + expect(mockShouldDisablePillTracking).toBe(false); + }); + + it('refreshes pill visibility after the expanded composer collapses', async () => { + // Given pill tracking is suspended while the expanded composer covers the report. + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${REPORT_ID}`, true); + const {result} = await renderScroll(); + act(() => result.current.onLoad()); + mockUpdatePillVisibility.mockClear(); + + // When the composer collapses and the report becomes visible again. + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${REPORT_ID}`, false); + }); + await waitForBatchedUpdatesWithAct(); + + // Then tracking resumes and the pills reflect the current scroll position. + expect(mockShouldDisablePillTracking).toBe(false); + expect(mockUpdatePillVisibility).toHaveBeenCalledTimes(1); + }); + it('keeps scroll-based pill tracking enabled when the composer is not expanded', async () => { // Given the composer at its normal size and no linked message to position // When the hook renders - await renderScroll(); + const {result} = await renderScroll(); + act(() => result.current.onLoad()); // Then scroll-based pill tracking stays enabled so the Latest messages pill follows scrolling expect(mockShouldDisablePillTracking).toBe(false); @@ -474,44 +994,39 @@ describe('useReportActionsScroll', () => { }); }); - describe('flushPendingScrollToBottom', () => { + describe('pending live-tail requests', () => { it('does nothing when scroll-to-bottom is not enabled', async () => { mockIsScrollToBottomEnabled = false; - const {result} = await renderScroll(); - act(() => { - result.current.flushPendingScrollToBottom(); - }); + await renderScroll(); expect(mockScrollToBottom).not.toHaveBeenCalled(); expect(mockSetIsScrollToBottomEnabled).not.toHaveBeenCalled(); expect(mockCompleteLiveTailPrune).not.toHaveBeenCalled(); }); - it('scrolls, disables itself and prunes when scroll-to-bottom is enabled', async () => { + it('consumes the request after render without waiting for a future viewport layout', async () => { mockIsScrollToBottomEnabled = true; - const {result} = await renderScroll(); - act(() => { - result.current.flushPendingScrollToBottom(); - }); + const {rerender} = await renderScroll(); expect(mockScrollToBottom).toHaveBeenCalledTimes(1); expect(mockSetIsScrollToBottomEnabled).toHaveBeenCalledWith(false); expect(mockCompleteLiveTailPrune).toHaveBeenCalledTimes(1); + + mockScrollOffsetRef.current = 9999; + rerender(buildParams()); + expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); - it('clears the stale linked-message offset and hides the pill when flushing', async () => { + it('clears the stale linked-message offset and hides the pill when consuming the request', async () => { // Given a live-tail jump with the offset and pill left over from a linked message mockIsScrollToBottomEnabled = true; mockScrollOffsetRef.current = CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD + 1; mockIsFloatingMessageCounterVisible = true; - const {result} = await renderScroll(); - // When the list flushes the jump without emitting another scroll event - act(() => { - result.current.flushPendingScrollToBottom(); - }); + // When the list consumes the jump without emitting another scroll event + await renderScroll(); // Then later content size changes cannot restore the pill from the historical offset expect(mockScrollOffsetRef.current).toBe(0); @@ -522,99 +1037,14 @@ describe('useReportActionsScroll', () => { }); }); - describe('onLoad', () => { - it('does nothing when the list is not configured to focus to top on mount', async () => { - const {result} = await renderScroll(); - act(() => { - result.current.onLoad(); - }); - - // Stays disabled with no autoscroll threshold for a regular chat. - expect(result.current.maintainVisibleContentPosition.disabled).toBe(true); - expect(result.current.maintainVisibleContentPosition.autoscrollToBottomThreshold).toBeUndefined(); - }); - - it('waits for the report actions to have loaded before disabling autoscroll-to-top', async () => { - mockIsTransactionThread = true; - // No loading state → onLoad bails. - - const {result} = await renderScroll(); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - - act(() => { - result.current.onLoad(); - }); - - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - }); - - it('disables autoscroll-to-top after a frame once report actions have loaded', async () => { - mockIsTransactionThread = true; - await setReportLoadingState({isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}); - - const {result} = await renderScroll(); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - - act(() => { - result.current.onLoad(); - }); - - // The threshold must drop to 0 (not undefined) so FlashList keeps clearing its internal pending-autoscroll flag. - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(0); - }); - - it('disables autoscroll-to-top when report actions finish loading after the list has mounted', async () => { - mockIsTransactionThread = true; - - const {result} = await renderScroll(); - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(CONST.REPORT.ACTIONS.ACTION_VISIBLE_THRESHOLD); - - // Load completes after mount → companion effect turns autoscroll off. - await act(async () => { - await setReportLoadingState({isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true}); - await waitForBatchedUpdates(); - }); - - // The threshold must drop to 0 (not undefined) so FlashList keeps clearing its internal pending-autoscroll flag. - expect(result.current.maintainVisibleContentPosition?.autoscrollToBottomThreshold).toBe(0); - }); - }); - describe('effects', () => { - it('schedules an initial scroll-to-bottom on mount for a regular chat report', async () => { - await renderScroll(); - - expect(mockScrollToBottom).not.toHaveBeenCalled(); - flushTransitions(); - - expect(mockSetIsFloatingMessageCounterVisible).toHaveBeenCalledWith(false); - expect(mockScrollToBottom).toHaveBeenCalledTimes(1); - }); - - it('does not scroll to bottom on mount when there is an initial scroll key', async () => { - mockRouteParams = {reportActionID: LINKED_ACTION_ID}; - - await renderScroll({sortedVisibleReportActions: [makeAction(LINKED_ACTION_ID)]}); - flushTransitions(); - - expect(mockScrollToBottom).not.toHaveBeenCalled(); - }); - - it('does not scroll to bottom on mount when the list focuses to top', async () => { - mockIsTransactionThread = true; - - await renderScroll(); - flushTransitions(); - - expect(mockScrollToBottom).not.toHaveBeenCalled(); - }); + it('follows incoming messages through the shared pixel-based policy', async () => { + mockScrollOffsetRef.current = 0; - it('scrolls to bottom on mount for a single-expense money request report opened from the X Replies link', async () => { - mockIsMoneyRequestReport = true; - mockRouteParams = {shouldScrollToLatest: 'true'}; + const {rerender} = await renderScroll(); - await renderScroll(); - flushTransitions(); + const actions = [makeAction('2'), makeAction('1')]; + rerender(buildParams({sortedVisibleReportActions: actions, renderedVisibleReportActions: actions.toReversed()})); expect(mockScrollToBottom).toHaveBeenCalledTimes(1); }); @@ -628,32 +1058,15 @@ describe('useReportActionsScroll', () => { expect(mockSetParams).toHaveBeenCalledWith({shouldScrollToLatest: undefined}); }); - it('does not clear the X Replies flag when it was never set', async () => { - mockIsMoneyRequestReport = true; - - await renderScroll(); - - expect(mockSetParams).not.toHaveBeenCalled(); - }); - - it('auto-scrolls to bottom when a new draft key arrives near the bottom and the newest action is present', async () => { + it('does not schedule a competing scroll when a streamed draft grows', async () => { mockScrollOffsetRef.current = 0; - const {rerender} = await renderScroll({draftAutoScrollKey: ''}); - - rerender(buildParams({draftAutoScrollKey: 'draft-1'})); - - expect(mockSetIsFloatingMessageCounterVisible).toHaveBeenCalledWith(false); - expect(mockScrollToBottom).toHaveBeenCalled(); - }); - - it('does not auto-scroll on a new draft key when scrolled away from the bottom', async () => { - mockScrollOffsetRef.current = 9999; - - const {rerender} = await renderScroll({draftAutoScrollKey: ''}); + const draft = makeAction('2', {message: [{type: 'COMMENT', text: 'Hello', html: '

Hello

'}]}); + const {rerender} = await renderScroll({renderedVisibleReportActions: [makeAction('1'), draft]}); mockScrollToBottom.mockClear(); - rerender(buildParams({draftAutoScrollKey: 'draft-1'})); + const updatedDraft: ReportAction = {...draft, message: [{type: 'COMMENT', text: 'Hello, here is the rest of the reply.', html: '

Hello, here is the rest of the reply.

'}]}; + rerender(buildParams({renderedVisibleReportActions: [makeAction('1'), updatedDraft]})); expect(mockScrollToBottom).not.toHaveBeenCalled(); }); diff --git a/tests/unit/useReportScrollManagerTest.tsx b/tests/unit/useReportScrollManagerTest.tsx index 6fcd7cb5c64a..5d9bdf09132b 100644 --- a/tests/unit/useReportScrollManagerTest.tsx +++ b/tests/unit/useReportScrollManagerTest.tsx @@ -1,10 +1,9 @@ import {act, renderHook} from '@testing-library/react-native'; -import type FlatListRefType from '@components/FlashList/types'; - import useReportScrollManager from '@hooks/useReportScrollManager'; import {ActionListContext, useActionListContext} from '@pages/inbox/ActionListContext'; +import type ActionListRefType from '@pages/inbox/ActionListTypes'; import type {ReactNode} from 'react'; @@ -21,20 +20,20 @@ function buildMockListRef() { scrollToIndex: jest.fn(), scrollToOffset: jest.fn(), scrollToEnd: jest.fn(), - getNativeScrollRef: jest.fn(() => undefined), + getNativeScrollRef: jest.fn(() => ({scrollToEnd: jest.fn()})), }; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const ref = {current: methods} as unknown as FlatListRefType; + const ref = {current: methods} as unknown as ActionListRefType; return {ref, methods}; } // Context value backed by a closure holder so registering a ref is visible to the manager's getListRef(). function buildContextValue() { - let held: FlatListRefType = null; + let held: ActionListRefType = null; return { scrollOffsetRef: {current: 0}, getScrollOffset: () => 0, - registerListRef: (ref: FlatListRefType) => { + registerListRef: (ref: ActionListRefType) => { held = ref; }, getListRef: () => held, @@ -58,28 +57,32 @@ function renderManager() { describe('useReportScrollManager', () => { it('resolves the list ref at call time, not at init (register AFTER the manager is created)', () => { + // Given a manager created before its list ref is registered. const {result} = renderManager(); const {ref, methods} = buildMockListRef(); - // Register only after the manager hook has already run once. + // When the list registers and a scroll is requested afterward. act(() => { result.current.registerListRef(ref); }); act(() => { - result.current.manager.scrollToEnd(); + result.current.manager.scrollToBottom(); }); - // The ref was resolved lazily — the method on the later-registered ref fired. - expect(methods.scrollToEnd).toHaveBeenCalled(); + // Then the list handles the scroll so its virtualization and footer positioning stay active. + expect(methods.scrollToEnd).toHaveBeenCalledWith({animated: false}); + expect(methods.getNativeScrollRef).not.toHaveBeenCalled(); }); it('no-ops when no ref is registered', () => { + // Given a manager whose list has not mounted yet. const {result} = renderManager(); + // When any scroll command runs, then it tolerates the absent list. expect(() => { act(() => { - result.current.manager.scrollToEnd(); + result.current.manager.scrollToBottom(); result.current.manager.scrollToIndex(3); result.current.manager.scrollToOffset(100); result.current.manager.scrollToIndex(1, {animated: false}); diff --git a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts index 914a29973d3d..b11d21073c4b 100644 --- a/tests/unit/useReportUnreadMessageScrollTrackingTest.ts +++ b/tests/unit/useReportUnreadMessageScrollTrackingTest.ts @@ -127,6 +127,56 @@ describe('useReportUnreadMessageScrollTracking', () => { expect(result.current.isFloatingMessageCounterVisible).toBe(false); }); + it.each([false, true])('defers unread viewability until initial positioning finishes when inverted is %s', (isInverted) => { + // Given the list is still positioning an unread message outside its provisional viewport. + const onUnreadActionVisible = jest.fn(); + const {result, rerender} = renderHook( + ({shouldDisablePillTracking}) => + useReportUnreadMessageScrollTracking({ + reportID, + currentVerticalScrollingOffsetRef: {current: 0}, + onUnreadActionVisible, + onTrackScrolling: jest.fn(), + hasNewerActions: false, + unreadMarkerReportActionIndex: 5, + isInverted, + shouldDisablePillTracking, + }), + {initialProps: {shouldDisablePillTracking: true}}, + ); + + // When provisional viewability arrives before initial positioning settles. + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: isInverted ? 6 : 4, key: 'older-message', isViewable: true, item: {}}], changed: []}); + }); + + // Then the unread pill does not flash over the covered list. + expect(result.current.isFloatingMessageCounterVisible).toBe(false); + expect(onUnreadActionVisible).not.toHaveBeenCalled(); + + // When positioning finishes without another viewability event. + rerender({shouldDisablePillTracking: false}); + + // Then the saved viewport exposes the pill because the unread message is still outside it. + expect(result.current.isFloatingMessageCounterVisible).toBe(true); + + // When another positioning pass brings the unread message into its covered viewport. + rerender({shouldDisablePillTracking: true}); + act(() => { + result.current.onViewableItemsChanged({viewableItems: [{index: 5, key: 'unread-message', isViewable: true, item: {}}], changed: []}); + }); + + // Then the covered message does not complete read tracking. + expect(onUnreadActionVisible).not.toHaveBeenCalled(); + + // When that viewport becomes ready without another viewability event. + rerender({shouldDisablePillTracking: false}); + + // Then the latest saved viewport completes read tracking and hides the pill. + expect(onUnreadActionVisible).toHaveBeenCalled(); + expect(result.current.isFloatingMessageCounterVisible).toBe(false); + }); + it.each([false, true])('ignores viewability events behind another screen and resumes after refocus when inverted is %s', (isInverted) => { // Given the unread marker is outside the viewport and the pill is visible. const onUnreadActionVisible = jest.fn(); diff --git a/web/proxy.ts b/web/proxy.ts index b0133af7da5e..1f9738728472 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -72,8 +72,9 @@ const server = http.createServer((request: IncomingMessage, response: ServerResp headers: { ...request.headers, host: hostname, + // Identify the development proxy so upstream checks match the actual HTTP client. // eslint-disable-next-line @typescript-eslint/naming-convention - 'user-agent': request.headers['user-agent']?.concat(' Development-NewDot/1.0'), + 'user-agent': 'Development-NewDot/1.0', }, port: 443, }); @@ -87,6 +88,13 @@ const server = http.createServer((request: IncomingMessage, response: ServerResp proxyRequest.on('error', (error) => { /* eslint-disable-next-line no-console */ console.log(error); + // A failed upstream connection must not leave the browser waiting indefinitely. + if (response.headersSent) { + response.destroy(error); + return; + } + response.writeHead(502); + response.end(); }); });