diff --git a/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx b/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx index cc52429b23..a16763f467 100644 --- a/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx +++ b/packages/app/src/agent-stream/legend-list-held-row.browser.test.tsx @@ -129,7 +129,8 @@ function mountList(heldRowId: string) { } return position - state.scroll; }; - return { render, scrollTo, heldRowTop }; + const rowSize = (rowId: string) => listRef.current?.getState().sizes.get(rowId); + return { render, scrollTo, heldRowTop, rowSize }; } it("anchors a data change on a held row whose top is above the viewport", async () => { @@ -169,3 +170,21 @@ it("anchors a size change on a held row whose top is above the viewport", async expect(list.heldRowTop()).toBe(-20); }); + +it("keeps measured row sizes while the list is not laid out", async () => { + // A hidden retained tab is display: none. Its rows still render, and a row measured there + // reports 0 x 0. When the tab shows again, a row whose frame did not change gets no new layout + // event, so a 0 recorded while hidden would stay and the next row would overlap it. + const rows: Row[] = Array.from({ length: 3 }, (_, index) => ({ id: `row-${index}`, height: 40 })); + const list = mountList("row-0"); + list.render(rows); + await wait(150); + expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]); + + host.style.display = "none"; + await wait(150); + list.render(rows.map((row) => ({ ...row }))); + await wait(150); + + expect(rows.map((row) => list.rowSize(row.id))).toEqual([40, 40, 40]); +}); diff --git a/patches/@legendapp+list+3.7.1.patch b/patches/@legendapp+list+3.7.1.patch index 3a59c09550..b232494bf5 100644 --- a/patches/@legendapp+list+3.7.1.patch +++ b/patches/@legendapp+list+3.7.1.patch @@ -25,6 +25,9 @@ # 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. @@ -45,7 +48,7 @@ index 983e937..970ba51 100644 * 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..b1689ad 100644 +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 @@ -205,7 +208,19 @@ index eeb3fb0..b1689ad 100644 } // src/core/updateItemSizes.ts -@@ -5414,7 +5458,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif +@@ -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); @@ -214,7 +229,7 @@ index eeb3fb0..b1689ad 100644 return 0; } const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize); -@@ -6283,6 +6327,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { +@@ -6283,6 +6332,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { // src/components/Containers.native.tsx var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0; @@ -225,7 +240,7 @@ index eeb3fb0..b1689ad 100644 var ContainersLayer = typedMemo(function ContainersLayer2({ children, freshDataTransitionEpoch, -@@ -6290,7 +6338,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -6290,7 +6343,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ }) { const ctx = useStateContext(); const columnWrapperStyle = ctx.columnWrapperStyle; @@ -234,7 +249,7 @@ index eeb3fb0..b1689ad 100644 const minSize = useCommittedMinSize(); const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); -@@ -6513,6 +6561,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont +@@ -6513,6 +6566,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont null ); }); @@ -249,7 +264,7 @@ index eeb3fb0..b1689ad 100644 var ListComponent = typedMemo(function ListComponent2({ canRender, freshDataTransitionEpoch, -@@ -6651,7 +6707,7 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -6651,7 +6712,7 @@ var ListComponent = typedMemo(function ListComponent2({ style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style }, /* @__PURE__ */ React2__namespace.createElement(ScrollAdjust, null), @@ -258,7 +273,7 @@ index eeb3fb0..b1689ad 100644 ListEmptyComponent && getComponent(ListEmptyComponent), shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2__namespace.createElement(AlignItemsAtEndSpacer, { horizontal }), canRender && !ListEmptyComponent && /* @__PURE__ */ React2__namespace.createElement( -@@ -6665,7 +6721,7 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -6665,7 +6726,7 @@ var ListComponent = typedMemo(function ListComponent2({ stickyHeaderConfig } ), @@ -267,7 +282,7 @@ index eeb3fb0..b1689ad 100644 IS_DEV && ENABLE_DEVMODE ); }); -@@ -7886,7 +7942,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) { +@@ -7886,7 +7947,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) { const state = ctx.state; if (stylePaddingTop !== void 0) { const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0; @@ -279,7 +294,7 @@ index eeb3fb0..b1689ad 100644 let prevTotalSize = peek$(ctx, "totalSize") || 0; set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop); state.timeoutSetPaddingTop = setTimeout(() => { -@@ -7989,6 +8048,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7989,6 +8053,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded estimatedListSize, extraData, getFixedItemSize, @@ -287,7 +302,7 @@ index eeb3fb0..b1689ad 100644 getItemType, horizontal, rtl, -@@ -8282,6 +8342,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -8282,6 +8347,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance, estimatedItemSize, @@ -296,7 +311,7 @@ index eeb3fb0..b1689ad 100644 getItemType: useWrapIfItem(getItemType), hasExternalScroll, diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs -index fe5f415..e61945e 100644 +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 @@ -456,7 +471,19 @@ index fe5f415..e61945e 100644 } // src/core/updateItemSizes.ts -@@ -5393,7 +5437,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif +@@ -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); @@ -465,7 +492,7 @@ index fe5f415..e61945e 100644 return 0; } const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize); -@@ -6262,6 +6306,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { +@@ -6262,6 +6311,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { // src/components/Containers.native.tsx var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0; @@ -476,7 +503,7 @@ index fe5f415..e61945e 100644 var ContainersLayer = typedMemo(function ContainersLayer2({ children, freshDataTransitionEpoch, -@@ -6269,7 +6317,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ +@@ -6269,7 +6322,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ }) { const ctx = useStateContext(); const columnWrapperStyle = ctx.columnWrapperStyle; @@ -485,7 +512,7 @@ index fe5f415..e61945e 100644 const minSize = useCommittedMinSize(); const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); -@@ -6492,6 +6540,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont +@@ -6492,6 +6545,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont null ); }); @@ -500,7 +527,7 @@ index fe5f415..e61945e 100644 var ListComponent = typedMemo(function ListComponent2({ canRender, freshDataTransitionEpoch, -@@ -6630,7 +6686,7 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -6630,7 +6691,7 @@ var ListComponent = typedMemo(function ListComponent2({ style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style }, /* @__PURE__ */ React2.createElement(ScrollAdjust, null), @@ -509,7 +536,7 @@ index fe5f415..e61945e 100644 ListEmptyComponent && getComponent(ListEmptyComponent), shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2.createElement(AlignItemsAtEndSpacer, { horizontal }), canRender && !ListEmptyComponent && /* @__PURE__ */ React2.createElement( -@@ -6644,7 +6700,7 @@ var ListComponent = typedMemo(function ListComponent2({ +@@ -6644,7 +6705,7 @@ var ListComponent = typedMemo(function ListComponent2({ stickyHeaderConfig } ), @@ -518,7 +545,7 @@ index fe5f415..e61945e 100644 IS_DEV && ENABLE_DEVMODE ); }); -@@ -7865,7 +7921,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) { +@@ -7865,7 +7926,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) { const state = ctx.state; if (stylePaddingTop !== void 0) { const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0; @@ -530,7 +557,7 @@ index fe5f415..e61945e 100644 let prevTotalSize = peek$(ctx, "totalSize") || 0; set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop); state.timeoutSetPaddingTop = setTimeout(() => { -@@ -7968,6 +8027,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7968,6 +8032,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded estimatedListSize, extraData, getFixedItemSize, @@ -538,7 +565,7 @@ index fe5f415..e61945e 100644 getItemType, horizontal, rtl, -@@ -8261,6 +8321,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -8261,6 +8326,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance, estimatedItemSize,