Restoring sticky live view scroll position during live redirects

Hey everyone :wave:
Im using sticky live view to render a navbar across my app. The issue is the navbar scroll position resets whenever a live redirect happens within the app. How would you recommend approaching this?

Welcome!

Hmm, that’s odd – LiveView’s definitely gotten better at restoring scroll position out of the box in more recent versions, but it sounds like sticky/nested LiveViews might still be a problem.

Anyways, I’d probably stash scroll position on the client and restore it via a phx-hook on mounted rather than tracking it from the server since it’s a UI concern.

# pseudocode
Hooks.StickyNav = {
  mounted(){
    // stash scroll top
    this.el.addEventListener("scroll", e => {
      // consider debouncing
      sessionStorage.setItem("stickyNavScrollTop", this.el.scrollTop)
    })

   // restore scroll top
   scrollTop = sessionStorage.getItem("stickyNavScrollTop")
   if (scrollTop) { this.el.scrollTop = scrollTop }
  }
}

note: session storage is tied to an individual tab so local storage may be the way to go if you want to handle new tabs as well

And just a heads up, live_redirect has been deprecated for a good while now so it may be worth updating soon – especially with 1.0 around the corner!

I have the same problem. While the code above looks ok, it does not work because the sticky liveview is not remounted on live navigation. The updated hook is also not fired.
There is the “phx:navigate” javascript event that you can listen to with window.addEventListener("phx:navigate", <read sessionStorage>...), but this causes a flickering of the UI since liveview will have reset the scroll position before that.
Does someone have a better solution?
There’s this issue here in live_view (scroll other element than body on history navigation · Issue #2107 · phoenixframework/phoenix_live_view · GitHub) but it would be nice to have a workaround in the meantime.

I use this snippet in a few places and it works ok

Hooks.ScrollPosition = {
  mounted() {
    let top = localStorage.getItem(this.el.id);

    if (top !== null) {
      this.el.scrollTop = parseInt(top, 10);
    }

    window.addEventListener("phx:page-loading-start", _info => localStorage.setItem(this.el.id, this.el.scrollTop))
  }
}

Thanks for the snippet. This seems to be used when navigating between different pages (and not live navigation), right? I’m looking for something specifically for live navigation and sticky liveview. I have tried to listen to phx:navigate and phx:page-loading-start/phx:page-loading-stop, but I always get some flickering. That’s my code:

Hooks.KeepScrollPosition = {
  mounted() 
    this.el.addEventListener("scroll", (_e) => {
      sessionStorage.setItem(`panel-primary-view-scroll-position`, this.el.scrollTop);
    });
    window.addEventListener("phx:page-loading-stop", (e) => {
      const id = "panel-primary-view";
      scrollTop = sessionStorage.getItem(`${id}-scroll-position`);
      if (scrollTop) {
        const div = document.getElementById(id);
        setTimeout(() => {
          div.scrollTop = scrollTop;
        }, 0);
      }
    });
  },
};

I should obviously remove the listener at some point.I can’t get it working without setTimeout.

Seems like this is working for my use-case:

function debounce(func, timeout = 100) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => {
      func.apply(this, args);
    }, timeout);
  };
}

/**
 * A hook used to keep the scroll position of elements
 * inside a sticky liveview after a live navigation.
 */

/**
 * Session storage key for restoring panel size.
 */
const SCROLL_POSITION_STORAGE_KEY = "scroll-position";

const KeepScrollPosition = {
  restoreScrollPosition(_info) {
    scrollTop = sessionStorage.getItem(`${this.el.id}-${SCROLL_POSITION_STORAGE_KEY}`);
    if (scrollTop) {
      this.el.scrollTop = scrollTop;
    }
  },

  mounted() {
    const save = debounce(() => {
      sessionStorage.setItem(`${this.el.id}-${SCROLL_POSITION_STORAGE_KEY}`, this.el.scrollTop);
    });

    this._restoreScrollPosition = this.restoreScrollPosition.bind(this);
    this.el.addEventListener("scroll", save);
    window.addEventListener("phx:page-loading-stop", this._restoreScrollPosition);
  },

  destroyed() {
    window.removeEventListener("phx:page-loading-stop", this._restoreScrollPosition);
  },
};

export { KeepScrollPosition };