Files
paseo/patches/@legendapp+list+3.7.1.patch
T

576 lines
36 KiB
Diff

# LegendList 3.7.1 for the native chat timeline (docs/agent-stream-disclosure.md).
# Upstream: https://github.com/LegendApp/legend-list/issues/593 covers every change below.
# react-native.js and react-native.mjs carry the same hunks; each has a "Paseo patch" comment.
#
# 1. getEstimatedItemSize (react-native.d.ts, getItemSize, LegendListInner props): a per-item
# estimate for an unmeasured row wins over the running average for its type.
# 2. MVCP anchor lock on native (canRestoreAnchorLock, resolveAnchorLock, updateAnchorLock,
# prepareMVCP): upstream enables it on web only. On native the lock ends when its row leaves
# the view or shouldRestorePosition rejects it, instead of after 300 ms.
# 3. Preserved initial end only with maintainScrollAtEnd (finishScrollTo,
# finishBootstrapInitialScrollWithoutScroll): otherwise every data change re-arms the initial
# scroll to the end.
# 4. Silent initial scroll retry in one task (checkFinishedScrollFallback): the nudge and the return
# to the target go out together, so Android does not reveal 1 dp short of the end.
# 5. Layout noise against the floored known size (isNativeLayoutNoise, updateOneItemSize): a
# one-pixel snap change is not a resize.
# 6. Content height from React state on the New Architecture (useContentSize, ContainersLayer):
# the Animated height mounts the estimated layout for a frame.
# 7. Header and footer revealed with the rows (RevealedLayoutView, ListComponent).
# 8. Top-padding shrink hold on iOS only (setPaddingTop): Android paints the held height as blank
# space at the end.
# 9. Rows cut off at the top count as in view (getIdsInVisibleRange): they follow the fully
# visible rows in idsInView, so a disclosure hold on a row whose top is above the viewport
# still anchors on that row instead of the row below it.
# 10. A size change keeps only a row shouldRestorePosition accepts in place (prepareMVCP), as a
# data change already does, so a disclosure that only grows (a todo card) holds the cut-off
# row instead of the first row in view.
# 11. A 0 x 0 measurement is not a size (updateItemSizesBatch): rows of a hidden retained tab
# (display: none) still render and measure 0 x 0. Recording it collapsed the row, and a row
# whose frame was unchanged when the tab showed again got no layout event to correct it.
#
# Upgrading: drop each hunk whose fix the new version ships, regenerate with
# `npx patch-package @legendapp/list`, and copy this header back on top.
diff --git a/node_modules/@legendapp/list/react-native.d.ts b/node_modules/@legendapp/list/react-native.d.ts
index 983e937..970ba51 100644
--- a/node_modules/@legendapp/list/react-native.d.ts
+++ b/node_modules/@legendapp/list/react-native.d.ts
@@ -204,6 +204,11 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> {
* Change extraData when the fixed sizes returned by this callback change.
*/
getFixedItemSize?: (item: ItemT, index: number, type: TItemType) => number | undefined;
+ /**
+ * Paseo patch: estimated size of an item that has not been measured yet. Return undefined to
+ * fall back to the average size for its type.
+ */
+ getEstimatedItemSize?: (item: ItemT, index: number, type: TItemType) => number | undefined;
/**
* Returns a stable item type used for pooling and size estimation.
*/
diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js
index eeb3fb0..5fe940d 100644
--- a/node_modules/@legendapp/list/react-native.js
+++ b/node_modules/@legendapp/list/react-native.js
@@ -1497,7 +1497,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no
return renderedSize;
}
const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : "";
- if (useAverageSize && !scrollingTo) {
+ // Paseo patch: a caller estimate for an unmeasured item wins over the running average for its
+ // type, so a row new to the list can be laid out near its real size before it is measured.
+ if (state.props.getEstimatedItemSize) {
+ const estimate = state.props.getEstimatedItemSize(data, index, itemType);
+ if (estimate !== void 0) {
+ size = estimate + ctx.scrollAxisGap;
+ }
+ }
+ if (size === void 0 && useAverageSize && !scrollingTo) {
const averageSizeForType = (_c = averageSizes[itemType]) == null ? void 0 : _c.avg;
if (averageSizeForType !== void 0) {
size = roundSize(averageSizeForType);
@@ -1570,6 +1578,12 @@ var MVCP_POSITION_EPSILON = 0.1;
var MVCP_ANCHOR_LOCK_TTL_MS = 300;
var MVCP_ANCHOR_LOCK_QUIET_PASSES_TO_RELEASE = 2;
var NATIVE_END_CLAMP_EPSILON = 1;
+function canRestoreAnchorLock(state, id) {
+ const shouldRestorePosition = state.props.maintainVisibleContentPosition.shouldRestorePosition;
+ const index = state.indexByKey.get(id);
+ const data = state.props.data;
+ return !shouldRestorePosition || index !== void 0 && data[index] !== void 0 && shouldRestorePosition(data[index], index, data);
+}
function resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) {
if (!enableMVCPAnchorLock) {
state.mvcpAnchorLock = void 0;
@@ -1579,7 +1593,13 @@ function resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) {
if (!lock) {
return void 0;
}
- const isExpired = now > lock.expiresAt;
+ // Paseo patch: native keeps the anchor of a data change through the size passes that follow, as
+ // web does. Upstream limits the lock to web (d24a9d93 "feat: (web) add MVCP anchor lock") without a
+ // native reason. Without it, the pass that applies the measured sizes of prepended rows re-anchors
+ // on the first row in view, which can be one of those rows, and its growth moves the rows below.
+ // A wall-clock expiry is wrong on native, where the measurements arrive after the native mount
+ // and a slow render; the lock ends when its row leaves the view or may not be restored instead.
+ const isExpired = Platform.OS === "web" ? now > lock.expiresAt : !state.idsInView.includes(lock.id) || !canRestoreAnchorLock(state, lock.id);
const isMissing = state.indexByKey.get(lock.id) === void 0;
if (isExpired || isMissing || !mvcpData) {
state.mvcpAnchorLock = void 0;
@@ -1588,7 +1608,7 @@ function resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) {
return lock;
}
function updateAnchorLock(state, params) {
- if (Platform.OS === "web") {
+ {
const { anchorId, anchorPosition, dataChanged, now, positionDiff } = params;
const enableMVCPAnchorLock = !!dataChanged || !!state.mvcpAnchorLock;
const mvcpData = state.props.maintainVisibleContentPosition.data;
@@ -1713,7 +1733,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) {
} = props;
const isWeb = Platform.OS === "web";
const now = Date.now();
- const enableMVCPAnchorLock = isWeb && (!!dataChanged || !!state.mvcpAnchorLock);
+ const enableMVCPAnchorLock = !!dataChanged || !!state.mvcpAnchorLock;
const scrollingTo = state.scrollingTo;
if ((scrollingTo == null ? void 0 : scrollingTo.key) !== void 0) {
const nextIndex = state.indexByKey.get(scrollingTo.key);
@@ -1723,7 +1743,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) {
state.mvcpAnchorLock = void 0;
return void 0;
}
- const anchorLock = isWeb ? resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) : void 0;
+ const anchorLock = resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now);
let prevPosition;
let targetId;
const idsInViewWithPositions = [];
@@ -1755,7 +1775,10 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) {
}
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);
+ // Paseo patch: a size change keeps only a row shouldRestorePosition accepts in place, as a data
+ // change does. Upstream keeps the first row in view, so a disclosure hold on a row cut off at
+ // the top kept the row below it instead and moved the trigger by the row's size change.
+ targetId = idsInView.find((id) => indexByKey.get(id) !== void 0 && canRestoreAnchorLock(state, id));
}
if (dataChanged && idsInView.length > 0 && state.didContainersLayout) {
for (let i = 0; i < idsInView.length; i++) {
@@ -2371,7 +2394,10 @@ function finishScrollTo(ctx) {
};
if (scrollingTo.isInitialScroll || state.initialScroll) {
const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset";
- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1;
+ // Paseo patch: only a list that keeps its end keeps landing on the initial end after the
+ // initial scroll. Without maintainScrollAtEnd, the preserved target re-arms the initial scroll
+ // on every data change, footer layout and viewport resize, and a stream never lets it clear.
+ const shouldPreserveResizeTarget = !!state.props.maintainScrollAtEnd && !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1;
finishInitialScroll(ctx, {
onFinished: complete,
preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget,
@@ -2515,11 +2541,12 @@ function checkFinishedScrollFallback(ctx) {
const targetOffset = (_d = (_c = (_b = initialScrollWatchdog.get(state)) == null ? void 0 : _b.targetOffset) != null ? _c : isStillScrollingTo.targetOffset) != null ? _d : 0;
const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON;
initialScrollCompletion.markSilentInitialScrollRetry(state);
+ // Paseo patch: send the nudge and the return to the target in one task, so native applies both before
+ // it draws and the pair still emits the scroll event this retry exists for. Upstream returns on the next
+ // frame, but the 16 ms check below can run first, finish the scroll and cancel that frame; the list then
+ // revealed 1 dp short of its end and moved into place a frame later.
scrollToFallbackOffset(ctx, jiggleOffset);
- state.scheduledWork.frame(
- () => scrollToFallbackOffset(ctx, targetOffset),
- "checkFinishedScrollRetryFrame"
- );
+ scrollToFallbackOffset(ctx, targetOffset);
scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
} else if (shouldRetryUnalignedEndScroll) {
scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
@@ -3378,7 +3405,8 @@ function finishBootstrapInitialScrollWithoutScroll(ctx, resolvedOffset) {
var _a3;
const state = ctx.state;
clearBootstrapInitialScrollSession(state);
- const shouldPreserveResizeTarget = !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_a3 = state.initialScroll) == null ? void 0 : _a3.viewPosition) === 1;
+ // Paseo patch: see finishScrollTo; only a list that keeps its end preserves the initial end.
+ const shouldPreserveResizeTarget = !!state.props.maintainScrollAtEnd && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_a3 = state.initialScroll) == null ? void 0 : _a3.viewPosition) === 1;
finishInitialScroll(ctx, {
preserveTarget: shouldPreserveResizeTarget,
recalculateItems: true,
@@ -4569,6 +4597,17 @@ function getIdsInVisibleRange(state, range) {
const id = (_b = state.idCache[i]) != null ? _b : getId(state, i);
idsInView.push(id);
}
+ // Paseo patch: a row whose top is above the viewport is still in view. Upstream starts the ids at
+ // the first fully visible row, so a disclosure hold on a row cut off at the top finds no anchor
+ // (shouldRestorePosition rejects every other row) and the next size pass keeps the row below it
+ // in place instead. The partly visible rows go last, so the first fully visible row stays the
+ // anchor of every pass that does not restrict it.
+ if (range.startNoBuffer !== null && range.startNoBuffer < firstVisibleAnchorIndex) {
+ for (let i = range.startNoBuffer; i < firstVisibleAnchorIndex; i++) {
+ const id = (_b = state.idCache[i]) != null ? _b : getId(state, i);
+ idsInView.push(id);
+ }
+ }
}
return idsInView;
}
@@ -5176,8 +5215,13 @@ var NATIVE_LAYOUT_MEASUREMENT_EPSILON = 1 / PixelRatio.get() + FLOATING_POINT_SL
function isWithinEpsilon(delta) {
return Math.abs(delta) <= NATIVE_LAYOUT_MEASUREMENT_EPSILON;
}
-function isNativeLayoutNoise(delta) {
- return isWithinEpsilon(delta);
+// Paseo patch: sizesKnown holds roundSize values, floored to 1/8 dp, so the raw size behind a known
+// size lies in [known, known + 1/8). Upstream compares the raw size with the floored one, so a row
+// whose pixel-snapped height changes by one pixel, plus the floor, reads as a resize. On a cold open
+// that late resize moved every row a frame after the reveal.
+function isNativeLayoutNoise(rawSize, knownSize) {
+ const delta = rawSize - knownSize;
+ return delta >= -NATIVE_LAYOUT_MEASUREMENT_EPSILON && delta < NATIVE_LAYOUT_MEASUREMENT_EPSILON + 1 / 8;
}
// src/core/updateItemSizes.ts
@@ -5291,6 +5335,11 @@ function updateItemSizesBatch(ctx, measurements) {
const result = {};
const previousTotalSize = state.totalSize;
for (const measurement of measurements) {
+ // Paseo patch: a view inside a display: none ancestor measures 0 x 0. That is no measurement: a
+ // laid-out row is as wide as the list even when it has no height. Recording 0 there collapsed
+ // the row, and when the list showed again a row whose frame had not changed got no layout event
+ // to correct it, so the next row overlapped it.
+ if (measurement.size.width === 0 && measurement.size.height === 0) continue;
const ownsMeasuredItem = measurement.containerId === void 0 || isContainerItemCurrent(ctx, measurement.containerId, measurement.itemKey);
if (ownsMeasuredItem) {
const index = state.indexByKey.get(measurement.itemKey);
@@ -5414,7 +5463,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
state.fixedItemSizeKeys.add(itemKey);
}
const prevSizeKnown = sizesKnown.get(itemKey);
- if (Platform.OS !== "web" && prevSizeKnown !== void 0 && isNativeLayoutNoise(rawSize - prevSizeKnown)) {
+ if (Platform.OS !== "web" && prevSizeKnown !== void 0 && isNativeLayoutNoise(rawSize, prevSizeKnown)) {
return 0;
}
const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize);
@@ -6283,6 +6332,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
// src/components/Containers.native.tsx
var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0;
+// Paseo patch: on the new architecture the content height comes from React state only. The
+// JS-driven Animated height writes through setNativeProps, which Fabric commits synchronously
+// mid-task, mounting React's estimated layout for a frame before the measured one.
+var useContentSize = IsNewArchitecture ? useCommittedMinSize : () => useValue$("totalSize");
var ContainersLayer = typedMemo(function ContainersLayer2({
children,
freshDataTransitionEpoch,
@@ -6290,7 +6343,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
}) {
const ctx = useStateContext();
const columnWrapperStyle = ctx.columnWrapperStyle;
- const size = useValue$("totalSize");
+ const size = useContentSize();
const minSize = useCommittedMinSize();
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
@@ -6513,6 +6566,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
null
);
});
+// Paseo patch: the header and footer reveal with the rows. Upstream hides only the containers until
+// readyToRender, so a cold open paints the header and footer at their estimated place a frame or more
+// before the rows appear around them.
+var RevealedLayoutView = function RevealedLayoutView2({ freshDataTransitionEpoch, style, ...rest }) {
+ const [readyToRender] = useArr$(["readyToRender"]);
+ const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
+ return /* @__PURE__ */ React2__namespace.createElement(LayoutView, { ...rest, pointerEvents: isVisible ? "auto" : "none", style: isVisible ? style : [style, { opacity: 0 }] });
+};
var ListComponent = typedMemo(function ListComponent2({
canRender,
freshDataTransitionEpoch,
@@ -6651,7 +6712,7 @@ var ListComponent = typedMemo(function ListComponent2({
style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style
},
/* @__PURE__ */ React2__namespace.createElement(ScrollAdjust, null),
- ListHeaderComponent && /* @__PURE__ */ React2__namespace.createElement(LayoutView, { onLayoutChange: onLayoutHeader, style: ListHeaderComponentStyle }, getComponent(ListHeaderComponent)),
+ ListHeaderComponent && /* @__PURE__ */ React2__namespace.createElement(RevealedLayoutView, { freshDataTransitionEpoch, onLayoutChange: onLayoutHeader, style: ListHeaderComponentStyle }, getComponent(ListHeaderComponent)),
ListEmptyComponent && getComponent(ListEmptyComponent),
shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2__namespace.createElement(AlignItemsAtEndSpacer, { horizontal }),
canRender && !ListEmptyComponent && /* @__PURE__ */ React2__namespace.createElement(
@@ -6665,7 +6726,7 @@ var ListComponent = typedMemo(function ListComponent2({
stickyHeaderConfig
}
),
- ListFooterComponent && /* @__PURE__ */ React2__namespace.createElement(LayoutView, { onLayoutChange: onLayoutFooterInternal, style: ListFooterComponentStyle }, getComponent(ListFooterComponent)),
+ ListFooterComponent && /* @__PURE__ */ React2__namespace.createElement(RevealedLayoutView, { freshDataTransitionEpoch, onLayoutChange: onLayoutFooterInternal, style: ListFooterComponentStyle }, getComponent(ListFooterComponent)),
IS_DEV && ENABLE_DEVMODE
);
});
@@ -7886,7 +7947,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
const state = ctx.state;
if (stylePaddingTop !== void 0) {
const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0;
- if (stylePaddingTop < prevStylePaddingTop) {
+ // Paseo patch: only iOS compensates a shrinking top padding with a scroll adjust, which this
+ // keeps the native clamp from doubling. Elsewhere the held height paints as blank space at the
+ // end until the timer fires, and a scroll view that keeps its end scrolls into it.
+ if (stylePaddingTop < prevStylePaddingTop && Platform.OS === "ios") {
let prevTotalSize = peek$(ctx, "totalSize") || 0;
set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop);
state.timeoutSetPaddingTop = setTimeout(() => {
@@ -7989,6 +8053,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
estimatedListSize,
extraData,
getFixedItemSize,
+ getEstimatedItemSize,
getItemType,
horizontal,
rtl,
@@ -8282,6 +8347,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
dataVersion,
drawDistance,
estimatedItemSize,
+ getEstimatedItemSize: useWrapIfItem(getEstimatedItemSize),
getFixedItemSize: useWrapIfItem(getFixedItemSize),
getItemType: useWrapIfItem(getItemType),
hasExternalScroll,
diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs
index fe5f415..7f1b9dd 100644
--- a/node_modules/@legendapp/list/react-native.mjs
+++ b/node_modules/@legendapp/list/react-native.mjs
@@ -1476,7 +1476,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no
return renderedSize;
}
const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : "";
- if (useAverageSize && !scrollingTo) {
+ // Paseo patch: a caller estimate for an unmeasured item wins over the running average for its
+ // type, so a row new to the list can be laid out near its real size before it is measured.
+ if (state.props.getEstimatedItemSize) {
+ const estimate = state.props.getEstimatedItemSize(data, index, itemType);
+ if (estimate !== void 0) {
+ size = estimate + ctx.scrollAxisGap;
+ }
+ }
+ if (size === void 0 && useAverageSize && !scrollingTo) {
const averageSizeForType = (_c = averageSizes[itemType]) == null ? void 0 : _c.avg;
if (averageSizeForType !== void 0) {
size = roundSize(averageSizeForType);
@@ -1549,6 +1557,12 @@ var MVCP_POSITION_EPSILON = 0.1;
var MVCP_ANCHOR_LOCK_TTL_MS = 300;
var MVCP_ANCHOR_LOCK_QUIET_PASSES_TO_RELEASE = 2;
var NATIVE_END_CLAMP_EPSILON = 1;
+function canRestoreAnchorLock(state, id) {
+ const shouldRestorePosition = state.props.maintainVisibleContentPosition.shouldRestorePosition;
+ const index = state.indexByKey.get(id);
+ const data = state.props.data;
+ return !shouldRestorePosition || index !== void 0 && data[index] !== void 0 && shouldRestorePosition(data[index], index, data);
+}
function resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) {
if (!enableMVCPAnchorLock) {
state.mvcpAnchorLock = void 0;
@@ -1558,7 +1572,13 @@ function resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) {
if (!lock) {
return void 0;
}
- const isExpired = now > lock.expiresAt;
+ // Paseo patch: native keeps the anchor of a data change through the size passes that follow, as
+ // web does. Upstream limits the lock to web (d24a9d93 "feat: (web) add MVCP anchor lock") without a
+ // native reason. Without it, the pass that applies the measured sizes of prepended rows re-anchors
+ // on the first row in view, which can be one of those rows, and its growth moves the rows below.
+ // A wall-clock expiry is wrong on native, where the measurements arrive after the native mount
+ // and a slow render; the lock ends when its row leaves the view or may not be restored instead.
+ const isExpired = Platform.OS === "web" ? now > lock.expiresAt : !state.idsInView.includes(lock.id) || !canRestoreAnchorLock(state, lock.id);
const isMissing = state.indexByKey.get(lock.id) === void 0;
if (isExpired || isMissing || !mvcpData) {
state.mvcpAnchorLock = void 0;
@@ -1567,7 +1587,7 @@ function resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) {
return lock;
}
function updateAnchorLock(state, params) {
- if (Platform.OS === "web") {
+ {
const { anchorId, anchorPosition, dataChanged, now, positionDiff } = params;
const enableMVCPAnchorLock = !!dataChanged || !!state.mvcpAnchorLock;
const mvcpData = state.props.maintainVisibleContentPosition.data;
@@ -1692,7 +1712,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) {
} = props;
const isWeb = Platform.OS === "web";
const now = Date.now();
- const enableMVCPAnchorLock = isWeb && (!!dataChanged || !!state.mvcpAnchorLock);
+ const enableMVCPAnchorLock = !!dataChanged || !!state.mvcpAnchorLock;
const scrollingTo = state.scrollingTo;
if ((scrollingTo == null ? void 0 : scrollingTo.key) !== void 0) {
const nextIndex = state.indexByKey.get(scrollingTo.key);
@@ -1702,7 +1722,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) {
state.mvcpAnchorLock = void 0;
return void 0;
}
- const anchorLock = isWeb ? resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) : void 0;
+ const anchorLock = resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now);
let prevPosition;
let targetId;
const idsInViewWithPositions = [];
@@ -1734,7 +1754,10 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) {
}
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);
+ // Paseo patch: a size change keeps only a row shouldRestorePosition accepts in place, as a data
+ // change does. Upstream keeps the first row in view, so a disclosure hold on a row cut off at
+ // the top kept the row below it instead and moved the trigger by the row's size change.
+ targetId = idsInView.find((id) => indexByKey.get(id) !== void 0 && canRestoreAnchorLock(state, id));
}
if (dataChanged && idsInView.length > 0 && state.didContainersLayout) {
for (let i = 0; i < idsInView.length; i++) {
@@ -2350,7 +2373,10 @@ function finishScrollTo(ctx) {
};
if (scrollingTo.isInitialScroll || state.initialScroll) {
const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset";
- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1;
+ // Paseo patch: only a list that keeps its end keeps landing on the initial end after the
+ // initial scroll. Without maintainScrollAtEnd, the preserved target re-arms the initial scroll
+ // on every data change, footer layout and viewport resize, and a stream never lets it clear.
+ const shouldPreserveResizeTarget = !!state.props.maintainScrollAtEnd && !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1;
finishInitialScroll(ctx, {
onFinished: complete,
preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget,
@@ -2494,11 +2520,12 @@ function checkFinishedScrollFallback(ctx) {
const targetOffset = (_d = (_c = (_b = initialScrollWatchdog.get(state)) == null ? void 0 : _b.targetOffset) != null ? _c : isStillScrollingTo.targetOffset) != null ? _d : 0;
const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON;
initialScrollCompletion.markSilentInitialScrollRetry(state);
+ // Paseo patch: send the nudge and the return to the target in one task, so native applies both before
+ // it draws and the pair still emits the scroll event this retry exists for. Upstream returns on the next
+ // frame, but the 16 ms check below can run first, finish the scroll and cancel that frame; the list then
+ // revealed 1 dp short of its end and moved into place a frame later.
scrollToFallbackOffset(ctx, jiggleOffset);
- state.scheduledWork.frame(
- () => scrollToFallbackOffset(ctx, targetOffset),
- "checkFinishedScrollRetryFrame"
- );
+ scrollToFallbackOffset(ctx, targetOffset);
scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS);
} else if (shouldRetryUnalignedEndScroll) {
scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated);
@@ -3357,7 +3384,8 @@ function finishBootstrapInitialScrollWithoutScroll(ctx, resolvedOffset) {
var _a3;
const state = ctx.state;
clearBootstrapInitialScrollSession(state);
- const shouldPreserveResizeTarget = !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_a3 = state.initialScroll) == null ? void 0 : _a3.viewPosition) === 1;
+ // Paseo patch: see finishScrollTo; only a list that keeps its end preserves the initial end.
+ const shouldPreserveResizeTarget = !!state.props.maintainScrollAtEnd && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_a3 = state.initialScroll) == null ? void 0 : _a3.viewPosition) === 1;
finishInitialScroll(ctx, {
preserveTarget: shouldPreserveResizeTarget,
recalculateItems: true,
@@ -4548,6 +4576,17 @@ function getIdsInVisibleRange(state, range) {
const id = (_b = state.idCache[i]) != null ? _b : getId(state, i);
idsInView.push(id);
}
+ // Paseo patch: a row whose top is above the viewport is still in view. Upstream starts the ids at
+ // the first fully visible row, so a disclosure hold on a row cut off at the top finds no anchor
+ // (shouldRestorePosition rejects every other row) and the next size pass keeps the row below it
+ // in place instead. The partly visible rows go last, so the first fully visible row stays the
+ // anchor of every pass that does not restrict it.
+ if (range.startNoBuffer !== null && range.startNoBuffer < firstVisibleAnchorIndex) {
+ for (let i = range.startNoBuffer; i < firstVisibleAnchorIndex; i++) {
+ const id = (_b = state.idCache[i]) != null ? _b : getId(state, i);
+ idsInView.push(id);
+ }
+ }
}
return idsInView;
}
@@ -5155,8 +5194,13 @@ var NATIVE_LAYOUT_MEASUREMENT_EPSILON = 1 / PixelRatio.get() + FLOATING_POINT_SL
function isWithinEpsilon(delta) {
return Math.abs(delta) <= NATIVE_LAYOUT_MEASUREMENT_EPSILON;
}
-function isNativeLayoutNoise(delta) {
- return isWithinEpsilon(delta);
+// Paseo patch: sizesKnown holds roundSize values, floored to 1/8 dp, so the raw size behind a known
+// size lies in [known, known + 1/8). Upstream compares the raw size with the floored one, so a row
+// whose pixel-snapped height changes by one pixel, plus the floor, reads as a resize. On a cold open
+// that late resize moved every row a frame after the reveal.
+function isNativeLayoutNoise(rawSize, knownSize) {
+ const delta = rawSize - knownSize;
+ return delta >= -NATIVE_LAYOUT_MEASUREMENT_EPSILON && delta < NATIVE_LAYOUT_MEASUREMENT_EPSILON + 1 / 8;
}
// src/core/updateItemSizes.ts
@@ -5270,6 +5314,11 @@ function updateItemSizesBatch(ctx, measurements) {
const result = {};
const previousTotalSize = state.totalSize;
for (const measurement of measurements) {
+ // Paseo patch: a view inside a display: none ancestor measures 0 x 0. That is no measurement: a
+ // laid-out row is as wide as the list even when it has no height. Recording 0 there collapsed
+ // the row, and when the list showed again a row whose frame had not changed got no layout event
+ // to correct it, so the next row overlapped it.
+ if (measurement.size.width === 0 && measurement.size.height === 0) continue;
const ownsMeasuredItem = measurement.containerId === void 0 || isContainerItemCurrent(ctx, measurement.containerId, measurement.itemKey);
if (ownsMeasuredItem) {
const index = state.indexByKey.get(measurement.itemKey);
@@ -5393,7 +5442,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif
state.fixedItemSizeKeys.add(itemKey);
}
const prevSizeKnown = sizesKnown.get(itemKey);
- if (Platform.OS !== "web" && prevSizeKnown !== void 0 && isNativeLayoutNoise(rawSize - prevSizeKnown)) {
+ if (Platform.OS !== "web" && prevSizeKnown !== void 0 && isNativeLayoutNoise(rawSize, prevSizeKnown)) {
return 0;
}
const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize);
@@ -6262,6 +6311,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) {
// src/components/Containers.native.tsx
var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0;
+// Paseo patch: on the new architecture the content height comes from React state only. The
+// JS-driven Animated height writes through setNativeProps, which Fabric commits synchronously
+// mid-task, mounting React's estimated layout for a frame before the measured one.
+var useContentSize = IsNewArchitecture ? useCommittedMinSize : () => useValue$("totalSize");
var ContainersLayer = typedMemo(function ContainersLayer2({
children,
freshDataTransitionEpoch,
@@ -6269,7 +6322,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({
}) {
const ctx = useStateContext();
const columnWrapperStyle = ctx.columnWrapperStyle;
- const size = useValue$("totalSize");
+ const size = useContentSize();
const minSize = useCommittedMinSize();
const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]);
const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
@@ -6492,6 +6545,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont
null
);
});
+// Paseo patch: the header and footer reveal with the rows. Upstream hides only the containers until
+// readyToRender, so a cold open paints the header and footer at their estimated place a frame or more
+// before the rows appear around them.
+var RevealedLayoutView = function RevealedLayoutView2({ freshDataTransitionEpoch, style, ...rest }) {
+ const [readyToRender] = useArr$(["readyToRender"]);
+ const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch);
+ return /* @__PURE__ */ React2.createElement(LayoutView, { ...rest, pointerEvents: isVisible ? "auto" : "none", style: isVisible ? style : [style, { opacity: 0 }] });
+};
var ListComponent = typedMemo(function ListComponent2({
canRender,
freshDataTransitionEpoch,
@@ -6630,7 +6691,7 @@ var ListComponent = typedMemo(function ListComponent2({
style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style
},
/* @__PURE__ */ React2.createElement(ScrollAdjust, null),
- ListHeaderComponent && /* @__PURE__ */ React2.createElement(LayoutView, { onLayoutChange: onLayoutHeader, style: ListHeaderComponentStyle }, getComponent(ListHeaderComponent)),
+ ListHeaderComponent && /* @__PURE__ */ React2.createElement(RevealedLayoutView, { freshDataTransitionEpoch, onLayoutChange: onLayoutHeader, style: ListHeaderComponentStyle }, getComponent(ListHeaderComponent)),
ListEmptyComponent && getComponent(ListEmptyComponent),
shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2.createElement(AlignItemsAtEndSpacer, { horizontal }),
canRender && !ListEmptyComponent && /* @__PURE__ */ React2.createElement(
@@ -6644,7 +6705,7 @@ var ListComponent = typedMemo(function ListComponent2({
stickyHeaderConfig
}
),
- ListFooterComponent && /* @__PURE__ */ React2.createElement(LayoutView, { onLayoutChange: onLayoutFooterInternal, style: ListFooterComponentStyle }, getComponent(ListFooterComponent)),
+ ListFooterComponent && /* @__PURE__ */ React2.createElement(RevealedLayoutView, { freshDataTransitionEpoch, onLayoutChange: onLayoutFooterInternal, style: ListFooterComponentStyle }, getComponent(ListFooterComponent)),
IS_DEV && ENABLE_DEVMODE
);
});
@@ -7865,7 +7926,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) {
const state = ctx.state;
if (stylePaddingTop !== void 0) {
const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0;
- if (stylePaddingTop < prevStylePaddingTop) {
+ // Paseo patch: only iOS compensates a shrinking top padding with a scroll adjust, which this
+ // keeps the native clamp from doubling. Elsewhere the held height paints as blank space at the
+ // end until the timer fires, and a scroll view that keeps its end scrolls into it.
+ if (stylePaddingTop < prevStylePaddingTop && Platform.OS === "ios") {
let prevTotalSize = peek$(ctx, "totalSize") || 0;
set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop);
state.timeoutSetPaddingTop = setTimeout(() => {
@@ -7968,6 +8032,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
estimatedListSize,
extraData,
getFixedItemSize,
+ getEstimatedItemSize,
getItemType,
horizontal,
rtl,
@@ -8261,6 +8326,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded
dataVersion,
drawDistance,
estimatedItemSize,
+ getEstimatedItemSize: useWrapIfItem(getEstimatedItemSize),
getFixedItemSize: useWrapIfItem(getFixedItemSize),
getItemType: useWrapIfItem(getItemType),
hasExternalScroll,