From 36a335dcd0b8307d1423cb4f7d12f5e8b4c61394 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Thu, 5 Nov 2026 12:09:50 +0000 Subject: [PATCH 1/4] chore: upgrade LegendList to 3.7.1 and patch its scroll anchoring Bump @legendapp/list to 3.7.1 and add the four LegendList patches that keep a chat's visible messages in place, plus the Android MVCP patch update that lets native scroll compensation use LegendList's zero-sized anchor. They are extracted from #100733, where the report actions list moves to LegendList. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 8 +- package.json | 2 +- ...+001+apply-pending-anchor-correction.patch | 124 +++++++++ ...d-aligned-lists-to-last-visible-item.patch | 136 ++++++++++ ...chor-data-changes-after-scroll-jumps.patch | 88 +++++++ ...ist-after-initial-scroll-adjustments.patch | 240 ++++++++++++++++++ patches/@legendapp/list/details.md | 37 +++ patches/react-native/details.md | 7 +- ...act-native+0.86.0+002+fixMVCPAndroid.patch | 12 +- 9 files changed, 645 insertions(+), 9 deletions(-) create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.1+002+anchor-end-aligned-lists-to-last-visible-item.patch create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.1+004+reveal-list-after-initial-scroll-adjustments.patch create mode 100644 patches/@legendapp/list/details.md diff --git a/package-lock.json b/package-lock.json index da4940ae9e27..7f041e5a38dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "@fullstory/react-native": "^1.10.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", - "@legendapp/list": "^3.7.0", + "@legendapp/list": "^3.7.1", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", @@ -11729,9 +11729,9 @@ "license": "MIT" }, "node_modules/@legendapp/list": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.7.0.tgz", - "integrity": "sha512-Er2D5eKIv+rfbakJ3ra4pD7NCNtOj2m1nrU6s6Ea3dO+x3vgt0h9NE0dn5WHOLIRkPEWHiwbslgl95QxZiEkjg==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.7.1.tgz", + "integrity": "sha512-zgIy84yG2JP93+kib61rZBTcYGZ6fULrkCQz/MF7WrjVJeJQa/rR4z+rXfM5oveirO5mYkro4IDGytARIjJe9w==", "license": "MIT", "dependencies": { "use-sync-external-store": "^1.5.0" diff --git a/package.json b/package.json index c6ab2da72881..f38152a065b4 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "@fullstory/react-native": "^1.10.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", - "@legendapp/list": "^3.7.0", + "@legendapp/list": "^3.7.1", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", diff --git a/patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch b/patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch new file mode 100644 index 000000000000..c49af3ffb4b6 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch @@ -0,0 +1,124 @@ +001.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index 4500177..ff503a5 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -4719,7 +4719,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, mvcpAdjustmentSource } = params; ++ let { doMVCP } = 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"); +@@ -4820,6 +4821,11 @@ function calculateItemsInView(ctx, params = {}) { + } + } + } ++ // A deferred item-size correction must land in any pass that repositions items, or they move without it. ++ if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ doMVCP = true; ++ } + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged) : void 0; + if (dataChanged) { + resetLayoutCachesForDataChange(state); +001.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index 1dab5e5..fac9bd4 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -4698,7 +4698,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, mvcpAdjustmentSource } = params; ++ let { doMVCP } = 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"); +@@ -4799,6 +4800,11 @@ function calculateItemsInView(ctx, params = {}) { + } + } + } ++ // A deferred item-size correction must land in any pass that repositions items, or they move without it. ++ if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ doMVCP = true; ++ } + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged) : void 0; + if (dataChanged) { + resetLayoutCachesForDataChange(state); +001.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index 4500177..ff503a5 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -4719,7 +4719,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, mvcpAdjustmentSource } = params; ++ let { doMVCP } = 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"); +@@ -4820,6 +4821,11 @@ function calculateItemsInView(ctx, params = {}) { + } + } + } ++ // A deferred item-size correction must land in any pass that repositions items, or they move without it. ++ if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ doMVCP = true; ++ } + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged) : void 0; + if (dataChanged) { + resetLayoutCachesForDataChange(state); +001.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index 1dab5e5..fac9bd4 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -4698,7 +4698,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, mvcpAdjustmentSource } = params; ++ let { doMVCP } = 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"); +@@ -4799,6 +4800,11 @@ function calculateItemsInView(ctx, params = {}) { + } + } + } ++ // A deferred item-size correction must land in any pass that repositions items, or they move without it. ++ if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ doMVCP = true; ++ } + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged) : void 0; + if (dataChanged) { + resetLayoutCachesForDataChange(state); diff --git a/patches/@legendapp/list/@legendapp+list+3.7.1+002+anchor-end-aligned-lists-to-last-visible-item.patch b/patches/@legendapp/list/@legendapp+list+3.7.1+002+anchor-end-aligned-lists-to-last-visible-item.patch new file mode 100644 index 000000000000..2adf3007608a --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.1+002+anchor-end-aligned-lists-to-last-visible-item.patch @@ -0,0 +1,136 @@ +001.patch +002.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index ff503a5..b8ecd39 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -1621,6 +1621,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const { + maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ // End-aligned lists keep the last visible item in place, like an inverted list. ++ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1657,11 +1659,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : 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 = indexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +001.patch +002.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index fac9bd4..accd9fa 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -1600,6 +1600,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const { + maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ // End-aligned lists keep the last visible item in place, like an inverted list. ++ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1636,11 +1638,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : 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 = indexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +001.patch +002.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index ff503a5..b8ecd39 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -1621,6 +1621,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const { + maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ // End-aligned lists keep the last visible item in place, like an inverted list. ++ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1657,11 +1659,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : 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 = indexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +001.patch +002.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index fac9bd4..accd9fa 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -1600,6 +1600,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const { + maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ // End-aligned lists keep the last visible item in place, like an inverted list. ++ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1636,11 +1638,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : 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 = indexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; diff --git a/patches/@legendapp/list/@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch b/patches/@legendapp/list/@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch new file mode 100644 index 000000000000..8e96d3f5e865 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch @@ -0,0 +1,88 @@ +001.patch +002.patch +003.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index b8ecd39..3d15098 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -7692,6 +7692,10 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) + state.scrollForNextCalculateItemsInView = void 0; + } + state.positionsAreCurrent = false; ++ if (state.props.maintainVisibleContentPosition.data) { ++ // New data is anchored below; a reset left over from an earlier scroll jump would let its rows move the view. ++ 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); +001.patch +002.patch +003.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index accd9fa..0f0be2d 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -7671,6 +7671,10 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) + state.scrollForNextCalculateItemsInView = void 0; + } + state.positionsAreCurrent = false; ++ if (state.props.maintainVisibleContentPosition.data) { ++ // New data is anchored below; a reset left over from an earlier scroll jump would let its rows move the view. ++ 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); +001.patch +002.patch +003.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index b8ecd39..3d15098 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -7692,6 +7692,10 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) + state.scrollForNextCalculateItemsInView = void 0; + } + state.positionsAreCurrent = false; ++ if (state.props.maintainVisibleContentPosition.data) { ++ // New data is anchored below; a reset left over from an earlier scroll jump would let its rows move the view. ++ 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); +001.patch +002.patch +003.patch +s0 +s1 +s2 +s3 +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index accd9fa..0f0be2d 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -7671,6 +7671,10 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) + state.scrollForNextCalculateItemsInView = void 0; + } + state.positionsAreCurrent = false; ++ if (state.props.maintainVisibleContentPosition.data) { ++ // New data is anchored below; a reset left over from an earlier scroll jump would let its rows move the view. ++ 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/patches/@legendapp/list/@legendapp+list+3.7.1+004+reveal-list-after-initial-scroll-adjustments.patch b/patches/@legendapp/list/@legendapp+list+3.7.1+004+reveal-list-after-initial-scroll-adjustments.patch new file mode 100644 index 000000000000..1af7e2c9a0ec --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.1+004+reveal-list-after-initial-scroll-adjustments.patch @@ -0,0 +1,240 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index eeb3fb0..d1e3f1c 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -797,9 +797,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; +@@ -838,7 +835,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, {}); ++ } ++ }); + } + } + } +@@ -1232,7 +1235,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 fe5f415..2252796 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -776,9 +776,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; +@@ -817,7 +814,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, {}); ++ } ++ }); + } + } + } +@@ -1211,7 +1214,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 3d15098..72be92e 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -731,9 +731,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -746,7 +743,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, {}); ++ } ++ }); + } + } + } +@@ -1134,7 +1137,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 0f0be2d..5a359ba 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -710,9 +710,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -725,7 +722,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, {}); ++ } ++ }); + } + } + } +@@ -1113,7 +1116,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 3d15098..72be92e 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -731,9 +731,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -746,7 +743,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, {}); ++ } ++ }); + } + } + } +@@ -1134,7 +1137,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 0f0be2d..5a359ba 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -710,9 +710,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -725,7 +722,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, {}); ++ } ++ }); + } + } + } +@@ -1113,7 +1116,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/details.md b/patches/@legendapp/list/details.md new file mode 100644 index 000000000000..901ffa6a8ad8 --- /dev/null +++ b/patches/@legendapp/list/details.md @@ -0,0 +1,37 @@ +# `@legendapp/list` patches + +### [@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch](@legendapp+list+3.7.1+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. +- 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.1+002+anchor-end-aligned-lists-to-last-visible-item.patch](@legendapp+list+3.7.1+002+anchor-end-aligned-lists-to-last-visible-item.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. +- 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. + +### [@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch](@legendapp+list+3.7.1+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. +- 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.1+004+reveal-list-after-initial-scroll-adjustments.patch](@legendapp+list+3.7.1+004+reveal-list-after-initial-scroll-adjustments.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. +- 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. diff --git a/patches/react-native/details.md b/patches/react-native/details.md index b44903fcf93a..167168671d9d 100644 --- a/patches/react-native/details.md +++ b/patches/react-native/details.md @@ -9,13 +9,16 @@ ### [react-native+0.86.0+002+fixMVCPAndroid.patch](react-native+0.86.0+002+fixMVCPAndroid.patch) -- Reason: Fixes content jumping issues with `MaintainVisibleContentPosition` on Android, particularly in bidirectional pagination scenarios. The patch makes two key improvements: +- Reason: Fixes content jumping issues with `MaintainVisibleContentPosition` on Android, particularly in bidirectional pagination scenarios. The patch: 1. Changes when the first visible view is calculated - now happens on scroll events instead of during Fabric's willMountItems lifecycle, which was causing incorrect updates 2. Improves first visible view selection logic to handle Fabric's z-index-based view reordering by finding the view with the smallest position that's still greater than the scroll position + 3. Preserves a positioned, zero-sized first child as a scroll anchor. LegendList moves this anchor to compensate for item measurements. Selecting its surrounding container or rejecting its empty frame prevents native scroll compensation and makes the chat jump as estimated rows shrink. LegendList 3.7 places the anchor at its adjustment offset plus 10,000,000 pixels, beyond the viewport. The strict visibility check also prevents a fixed, zero-sized spacer at or above the scroll offset in another scroll view from taking over its visible-content anchor. +- Scope: This changes Android `MaintainVisibleContentPosition` for every React Native scroll view that enables it, not only the report-action list. Keep this in mind when updating React Native or investigating anchoring in other scroll views. - Upstream PR/issue: https://github.com/react/react-native/pull/57959 merged on 2026-10-28 as [9bc1a28a9623419d62eb350acd0c172644e2c022](https://github.com/react/react-native/commit/9bc1a28a9623419d62eb350acd0c172644e2c022). It ports the original Android MVCP proposal and includes z-index anchor selection and support for LegendList's positioned, zero-sized anchor. - Expected release: React Native 0.89.0, [currently scheduled for 2026-12-07](https://reactnative.dev/releases/overview), unless cherry-picked into an earlier release. The fix landed after the 0.88 branch cut and before the scheduled 0.89 branch cut on 2026-11-03. +- Reproducer: https://github.com/react/react-native/pull/59360 adds the RNTester reproduction, without a runtime fix. The standalone reproduction and proposed anchor patch are in https://github.com/margelo/react-native-mvcp-reproducer. - Earlier proposal: https://github.com/react/react-native/pull/46247 closed without merging. -- Removal: Expected to be removable when the adopted React Native release contains `9bc1a28`. Before removing it, verify Android scroll-position preservation during bidirectional pagination and row-height changes with the patch disabled. Keep it on React Native 0.86 unless the upstream fix is backported. +- Removal: Expected to be removable when the adopted React Native release contains `9bc1a28`. Before removing it, verify bidirectional pagination, changing row heights, and LegendList's positioned-anchor corrections with the patch disabled. Keep it on React Native 0.86 unless the upstream fix is backported. - E/App issue: 🛑 - PR Introducing Patch: https://github.com/Expensify/App/pull/46315 (introduced), https://github.com/Expensify/App/pull/45289 (refactored) diff --git a/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch b/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch index 223cce3db9b3..90b705c6df69 100644 --- a/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch +++ b/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch @@ -35,9 +35,16 @@ index 2bee605..ba26e7b 100644 for (i in config.minIndexForVisible until contentView.childCount) { val child = contentView.getChildAt(i) -@@ -128,27 +135,49 @@ internal class MaintainVisibleScrollPositionHelper( +@@ -128,27 +135,57 @@ internal class MaintainVisibleScrollPositionHelper( val position = if (horizontal) child.x + child.width else child.y + child.height ++ // Virtualized lists can use a zero-sized, positioned first child as their scroll anchor. ++ // Preserve it instead of choosing the container that holds all rendered items. ++ if (i == config.minIndexForVisible && child.width == 0 && child.height == 0 && position > currentScroll) { ++ firstVisibleView = child ++ break ++ } ++ // If the child is partially visible or this is the last child, select it as the anchor. - if (position > currentScroll || i == contentView.childCount - 1) { - firstVisibleViewRef = WeakReference(child) @@ -70,7 +77,8 @@ index 2bee605..ba26e7b 100644 } + val frame = Rect() + firstVisibleView.getHitRect(frame) -+ if (frame.width() > 0 || frame.height() > 0) { ++ // Zero-sized anchors have a meaningful position even though they have no area. ++ if (frame.width() > 0 || frame.height() > 0 || frame.left != 0 || frame.top != 0) { + prevFirstVisibleFrame = frame + } else { + prevFirstVisibleFrame = null From 59d60d7cbc4b1f979b25fc556155d4c6356bb3b6 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 9 Nov 2026 11:59:54 +0100 Subject: [PATCH 2/4] chore: upgrade LegendList to 3.7.2 and port its patches Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 8 +- package.json | 2 +- ...001+apply-pending-anchor-correction.patch} | 76 +++++++------------ ...-aligned-lists-to-last-visible-item.patch} | 68 +++++------------ ...hor-data-changes-after-scroll-jumps.patch} | 48 ++---------- ...st-after-initial-scroll-adjustments.patch} | 48 ++++++------ patches/@legendapp/list/details.md | 8 +- 7 files changed, 89 insertions(+), 169 deletions(-) rename patches/@legendapp/list/{@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch => @legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch} (72%) rename patches/@legendapp/list/{@legendapp+list+3.7.1+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} (73%) rename patches/@legendapp/list/{@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch => @legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch} (68%) rename patches/@legendapp/list/{@legendapp+list+3.7.1+004+reveal-list-after-initial-scroll-adjustments.patch => @legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch} (85%) diff --git a/package-lock.json b/package-lock.json index 7f041e5a38dd..d16d9db78b6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "@fullstory/react-native": "^1.10.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", - "@legendapp/list": "^3.7.1", + "@legendapp/list": "^3.7.2", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", @@ -11729,9 +11729,9 @@ "license": "MIT" }, "node_modules/@legendapp/list": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.7.1.tgz", - "integrity": "sha512-zgIy84yG2JP93+kib61rZBTcYGZ6fULrkCQz/MF7WrjVJeJQa/rR4z+rXfM5oveirO5mYkro4IDGytARIjJe9w==", + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.7.2.tgz", + "integrity": "sha512-StFy5qjOHHuxHKjenwLq500S/z+zuF43hj8iqXhX9EMtzeOagPfsnMRWphvBG4CYBvMTX6VzgLjN5BzzwY1jDA==", "license": "MIT", "dependencies": { "use-sync-external-store": "^1.5.0" diff --git a/package.json b/package.json index f38152a065b4..76a0fa04df82 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "@fullstory/react-native": "^1.10.2", "@gorhom/portal": "^1.0.14", "@invertase/react-native-apple-authentication": "^2.5.0", - "@legendapp/list": "^3.7.1", + "@legendapp/list": "^3.7.2", "@lottiefiles/dotlottie-react": "0.13.5", "@onfido/react-native-sdk": "15.1.0", "@pusher/pusher-websocket-react-native": "^1.3.1", diff --git a/patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch similarity index 72% rename from patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch rename to patches/@legendapp/list/@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch index c49af3ffb4b6..19a3139e8df6 100644 --- a/patches/@legendapp/list/@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch @@ -1,124 +1,104 @@ -001.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js -index 4500177..ff503a5 100644 +index 5155454..c102661 100644 --- a/node_modules/@legendapp/list/react-native.web.js +++ b/node_modules/@legendapp/list/react-native.web.js -@@ -4719,7 +4719,8 @@ function calculateItemsInView(ctx, params = {}) { +@@ -4843,7 +4843,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, mvcpAdjustmentSource } = params; -+ let { doMVCP } = 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"); -@@ -4820,6 +4821,11 @@ function calculateItemsInView(ctx, params = {}) { +@@ -4943,6 +4944,11 @@ function calculateItemsInView(ctx, params = {}) { } } } -+ // A deferred item-size correction must land in any pass that repositions items, or they move without it. + if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { + state.scheduledWork.cancel("mvcpRecalculate"); + doMVCP = true; ++ mvcpAdjustmentSource = "item-size"; + } - const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged) : void 0; + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged, mvcpAdjustmentSource) : void 0; if (dataChanged) { resetLayoutCachesForDataChange(state); -001.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs -index 1dab5e5..fac9bd4 100644 +index ce08abe..ff71fd6 100644 --- a/node_modules/@legendapp/list/react-native.web.mjs +++ b/node_modules/@legendapp/list/react-native.web.mjs -@@ -4698,7 +4698,8 @@ function calculateItemsInView(ctx, params = {}) { +@@ -4822,7 +4822,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, mvcpAdjustmentSource } = params; -+ let { doMVCP } = 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"); -@@ -4799,6 +4800,11 @@ function calculateItemsInView(ctx, params = {}) { +@@ -4922,6 +4923,11 @@ function calculateItemsInView(ctx, params = {}) { } } } -+ // A deferred item-size correction must land in any pass that repositions items, or they move without it. + if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { + state.scheduledWork.cancel("mvcpRecalculate"); + doMVCP = true; ++ mvcpAdjustmentSource = "item-size"; + } - const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged) : void 0; + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged, mvcpAdjustmentSource) : void 0; if (dataChanged) { resetLayoutCachesForDataChange(state); -001.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js -index 4500177..ff503a5 100644 +index 5155454..c102661 100644 --- a/node_modules/@legendapp/list/react.js +++ b/node_modules/@legendapp/list/react.js -@@ -4719,7 +4719,8 @@ function calculateItemsInView(ctx, params = {}) { +@@ -4843,7 +4843,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, mvcpAdjustmentSource } = params; -+ let { doMVCP } = 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"); -@@ -4820,6 +4821,11 @@ function calculateItemsInView(ctx, params = {}) { +@@ -4943,6 +4944,11 @@ function calculateItemsInView(ctx, params = {}) { } } } -+ // A deferred item-size correction must land in any pass that repositions items, or they move without it. + if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { + state.scheduledWork.cancel("mvcpRecalculate"); + doMVCP = true; ++ mvcpAdjustmentSource = "item-size"; + } - const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged) : void 0; + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged, mvcpAdjustmentSource) : void 0; if (dataChanged) { resetLayoutCachesForDataChange(state); -001.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs -index 1dab5e5..fac9bd4 100644 +index ce08abe..ff71fd6 100644 --- a/node_modules/@legendapp/list/react.mjs +++ b/node_modules/@legendapp/list/react.mjs -@@ -4698,7 +4698,8 @@ function calculateItemsInView(ctx, params = {}) { +@@ -4822,7 +4822,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, mvcpAdjustmentSource } = params; -+ let { doMVCP } = 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"); -@@ -4799,6 +4800,11 @@ function calculateItemsInView(ctx, params = {}) { +@@ -4922,6 +4923,11 @@ function calculateItemsInView(ctx, params = {}) { } } } -+ // A deferred item-size correction must land in any pass that repositions items, or they move without it. + if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { + state.scheduledWork.cancel("mvcpRecalculate"); + doMVCP = true; ++ mvcpAdjustmentSource = "item-size"; + } - const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged) : void 0; + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged, mvcpAdjustmentSource) : void 0; if (dataChanged) { resetLayoutCachesForDataChange(state); diff --git a/patches/@legendapp/list/@legendapp+list+3.7.1+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 similarity index 73% rename from patches/@legendapp/list/@legendapp+list+3.7.1+002+anchor-end-aligned-lists-to-last-visible-item.patch rename to patches/@legendapp/list/@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch index 2adf3007608a..bad48a60d7d7 100644 --- a/patches/@legendapp/list/@legendapp+list+3.7.1+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 @@ -1,25 +1,18 @@ -001.patch -002.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js -index ff503a5..b8ecd39 100644 +index c102661..99cac83 100644 --- a/node_modules/@legendapp/list/react-native.web.js +++ b/node_modules/@legendapp/list/react-native.web.js -@@ -1621,6 +1621,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { +@@ -1706,6 +1706,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { const { maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } } = props; -+ // End-aligned lists keep the last visible item in place, like an inverted list. + const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; const now = Date.now(); const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); const scrollingTo = state.scrollingTo; -@@ -1657,11 +1659,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { +@@ -1743,11 +1744,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { } else if (scrollTarget !== void 0) { - targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : getId(state, scrollTarget); + 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); @@ -29,31 +22,24 @@ index ff503a5..b8ecd39 100644 - const id = idsInView[i]; + for (let i = 0; i < anchorIdsInView.length; i++) { + const id = anchorIdsInView[i]; - const index = indexByKey.get(id); + const index = previousIndexByKey.get(id); if (index !== void 0) { const position = positions[index]; -001.patch -002.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs -index fac9bd4..accd9fa 100644 +index ff71fd6..a7a89dd 100644 --- a/node_modules/@legendapp/list/react-native.web.mjs +++ b/node_modules/@legendapp/list/react-native.web.mjs -@@ -1600,6 +1600,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { +@@ -1685,6 +1685,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { const { maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } } = props; -+ // End-aligned lists keep the last visible item in place, like an inverted list. + const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; const now = Date.now(); const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); const scrollingTo = state.scrollingTo; -@@ -1636,11 +1638,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { +@@ -1722,11 +1723,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { } else if (scrollTarget !== void 0) { - targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : getId(state, scrollTarget); + 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); @@ -63,31 +49,24 @@ index fac9bd4..accd9fa 100644 - const id = idsInView[i]; + for (let i = 0; i < anchorIdsInView.length; i++) { + const id = anchorIdsInView[i]; - const index = indexByKey.get(id); + const index = previousIndexByKey.get(id); if (index !== void 0) { const position = positions[index]; -001.patch -002.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js -index ff503a5..b8ecd39 100644 +index c102661..99cac83 100644 --- a/node_modules/@legendapp/list/react.js +++ b/node_modules/@legendapp/list/react.js -@@ -1621,6 +1621,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { +@@ -1706,6 +1706,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { const { maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } } = props; -+ // End-aligned lists keep the last visible item in place, like an inverted list. + const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; const now = Date.now(); const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); const scrollingTo = state.scrollingTo; -@@ -1657,11 +1659,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { +@@ -1743,11 +1744,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { } else if (scrollTarget !== void 0) { - targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : getId(state, scrollTarget); + 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); @@ -97,31 +76,24 @@ index ff503a5..b8ecd39 100644 - const id = idsInView[i]; + for (let i = 0; i < anchorIdsInView.length; i++) { + const id = anchorIdsInView[i]; - const index = indexByKey.get(id); + const index = previousIndexByKey.get(id); if (index !== void 0) { const position = positions[index]; -001.patch -002.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs -index fac9bd4..accd9fa 100644 +index ff71fd6..a7a89dd 100644 --- a/node_modules/@legendapp/list/react.mjs +++ b/node_modules/@legendapp/list/react.mjs -@@ -1600,6 +1600,8 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { +@@ -1685,6 +1685,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { const { maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } } = props; -+ // End-aligned lists keep the last visible item in place, like an inverted list. + const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; const now = Date.now(); const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); const scrollingTo = state.scrollingTo; -@@ -1636,11 +1638,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { +@@ -1722,11 +1723,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { } else if (scrollTarget !== void 0) { - targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : getId(state, scrollTarget); + 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); @@ -131,6 +103,6 @@ index fac9bd4..accd9fa 100644 - const id = idsInView[i]; + for (let i = 0; i < anchorIdsInView.length; i++) { + const id = anchorIdsInView[i]; - const index = indexByKey.get(id); + const index = previousIndexByKey.get(id); if (index !== void 0) { const position = positions[index]; diff --git a/patches/@legendapp/list/@legendapp+list+3.7.1+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 similarity index 68% rename from patches/@legendapp/list/@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch rename to patches/@legendapp/list/@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch index 8e96d3f5e865..86a890e5d799 100644 --- a/patches/@legendapp/list/@legendapp+list+3.7.1+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,86 +1,54 @@ -001.patch -002.patch -003.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js -index b8ecd39..3d15098 100644 +index 99cac83..30d9757 100644 --- a/node_modules/@legendapp/list/react-native.web.js +++ b/node_modules/@legendapp/list/react-native.web.js -@@ -7692,6 +7692,10 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) +@@ -7880,6 +7880,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) state.scrollForNextCalculateItemsInView = void 0; } state.positionsAreCurrent = false; + if (state.props.maintainVisibleContentPosition.data) { -+ // New data is anchored below; a reset left over from an earlier scroll jump would let its rows move the view. + 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); -001.patch -002.patch -003.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs -index accd9fa..0f0be2d 100644 +index a7a89dd..f55dea9 100644 --- a/node_modules/@legendapp/list/react-native.web.mjs +++ b/node_modules/@legendapp/list/react-native.web.mjs -@@ -7671,6 +7671,10 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) +@@ -7859,6 +7859,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) state.scrollForNextCalculateItemsInView = void 0; } state.positionsAreCurrent = false; + if (state.props.maintainVisibleContentPosition.data) { -+ // New data is anchored below; a reset left over from an earlier scroll jump would let its rows move the view. + 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); -001.patch -002.patch -003.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js -index b8ecd39..3d15098 100644 +index 99cac83..30d9757 100644 --- a/node_modules/@legendapp/list/react.js +++ b/node_modules/@legendapp/list/react.js -@@ -7692,6 +7692,10 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) +@@ -7880,6 +7880,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) state.scrollForNextCalculateItemsInView = void 0; } state.positionsAreCurrent = false; + if (state.props.maintainVisibleContentPosition.data) { -+ // New data is anchored below; a reset left over from an earlier scroll jump would let its rows move the view. + 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); -001.patch -002.patch -003.patch -s0 -s1 -s2 -s3 diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs -index accd9fa..0f0be2d 100644 +index a7a89dd..f55dea9 100644 --- a/node_modules/@legendapp/list/react.mjs +++ b/node_modules/@legendapp/list/react.mjs -@@ -7671,6 +7671,10 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) +@@ -7859,6 +7859,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) state.scrollForNextCalculateItemsInView = void 0; } state.positionsAreCurrent = false; + if (state.props.maintainVisibleContentPosition.data) { -+ // New data is anchored below; a reset left over from an earlier scroll jump would let its rows move the view. + state.userScrollAnchorReset = void 0; + } calculateItemsInView(ctx, { dataChanged: true, doMVCP: true }); diff --git a/patches/@legendapp/list/@legendapp+list+3.7.1+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 similarity index 85% rename from patches/@legendapp/list/@legendapp+list+3.7.1+004+reveal-list-after-initial-scroll-adjustments.patch rename to patches/@legendapp/list/@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch index 1af7e2c9a0ec..3ced246136c8 100644 --- a/patches/@legendapp/list/@legendapp+list+3.7.1+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 @@ -1,8 +1,8 @@ diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js -index eeb3fb0..d1e3f1c 100644 +index 4af685d..8fd2025 100644 --- a/node_modules/@legendapp/list/react-native.js +++ b/node_modules/@legendapp/list/react-native.js -@@ -797,9 +797,6 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -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); @@ -12,7 +12,7 @@ index eeb3fb0..d1e3f1c 100644 } }; state.scroll += positionDiff; -@@ -838,7 +835,13 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -1104,7 +1101,13 @@ function requestAdjust(ctx, positionDiff, source) { } } else { state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; @@ -27,7 +27,7 @@ index eeb3fb0..d1e3f1c 100644 } } } -@@ -1232,7 +1235,9 @@ function setInitialRenderState(ctx, { +@@ -1529,7 +1532,9 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -39,10 +39,10 @@ index eeb3fb0..d1e3f1c 100644 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 fe5f415..2252796 100644 +index 9ebfddc..53fb116 100644 --- a/node_modules/@legendapp/list/react-native.mjs +++ b/node_modules/@legendapp/list/react-native.mjs -@@ -776,9 +776,6 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -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); @@ -52,7 +52,7 @@ index fe5f415..2252796 100644 } }; state.scroll += positionDiff; -@@ -817,7 +814,13 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -1083,7 +1080,13 @@ function requestAdjust(ctx, positionDiff, source) { } } else { state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; @@ -67,7 +67,7 @@ index fe5f415..2252796 100644 } } } -@@ -1211,7 +1214,9 @@ function setInitialRenderState(ctx, { +@@ -1508,7 +1511,9 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -79,10 +79,10 @@ index fe5f415..2252796 100644 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 3d15098..72be92e 100644 +index 30d9757..b782503 100644 --- a/node_modules/@legendapp/list/react-native.web.js +++ b/node_modules/@legendapp/list/react-native.web.js -@@ -731,9 +731,6 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -1001,9 +1001,6 @@ function requestAdjust(ctx, positionDiff, source) { const doit = () => { { state.scrollAdjustHandler.requestAdjust(positionDiff); @@ -92,7 +92,7 @@ index 3d15098..72be92e 100644 } }; state.scroll += positionDiff; -@@ -746,7 +743,13 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -1016,7 +1013,13 @@ function requestAdjust(ctx, positionDiff, source) { doit(); } else { state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; @@ -107,7 +107,7 @@ index 3d15098..72be92e 100644 } } } -@@ -1134,7 +1137,9 @@ function setInitialRenderState(ctx, { +@@ -1435,7 +1438,9 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -119,10 +119,10 @@ index 3d15098..72be92e 100644 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 0f0be2d..5a359ba 100644 +index f55dea9..a1bcd90 100644 --- a/node_modules/@legendapp/list/react-native.web.mjs +++ b/node_modules/@legendapp/list/react-native.web.mjs -@@ -710,9 +710,6 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -980,9 +980,6 @@ function requestAdjust(ctx, positionDiff, source) { const doit = () => { { state.scrollAdjustHandler.requestAdjust(positionDiff); @@ -132,7 +132,7 @@ index 0f0be2d..5a359ba 100644 } }; state.scroll += positionDiff; -@@ -725,7 +722,13 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -995,7 +992,13 @@ function requestAdjust(ctx, positionDiff, source) { doit(); } else { state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; @@ -147,7 +147,7 @@ index 0f0be2d..5a359ba 100644 } } } -@@ -1113,7 +1116,9 @@ function setInitialRenderState(ctx, { +@@ -1414,7 +1417,9 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -159,10 +159,10 @@ index 0f0be2d..5a359ba 100644 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 3d15098..72be92e 100644 +index 30d9757..b782503 100644 --- a/node_modules/@legendapp/list/react.js +++ b/node_modules/@legendapp/list/react.js -@@ -731,9 +731,6 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -1001,9 +1001,6 @@ function requestAdjust(ctx, positionDiff, source) { const doit = () => { { state.scrollAdjustHandler.requestAdjust(positionDiff); @@ -172,7 +172,7 @@ index 3d15098..72be92e 100644 } }; state.scroll += positionDiff; -@@ -746,7 +743,13 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -1016,7 +1013,13 @@ function requestAdjust(ctx, positionDiff, source) { doit(); } else { state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; @@ -187,7 +187,7 @@ index 3d15098..72be92e 100644 } } } -@@ -1134,7 +1137,9 @@ function setInitialRenderState(ctx, { +@@ -1435,7 +1438,9 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -199,10 +199,10 @@ index 3d15098..72be92e 100644 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 0f0be2d..5a359ba 100644 +index f55dea9..a1bcd90 100644 --- a/node_modules/@legendapp/list/react.mjs +++ b/node_modules/@legendapp/list/react.mjs -@@ -710,9 +710,6 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -980,9 +980,6 @@ function requestAdjust(ctx, positionDiff, source) { const doit = () => { { state.scrollAdjustHandler.requestAdjust(positionDiff); @@ -212,7 +212,7 @@ index 0f0be2d..5a359ba 100644 } }; state.scroll += positionDiff; -@@ -725,7 +722,13 @@ function requestAdjust(ctx, positionDiff, source) { +@@ -995,7 +992,13 @@ function requestAdjust(ctx, positionDiff, source) { doit(); } else { state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; @@ -227,7 +227,7 @@ index 0f0be2d..5a359ba 100644 } } } -@@ -1113,7 +1116,9 @@ function setInitialRenderState(ctx, { +@@ -1414,7 +1417,9 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } diff --git a/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md index 901ffa6a8ad8..4889cf76a52a 100644 --- a/patches/@legendapp/list/details.md +++ b/patches/@legendapp/list/details.md @@ -1,6 +1,6 @@ # `@legendapp/list` patches -### [@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch](@legendapp+list+3.7.1+001+apply-pending-anchor-correction.patch) +### [@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. @@ -9,7 +9,7 @@ - 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.1+002+anchor-end-aligned-lists-to-last-visible-item.patch](@legendapp+list+3.7.1+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+anchor-end-aligned-lists-to-last-visible-item.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. @@ -18,7 +18,7 @@ - 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. -### [@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch](@legendapp+list+3.7.1+003+anchor-data-changes-after-scroll-jumps.patch) +### [@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. @@ -27,7 +27,7 @@ - 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.1+004+reveal-list-after-initial-scroll-adjustments.patch](@legendapp+list+3.7.1+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+reveal-list-after-initial-scroll-adjustments.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. From 94d79b867b1ff1606300cb17fbc9db63524cb72f Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 9 Nov 2026 12:08:04 +0100 Subject: [PATCH 3/4] fix: keep LegendList's native scroll adjustments precise LegendList anchors native scroll adjustments to a view placed 10,000,000 points down. Native layout stores positions as 32-bit floats, which hold that value only to whole points, so each adjustment was rounded while the items moved by the exact amount. Patch the anchor down to 1,000,000 points, as proposed upstream in LegendApp/legend-list#602. Co-Authored-By: Claude Opus 5.5 --- ...05+keep-scroll-adjust-anchor-precise.patch | 30 +++++++++++++++++++ patches/@legendapp/list/details.md | 9 ++++++ 2 files changed, 39 insertions(+) create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch 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 new file mode 100644 index 000000000000..8c8072e4d7a7 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch @@ -0,0 +1,30 @@ +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 +@@ -6636,7 +6636,10 @@ + 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 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -6615,7 +6615,10 @@ + 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/patches/@legendapp/list/details.md b/patches/@legendapp/list/details.md index 4889cf76a52a..5865a4976761 100644 --- a/patches/@legendapp/list/details.md +++ b/patches/@legendapp/list/details.md @@ -35,3 +35,12 @@ - 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. + +### [@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. +- 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. From 3e5ac434b73cfeea2098f0813348d1f2ecbca1a5 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Mon, 9 Nov 2026 14:17:09 +0100 Subject: [PATCH 4/4] fix: keep LegendList's native initial scrolls on their current target Before a list is ready, LegendList moved its native anchor a frame after the positions changed, so an initial scroll dispatched in between was shifted a second time, and retries reused a stale target. Patch LegendList as proposed upstream in LegendApp/legend-list#604. Co-Authored-By: Claude Opus 5.5 --- ...keep-native-initial-scroll-on-target.patch | 228 ++++++++++++++++++ patches/@legendapp/list/details.md | 9 + 2 files changed, 237 insertions(+) create mode 100644 patches/@legendapp/list/@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch 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 new file mode 100644 index 000000000000..2bc501906ab1 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+006+keep-native-initial-scroll-on-target.patch @@ -0,0 +1,228 @@ +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/details.md b/patches/@legendapp/list/details.md index 5865a4976761..b3446ceaf904 100644 --- a/patches/@legendapp/list/details.md +++ b/patches/@legendapp/list/details.md @@ -44,3 +44,12 @@ - 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) + +- 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 +- 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.