diff --git a/package-lock.json b/package-lock.json index da4940ae9e27..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.0", + "@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.0", - "resolved": "https://registry.npmjs.org/@legendapp/list/-/list-3.7.0.tgz", - "integrity": "sha512-Er2D5eKIv+rfbakJ3ra4pD7NCNtOj2m1nrU6s6Ea3dO+x3vgt0h9NE0dn5WHOLIRkPEWHiwbslgl95QxZiEkjg==", + "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 c6ab2da72881..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.0", + "@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.2+001+apply-pending-anchor-correction.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch new file mode 100644 index 000000000000..19a3139e8df6 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+001+apply-pending-anchor-correction.patch @@ -0,0 +1,104 @@ +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index 5155454..c102661 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -4843,7 +4843,8 @@ function calculateItemsInView(ctx, params = {}) { + const stickyHeaderIndicesArr = state.props.stickyHeaderIndicesArr || []; + const stickyHeaderIndicesSet = state.props.stickyHeaderIndicesSet || /* @__PURE__ */ new Set(); + const drawDistance = getEffectiveDrawDistance(ctx, params.drawDistanceMode); +- const { dataChanged, doMVCP, forceFullItemPositions, mvcpAdjustmentSource } = params; ++ const { dataChanged, forceFullItemPositions } = params; ++ let { doMVCP, mvcpAdjustmentSource } = params; + const bootstrapInitialScrollState = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; + const suppressInitialScrollSideEffects = !!bootstrapInitialScrollState; + const prevNumContainers = peek$(ctx, "numContainers"); +@@ -4943,6 +4944,11 @@ function calculateItemsInView(ctx, params = {}) { + } + } + } ++ if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ doMVCP = true; ++ mvcpAdjustmentSource = "item-size"; ++ } + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged, mvcpAdjustmentSource) : void 0; + if (dataChanged) { + resetLayoutCachesForDataChange(state); +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index ce08abe..ff71fd6 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -4822,7 +4822,8 @@ function calculateItemsInView(ctx, params = {}) { + const stickyHeaderIndicesArr = state.props.stickyHeaderIndicesArr || []; + const stickyHeaderIndicesSet = state.props.stickyHeaderIndicesSet || /* @__PURE__ */ new Set(); + const drawDistance = getEffectiveDrawDistance(ctx, params.drawDistanceMode); +- const { dataChanged, doMVCP, forceFullItemPositions, mvcpAdjustmentSource } = params; ++ const { dataChanged, forceFullItemPositions } = params; ++ let { doMVCP, mvcpAdjustmentSource } = params; + const bootstrapInitialScrollState = ((_a3 = state.initialScrollSession) == null ? void 0 : _a3.kind) === "bootstrap" ? state.initialScrollSession.bootstrap : void 0; + const suppressInitialScrollSideEffects = !!bootstrapInitialScrollState; + const prevNumContainers = peek$(ctx, "numContainers"); +@@ -4922,6 +4923,11 @@ function calculateItemsInView(ctx, params = {}) { + } + } + } ++ if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ doMVCP = true; ++ mvcpAdjustmentSource = "item-size"; ++ } + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged, mvcpAdjustmentSource) : void 0; + if (dataChanged) { + resetLayoutCachesForDataChange(state); +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index 5155454..c102661 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -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 } = 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"); +@@ -4943,6 +4944,11 @@ function calculateItemsInView(ctx, params = {}) { + } + } + } ++ if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ doMVCP = true; ++ mvcpAdjustmentSource = "item-size"; ++ } + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged, mvcpAdjustmentSource) : void 0; + if (dataChanged) { + resetLayoutCachesForDataChange(state); +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index ce08abe..ff71fd6 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -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 } = 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"); +@@ -4922,6 +4923,11 @@ function calculateItemsInView(ctx, params = {}) { + } + } + } ++ if (!doMVCP && state.scheduledWork.has("mvcpRecalculate")) { ++ state.scheduledWork.cancel("mvcpRecalculate"); ++ doMVCP = true; ++ mvcpAdjustmentSource = "item-size"; ++ } + const checkMVCP = doMVCP && !suppressInitialScrollSideEffects ? prepareMVCP(ctx, dataChanged, mvcpAdjustmentSource) : void 0; + if (dataChanged) { + resetLayoutCachesForDataChange(state); diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch new file mode 100644 index 000000000000..bad48a60d7d7 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch @@ -0,0 +1,108 @@ +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index c102661..99cac83 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -1706,6 +1706,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const { + maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1743,11 +1744,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { +- for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ for (let i = 0; i < anchorIdsInView.length; i++) { ++ const id = anchorIdsInView[i]; + const index = previousIndexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index ff71fd6..a7a89dd 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -1685,6 +1685,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const { + maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1722,11 +1723,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { +- for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ for (let i = 0; i < anchorIdsInView.length; i++) { ++ const id = anchorIdsInView[i]; + const index = previousIndexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index c102661..99cac83 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -1706,6 +1706,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const { + maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1743,11 +1744,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { +- for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ for (let i = 0; i < anchorIdsInView.length; i++) { ++ const id = anchorIdsInView[i]; + const index = previousIndexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index ff71fd6..a7a89dd 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -1685,6 +1685,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + const { + maintainVisibleContentPosition: { data: mvcpData, size: mvcpScroll, shouldRestorePosition } + } = props; ++ const anchorIdsInView = props.alignItemsAtEnd ? [...idsInView].reverse() : idsInView; + const now = Date.now(); + const enableMVCPAnchorLock = (!!dataChanged || !!state.mvcpAnchorLock); + const scrollingTo = state.scrollingTo; +@@ -1722,11 +1723,11 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } else if (scrollTarget !== void 0) { + targetId = (_c = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _c : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ targetId = anchorIdsInView.find((id) => indexByKey.get(id) !== void 0); + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { +- for (let i = 0; i < idsInView.length; i++) { +- const id = idsInView[i]; ++ for (let i = 0; i < anchorIdsInView.length; i++) { ++ const id = anchorIdsInView[i]; + const index = previousIndexByKey.get(id); + if (index !== void 0) { + const position = positions[index]; diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch new file mode 100644 index 000000000000..86a890e5d799 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+003+anchor-data-changes-after-scroll-jumps.patch @@ -0,0 +1,56 @@ +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index 99cac83..30d9757 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -7880,6 +7880,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) + state.scrollForNextCalculateItemsInView = void 0; + } + state.positionsAreCurrent = false; ++ if (state.props.maintainVisibleContentPosition.data) { ++ state.userScrollAnchorReset = void 0; ++ } + calculateItemsInView(ctx, { dataChanged: true, doMVCP: true }); + if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { + const removedMeasurements = new Set(state.pendingEndMeasurements); +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index a7a89dd..f55dea9 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -7859,6 +7859,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) + state.scrollForNextCalculateItemsInView = void 0; + } + state.positionsAreCurrent = false; ++ if (state.props.maintainVisibleContentPosition.data) { ++ state.userScrollAnchorReset = void 0; ++ } + calculateItemsInView(ctx, { dataChanged: true, doMVCP: true }); + if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { + const removedMeasurements = new Set(state.pendingEndMeasurements); +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index 99cac83..30d9757 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -7880,6 +7880,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) + state.scrollForNextCalculateItemsInView = void 0; + } + state.positionsAreCurrent = false; ++ if (state.props.maintainVisibleContentPosition.data) { ++ state.userScrollAnchorReset = void 0; ++ } + calculateItemsInView(ctx, { dataChanged: true, doMVCP: true }); + if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { + const removedMeasurements = new Set(state.pendingEndMeasurements); +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index a7a89dd..f55dea9 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -7859,6 +7859,9 @@ function checkResetContainers(ctx, dataProp, { didColumnsChange = false } = {}) + state.scrollForNextCalculateItemsInView = void 0; + } + state.positionsAreCurrent = false; ++ if (state.props.maintainVisibleContentPosition.data) { ++ state.userScrollAnchorReset = void 0; ++ } + calculateItemsInView(ctx, { dataChanged: true, doMVCP: true }); + if ((_a3 = state.pendingEndMeasurements) == null ? void 0 : _a3.size) { + const removedMeasurements = new Set(state.pendingEndMeasurements); diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch new file mode 100644 index 000000000000..3ced246136c8 --- /dev/null +++ b/patches/@legendapp/list/@legendapp+list+3.7.2+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 4af685d..8fd2025 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -1063,9 +1063,6 @@ function requestAdjust(ctx, positionDiff, source) { + ctx.doScrollTo({ horizontal: state.props.horizontal, isAdjustment: true, offset: state.scroll }); + } else { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -1104,7 +1101,13 @@ function requestAdjust(ctx, positionDiff, source) { + } + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ requestAnimationFrame(() => { ++ doit(); ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } +@@ -1529,7 +1532,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index 9ebfddc..53fb116 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -1042,9 +1042,6 @@ function requestAdjust(ctx, positionDiff, source) { + ctx.doScrollTo({ horizontal: state.props.horizontal, isAdjustment: true, offset: state.scroll }); + } else { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -1083,7 +1080,13 @@ function requestAdjust(ctx, positionDiff, source) { + } + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ requestAnimationFrame(() => { ++ doit(); ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } +@@ -1508,7 +1511,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index 30d9757..b782503 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -1001,9 +1001,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -1016,7 +1013,13 @@ function requestAdjust(ctx, positionDiff, source) { + doit(); + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ requestAnimationFrame(() => { ++ doit(); ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } +@@ -1435,7 +1438,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index f55dea9..a1bcd90 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -980,9 +980,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -995,7 +992,13 @@ function requestAdjust(ctx, positionDiff, source) { + doit(); + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ requestAnimationFrame(() => { ++ doit(); ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } +@@ -1414,7 +1417,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index 30d9757..b782503 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -1001,9 +1001,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -1016,7 +1013,13 @@ function requestAdjust(ctx, positionDiff, source) { + doit(); + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ requestAnimationFrame(() => { ++ doit(); ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } +@@ -1435,7 +1438,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index f55dea9..a1bcd90 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -980,9 +980,6 @@ function requestAdjust(ctx, positionDiff, source) { + const doit = () => { + { + state.scrollAdjustHandler.requestAdjust(positionDiff); +- if (state.adjustingFromInitialMount) { +- state.adjustingFromInitialMount--; +- } + } + }; + state.scroll += positionDiff; +@@ -995,7 +992,13 @@ function requestAdjust(ctx, positionDiff, source) { + doit(); + } else { + state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 0) + 1; +- requestAnimationFrame(doit); ++ requestAnimationFrame(() => { ++ doit(); ++ state.adjustingFromInitialMount = (state.adjustingFromInitialMount || 1) - 1; ++ if (!state.adjustingFromInitialMount) { ++ setInitialRenderState(ctx, {}); ++ } ++ }); + } + } + } +@@ -1414,7 +1417,9 @@ function setInitialRenderState(ctx, { + if (didInitialScroll) { + state.didFinishInitialScroll = true; + } +- const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); ++ const isReadyToRender = Boolean( ++ state.didContainersLayout && state.didFinishInitialScroll && !state.adjustingFromInitialMount ++ ); + if (isReadyToRender && !peek$(ctx, "readyToRender")) { + set$(ctx, "readyToRender", true); + setAdaptiveRender(ctx, "normal", "ready"); diff --git a/patches/@legendapp/list/@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch b/patches/@legendapp/list/@legendapp+list+3.7.2+005+keep-scroll-adjust-anchor-precise.patch 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/@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 new file mode 100644 index 000000000000..b3446ceaf904 --- /dev/null +++ b/patches/@legendapp/list/details.md @@ -0,0 +1,55 @@ +# `@legendapp/list` patches + +### [@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. +- Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/596 +- E/App issue: https://github.com/Expensify/App/pull/100733#issuecomment-6411009930 +- PR introducing patch: https://github.com/Expensify/App/pull/100733 +- Reproduction: On web, open a chat with more history than the first page. Scroll to the top until older messages load. The message at the top of the screen should stay in place instead of jumping and snapping back. + +### [@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch](@legendapp+list+3.7.2+002+anchor-end-aligned-lists-to-last-visible-item.patch) + +- 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.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. +- Upstream PR/issue: https://github.com/LegendApp/legend-list/pull/598 +- E/App issue: 🛑 +- PR introducing patch: https://github.com/Expensify/App/pull/100733 +- Reproduction: On web, clear the cache and open a chat with a long history. Drag the scrollbar thumb quickly up and down a few times, then hold it near the top until older messages load. The messages on screen should not move. + +### [@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch](@legendapp+list+3.7.2+004+reveal-list-after-initial-scroll-adjustments.patch) + +- 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. + +### [@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. + +### [@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. 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