@@ -724,6 +724,7 @@ const ListUI = (props) => {
724724 renderWindow,
725725 scrollToItem,
726726 pendingScrollRef,
727+ captureAnchor,
727728 } = useListScrollSync ( {
728729 ref,
729730 tracker,
@@ -738,6 +739,7 @@ const ListUI = (props) => {
738739 horizontal,
739740 } ) ;
740741
742+ virtual . captureAnchor = captureAnchor ;
741743 virtual . virtualItemSizeSignal = virtualItemSizeSignal ;
742744 virtual . horizontal = Boolean ( horizontal ) ;
743745 virtual . renderSkeleton = renderSkeleton ;
@@ -1103,6 +1105,34 @@ const useListScrollSync = ({
11031105 const getScroller = ( ) => getScrollerEl ( ref . current , scroller , horizontal ) ;
11041106 const getListEl = ( ) => ref . current . querySelector ( ".navi_list" ) ;
11051107
1108+ // The row the scroll holds onto across a change of geometry, and where it
1109+ // sat when that change was decided. Captured at the two moments the list
1110+ // knows its own geometry is about to change — the window moving, a page
1111+ // arriving — because at those moments the DOM still shows the state to
1112+ // preserve. Capturing on every render instead would mean capturing at
1113+ // moments where there is nothing to preserve, and missing the ones where
1114+ // there is.
1115+ const anchorRef = useRef ( null ) ;
1116+ const captureAnchor = ( ) => {
1117+ if ( anchorRef . current || ! ref . current || pendingScrollRef . current ) {
1118+ return ;
1119+ }
1120+ if (
1121+ ! startPlaceRef . current . userTookOver &&
1122+ scrolledWanted !== "start" &&
1123+ scrolledWanted !== undefined
1124+ ) {
1125+ // The list is holding itself somewhere; that is what owns the scroll.
1126+ return ;
1127+ }
1128+ anchorRef . current = captureScrollAnchor ( {
1129+ scrollerEl : getScroller ( ) ,
1130+ listEl : getListEl ( ) ,
1131+ items : tracker . visibleItemsSignal . peek ( ) ,
1132+ horizontal,
1133+ } ) ;
1134+ } ;
1135+
11061136 const [ renderWindow , setRenderWindow ] = useState ( ( ) => {
11071137 // Opening somewhere else than the beginning starts by framing there: the
11081138 // rows the list will draw are the rows it will ask for.
@@ -1119,6 +1149,7 @@ const useListScrollSync = ({
11191149 if ( newStart === start && newEnd === end ) {
11201150 return ;
11211151 }
1152+ captureAnchor ( ) ;
11221153 debugScroll ( `updateRenderWindow(${ newStart } , ${ newEnd } , "${ reason } ")` ) ;
11231154 const renderWindow = { start : newStart , end : newEnd } ;
11241155 renderWindowRef . current = renderWindow ;
@@ -1275,11 +1306,6 @@ const useListScrollSync = ({
12751306 // eslint-disable-next-line no-unused-expressions
12761307 virtualItemSizeSignal . value ;
12771308 }
1278- // The row the scroll is held onto across a commit (see the anchoring below).
1279- // A deliberate move — opening the list somewhere, coming back to a row — is
1280- // the list saying where it wants to be: whatever it was holding onto before
1281- // no longer applies, or the correction would undo the move.
1282- const anchorRef = useRef ( null ) ;
12831309 // Set around a scroll the list performs itself. What it protects against is
12841310 // not the scroll event as such, but what the listener would conclude from it:
12851311 // the position it is about to read was chosen to keep the rows where they
@@ -1537,17 +1563,11 @@ const useListScrollSync = ({
15371563 const scrollerEl = getScroller ( ) ;
15381564 if ( openAt === "end" ) {
15391565 anchorRef . current = null ;
1540- console . info (
1541- `[place] before write: scrollHeight=${ Math . round ( scrollerEl . scrollHeight ) } client=${ Math . round ( scrollerEl . clientHeight ) } top=${ Math . round ( scrollerEl . scrollTop ) } ` ,
1542- ) ;
15431566 if ( horizontal ) {
15441567 scrollerEl . scrollLeft = scrollerEl . scrollWidth ;
15451568 } else {
15461569 scrollerEl . scrollTop = scrollerEl . scrollHeight ;
15471570 }
1548- console . info (
1549- `[place] after write: top=${ Math . round ( scrollerEl . scrollTop ) } max=${ Math . round ( scrollerEl . scrollHeight - scrollerEl . clientHeight ) } ` ,
1550- ) ;
15511571 return ;
15521572 }
15531573 if ( typeof openAt === "object" && openAt . id !== undefined ) {
@@ -1658,12 +1678,20 @@ const useListScrollSync = ({
16581678 return undefined ;
16591679 }
16601680 const scrollerEl = getScroller ( ) ;
1661- const observer = new ResizeObserver ( ( ) => {
1662- // Held somewhere: its own height changing is the end of the list moving,
1663- // so it aims at it again. Nothing else can tell it — the size of a row is
1664- // measured here but read by the runs, so a size that settles re-renders
1665- // THEM, not this.
1666- if ( onGeometryChangeRef . current ( ) ) {
1681+ const listEl = getListEl ( ) ;
1682+ const observer = new ResizeObserver ( ( entries ) => {
1683+ // Two things resize here, and they call for opposite answers. The LIST
1684+ // growing is its own content settling: only a list holding itself
1685+ // somewhere cares (the end it aims at has moved), and a list the user is
1686+ // reading must not be touched — its rows are held still by the anchoring,
1687+ // which this would undo. The SCROLLER resizing is the window around it
1688+ // changing shape, and then the row that was at the top goes back where it
1689+ // was.
1690+ const listResized = entries . some ( ( entry ) => entry . target === listEl ) ;
1691+ if ( listResized && onGeometryChangeRef . current ( ) ) {
1692+ return ;
1693+ }
1694+ if ( ! entries . some ( ( entry ) => entry . target === scrollerEl ) ) {
16671695 return ;
16681696 }
16691697 const position = positionRef . current ;
@@ -1697,7 +1725,7 @@ const useListScrollSync = ({
16971725 }
16981726 } ) ;
16991727 observer . observe ( scrollerEl ) ;
1700- observer . observe ( getListEl ( ) ) ;
1728+ observer . observe ( listEl ) ;
17011729 return ( ) => {
17021730 observer . disconnect ( ) ;
17031731 } ;
@@ -1708,20 +1736,6 @@ const useListScrollSync = ({
17081736 // on changes it attributes to a scroll, and the fillers resize in the very
17091737 // same commit — so the row at the top of the viewport is measured before the
17101738 // commit and put back at the same offset after it.
1711- if (
1712- ! heldSomewhere &&
1713- ! searchText &&
1714- ! anchorRef . current &&
1715- ! pendingScrollRef . current &&
1716- ref . current
1717- ) {
1718- anchorRef . current = captureScrollAnchor ( {
1719- scrollerEl : getScroller ( ) ,
1720- listEl : getListEl ( ) ,
1721- items : tracker . visibleItemsSignal . peek ( ) ,
1722- horizontal,
1723- } ) ;
1724- }
17251739 useLayoutEffect ( ( ) => {
17261740 const anchor = anchorRef . current ;
17271741 if ( ! anchor || ! ref . current || heldSomewhere ) {
@@ -1767,9 +1781,9 @@ const useListScrollSync = ({
17671781 return ;
17681782 }
17691783 }
1770- anchorRef . current = null ;
17711784 const anchorEl = findRowElement ( getListEl ( ) , anchor . id ) ;
17721785 if ( ! anchorEl ) {
1786+ anchorRef . current = null ;
17731787 return ;
17741788 }
17751789 const scrollerEl = getScroller ( ) ;
@@ -1780,11 +1794,14 @@ const useListScrollSync = ({
17801794 : anchorRect . top - viewportRect . top ;
17811795 const drift = offsetNow - anchor . offset ;
17821796 if ( drift === 0 ) {
1797+ // Nothing moved in this commit — which does not mean nothing will: what
1798+ // was captured is a change that has not landed yet (a page merged into a
1799+ // run re-renders the run, and this list a commit later). The anchor is
1800+ // kept until it has something to correct, and dropped the moment the
1801+ // user scrolls, which is the one thing that makes it stale.
17831802 return ;
17841803 }
1785- debugScroll (
1786- `anchored row ${ anchor . id } drifted by ${ Math . round ( drift ) } px, compensating scroll` ,
1787- ) ;
1804+ anchorRef . current = null ;
17881805 scrolledByListRef . current = true ;
17891806 if ( horizontal ) {
17901807 scrollerEl . scrollLeft += drift ;
@@ -1803,6 +1820,8 @@ const useListScrollSync = ({
18031820 const listEl = getListEl ( ) ;
18041821 const onScroll = ( ) => {
18051822 updateCurrentScroll ( ) ;
1823+ // Where the user is now is where things must be held from now on.
1824+ anchorRef . current = null ;
18061825 if ( scrolledByListRef . current ) {
18071826 // The window stays where it is — the position it would be re-derived
18081827 // from was chosen to keep the rows still — but where the list is has
@@ -1872,6 +1891,7 @@ const useListScrollSync = ({
18721891 renderWindow : renderWindowRef . current ,
18731892 pendingScrollRef,
18741893 scrollToItem,
1894+ captureAnchor,
18751895 } ;
18761896} ;
18771897// The band of the scroller the user actually sees. A page-level scroller is
@@ -1979,12 +1999,26 @@ const captureScrollAnchor = ({ scrollerEl, listEl, items, horizontal }) => {
19791999// matter: the scroller may be larger than the list (scroller="parent") as well
19802000// as smaller (the list scrolls inside its own box).
19812001const getListVisibleScanRange = ( viewportRect , listRect , horizontal ) => {
2002+ // The screen has a say too: what is asked here is answered by
2003+ // elementFromPoint, which only knows about points that are actually on it. A
2004+ // list whose scroll box hangs below the fold of the page would otherwise be
2005+ // probed where nothing can be hit — and would silently stop keeping its rows
2006+ // still, which is exactly when it matters.
2007+ const screenTo = horizontal
2008+ ? document . documentElement . clientWidth
2009+ : document . documentElement . clientHeight ;
19822010 const viewportFrom = horizontal ? viewportRect . left : viewportRect . top ;
19832011 const viewportTo = horizontal ? viewportRect . right : viewportRect . bottom ;
19842012 const listFrom = horizontal ? listRect . left : listRect . top ;
19852013 const listTo = horizontal ? listRect . right : listRect . bottom ;
1986- const from = listFrom > viewportFrom ? listFrom : viewportFrom ;
1987- const to = listTo < viewportTo ? listTo : viewportTo ;
2014+ let from = listFrom > viewportFrom ? listFrom : viewportFrom ;
2015+ let to = listTo < viewportTo ? listTo : viewportTo ;
2016+ if ( from < 0 ) {
2017+ from = 0 ;
2018+ }
2019+ if ( to > screenTo ) {
2020+ to = screenTo ;
2021+ }
19882022 if ( to - from < 2 ) {
19892023 return null ;
19902024 }
@@ -2167,8 +2201,14 @@ const useVirtualItemSizeSignal = (ref, virtualItemSizeProp = 0, horizontal) => {
21672201 samples . sum = 0 ;
21682202 samples . count = 0 ;
21692203 samples . fromSkeletons = false ;
2170- } else if ( ! samples . fromSkeletons && measure . fromSkeletons ) {
2171- return ;
2204+ } else if ( measure . fromSkeletons ) {
2205+ if ( ! samples . fromSkeletons || samples . count > 0 ) {
2206+ // Rows on their way are given the size this very estimate holds, so
2207+ // measuring them again says nothing — and would say it in a loop: the
2208+ // size sets their height, their height sets the size. They seed it
2209+ // once, when there is nothing else to go on, and never again.
2210+ return ;
2211+ }
21722212 }
21732213 samples . sum += measure . size * measure . rowCount ;
21742214 samples . count += measure . rowCount ;
@@ -2935,6 +2975,9 @@ const createListVirtual = () => {
29352975 // The list is on its way somewhere: what the window frames is not what it
29362976 // is about to frame, so a run must not fetch for it (see holdWindow).
29372977 holdPending : false ,
2978+ // Called by a run just before rows land in it: what is on screen must not
2979+ // move because something arrived above it. Set by the list itself.
2980+ captureAnchor : ( ) => { } ,
29382981 horizontal : false ,
29392982 virtualItemSizeSignal : null ,
29402983 renderSkeleton : undefined ,
@@ -3515,9 +3558,6 @@ const useItemStore = ({ count, itemsAction, memoryBudget }) => {
35153558 if ( stillWanted ) {
35163559 return ;
35173560 }
3518- console . info (
3519- `[abort] request ${ request . start } ..${ request . end } window ${ windowFrom } ..${ windowTo } ` ,
3520- ) ;
35213561 request . controller ?. abort ( ) ;
35223562 request . busy = false ;
35233563 }
@@ -3563,6 +3603,9 @@ const useItemStore = ({ count, itemsAction, memoryBudget }) => {
35633603 const pageCount = Array . isArray ( page )
35643604 ? pageItems . length
35653605 : ( page . count ?? pageStart + pageItems . length ) ;
3606+ // Before the rows land: what is on screen has to stay where it is,
3607+ // and the DOM still shows the state to hold onto.
3608+ virtual . captureAnchor ( ) ;
35663609 let i = 0 ;
35673610 while ( i < pageItems . length ) {
35683611 pages . byIndex . set ( pageStart + i , pageItems [ i ] ) ;
0 commit comments