Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BUG] Simple header breaks restore state location #1081

Closed
phazei opened this issue May 4, 2024 · 3 comments
Closed

[BUG] Simple header breaks restore state location #1081

phazei opened this issue May 4, 2024 · 3 comments
Labels
bug Something isn't working

Comments

@phazei
Copy link
Contributor

phazei commented May 4, 2024

Describe the bug
If restoreStateFrom is used along with components Header, then the restoreStateFrom does not restore to the correct location

Reproduction
https://codesandbox.io/p/sandbox/sandpack-project-forked-x869rf?file=%2FApp.js%3A193%2C15

To Reproduce
Steps to reproduce the behavior:
Scroll up, click the "save" button. Click the "restore" button. See the location jump.

Expected behavior
Comment out line 194.

            components={{
                 //Header: Header,
            }}

Scroll up, click the "save" button. Click the "restore" button. See the location not jump.

@phazei phazei added the bug Something isn't working label May 4, 2024
@phazei
Copy link
Contributor Author

phazei commented May 9, 2024

I tried to debug this issue myself for a few hours, but honestly I just don't understand the urx streams at all, I can't even manage to figure out how to follow the flow.

In the stateLoadSystem, I found this which I think is the getState callback

    u.subscribe(
      u.pipe(getState, u.withLatestFrom(sizes, scrollTop, useWindowScroll, statefulWindowScrollContainerState, statefulWindowViewportRect)),
      ([callback, sizes, scrollTop, useWindowScroll, windowScrollContainerState, windowViewportRect]) => {
        const ranges = sizeTreeToRanges(sizes.sizeTree)
        if (useWindowScroll && windowScrollContainerState !== null && windowViewportRect !== null) {
          scrollTop = windowScrollContainerState.scrollTop - windowViewportRect.offsetTop
        }
        callback({ ranges, scrollTop })
      }
    )

And it seems that when I log the scrollTop is saving the correct position. I tried modifying the state.tsx example by adding a 200px height header. If I am at the very top and I save, the scrollTop is 0, when I restore it seems to compensate for the header a second time and it scrolls down 200px in addition to the scrollTop value. So if I scroll to item 0, and save, scrollTop is already 200px, and when I restore, it goes to item 8, which is 400px. It works perfectly if I manually subtract the header height from scrollTop before I save. Though I tried to retrieve the value from the stream it seems to be in, I see it grabbed from useEmitterValue in Virtuoso.tsx but whatever I tried I couldn't get it. I'd really love to help and debug it myself, but I can't even figure out where it's restoring it from, I think it's getting piped to initialTopMostItemIndex, but I can't make heads or tails of it.

@petyosi
Copy link
Owner

petyosi commented May 9, 2024

This is most likely a legit issue, but I am stretched very thin with my time; it's unlikely for me to be able to get to it at any point soon.

@phazei
Copy link
Contributor Author

phazei commented Jul 14, 2024

I think I figured out a solution.

The core of the issue is the scrolling restoration uses:
initialTopMostItemIndex({ offset: snapshot!.scrollTop, index: 0, align: 'start' }

It's off-setting the saved scrollTop from the first indexed item, but that doesn't account for the Header height.

I attempted to grab the header height from domIOsystem in the tup in stateLoadSystem.ts:

    u.connect(
      u.pipe(
        u.combineLatest(restoreStateFrom, headerHeight),
        u.filter(([snapshot, height]) => u.isDefined(snapshot)),
        u.map(([snapshot, headerHeight]) => {
          return locationFromSnapshot(snapshot, headerHeight)
        })
      ),
      initialTopMostItemIndex
    )
    ----
    function locationFromSnapshot(snapshot: StateSnapshot | undefined, headerHeight: number) {
  return { offset: snapshot!.scrollTop - headerHeight, index: 0, align: 'start' }
}

After lots of debugging and adding a new isHeaderHeightSet bool in the Header: component inside Virtuoso.tsx because there was no way to tell if it was 0 or if it wasn't set yet, I realized that even after a stream var is set, for the first few cycles of the app they are stuck with their default values. I think maybe all the defined streams should be set on a reload before attempting to process anything else. I didn't need that boolean anyway since I realized I needed to check if a Header component was even set, but listComponentPropsSystem isn't exported so I couldn't find a way to check that or the props outside of the main Virtuoso.tsx.

While debugging I found I can add && height > 0 to the u.filter, but by the time headerHeight is available, the filter from initialTopMostItemIndexSystem:

        u.filter(([[, didMount], scrolledToInitialItem, { sizeTree }, defaultItemSize, scrollScheduled]) => {
          return didMount && (!empty(sizeTree) || u.isDefined(defaultItemSize)) && !scrolledToInitialItem && !scrollScheduled
        }),

and that fails because scrolledToInitialItem && scrollScheduled are already true.

This is a solution in stateLoadSystem that works:

    u.connect(
      u.pipe(
        u.combineLatest(restoreStateFrom, headerHeight),
        u.filter(([snapshot, height]) => u.isDefined(snapshot) && height > 0),
        u.map(([snapshot, headerHeight]) => {
          return locationFromSnapshot(snapshot, headerHeight)
        })
      ),
      scrollToIndex
    )

But it bypasses the initialTopMostItemIndexSystem, and also always expects the height so it's not a good solution.

So if the headerHeight wasn't available before all the checks, then maybe after would be the ideal time to get it.

So this is the final solution:

    u.subscribe(
      u.pipe(
        u.combineLatest(listRefresh, didMount),
        u.withLatestFrom(scrolledToInitialItem, sizes, defaultItemSize, initialItemFinalLocationReached),
        u.filter(([[, didMount], scrolledToInitialItem, { sizeTree }, defaultItemSize, scrollScheduled]) => {
          return didMount && (!empty(sizeTree) || u.isDefined(defaultItemSize)) && !scrolledToInitialItem && !scrollScheduled
        }),
        u.withLatestFrom(initialTopMostItemIndex)
      ),
      ([, initialTopMostItemIndex]) => {
        u.handleNext(scrollTargetReached, () => {
          u.publish(initialItemFinalLocationReached, true)
        })

        skipFrames(4, () => {
          u.handleNext(scrollTop, () => {
            u.publish(scrolledToInitialItem, true)
          })

          //New Code:
          //by the 3rd skipped frame the values has loaded if it exists
          const currentHeaderHeight = u.getValue(headerHeight)
          if (typeof initialTopMostItemIndex === 'object' && typeof initialTopMostItemIndex.offset === 'number') {
            initialTopMostItemIndex.offset -= currentHeaderHeight
          }
          //End New Code

          u.publish(scrollToIndex, initialTopMostItemIndex)
        })
      }
    )

I played with the skipFrames, and it needs to be at least 3 before the headerHeight value is properly populated, it was 4 to begin with, so all good now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

2 participants