Using the new InfiniteScroll hook in LiveView main

After watching @chrismccord’s excellent “The Road To LiveView 1.0” keynote, the upcoming infinite scroll features with :limit on streamed collections looked like exactly what I needed to simplify the implementation of something I’m working on. I introduced LiveView to this project a while before it was even available on Hex, so I’m happy enough to depend on main to get the new features before 0.19 gets released.

However, my phx-viewport-top and phx-viewport-bottom events don’t seem to be triggering. Apart from switching my phoenix_live_view dependency to use github and adding those attributes to an element that has phx-update="stream", is there another step that I’m missing?

This is what’s in my template, with some irrelevant things removed or renamed:

  <div>
    <ul
      id="items"
      phx-update="stream"
      phx-viewport-top="prev-page"
      phx-viewport-bottom="next-page"
    >
      <li :for={{id, item} <- @streams.items} id={id}>
        [ render item ]
      </li>
    </ul>
  </div>
1 Like

Are you sure you’re on latest main? I didn’t merge the viewport branch stuff until recently.

1 Like

Are you sure you’re on latest main? I didn’t merge the viewport branch stuff until recently.

Pretty sure – I only switched to tracking main and ran mix deps.get yesterday (I noticed that the branch was merged a few days ago). I can see Hooks.InfiniteScroll in app.js, so it looks like it’s compiling the right assets.

I’ll try creating a clean app and see if that works, and work from there. There’s almost certainly something obvious I’m missing!

OK, it works in a new app, so I’ll just have to figure out what the key difference is between that and the real one. Thanks!

1 Like

Maybe you need to clean some JavaScript cache or similar so it picks up the latest JS bundle from LiveView?

2 Likes

Maybe you need to clean some JavaScript cache or similar so it picks up the latest JS bundle from LiveView?

Thanks – honoured to have two such esteemed people reply to my post :slightly_smiling_face:

I think I cleared all the caches (removed and rebuilt priv/static, and did an “empty cache and hard reload” in Chrome), but not 100% sure I got everything. I’ll keep digging!

2 Likes

Update – the test liveview that worked in a new app didn’t when I dropped it into the old one, so I suspected it was something to do with the asset-building process. The old app is still using Webpack, along with an ancient version of node.js and what seems like far too many npm packages, and when I tried installing esbuild instead the events suddenly started getting through OK. Time to bite the bullet and finally switch, I guess (it had been left as-is up to now to avoid figuring out how to get sass, bootstrap etc working with esbuild).

FWIW, there is a dart_sass package that works like esbuild: GitHub - CargoSense/dart_sass: An installer for sass powered by Elixir Mix

So you keep two distinct pipelines, one for JS, and another for CSS.

1 Like

I believe the issue is with some assumptions on the InfiniteScroll hook.

Look at this two lines of code (taken from InfiniteScroll):

window.addEventListener("scroll", this.onScroll);
var scrollTop = () => document.documentElement.scrollTop || document.body.scrollTop;

In my case, where the stream is inside an absolutely positioned element, the scroll event is not triggered at the window level, and document.body.scrollTop returns always zero.

Now, if I switch both those statements to:

targetEl.addEventListener("scroll", this.onScroll);

And

var scrollTop = (targetEl) => targetEl.scrollTop;  // or something like this

Everything works.

What I believe is missing is a way of telling the hook, which container does the scrolling. Maybe something like phx-viewport-container.

6 Likes

Thanks @fceruti, that definitely looks like it’s related to the issue I was having. My liveview was inside a grid layout, which ended up with the immediate parent appearing to have a height big enough to fit its content, with the scrolling happening further up the dom. I think the culprit was actually a footer which is a LiveComponent, with the generated phx-root div somehow confusing things. I ended up reworking the layout, and rendering the <footer> element in the layout instead of the component, and eventually managed to end up with the phx-update="stream" element having 100% height and the scroll events reaching the hook correctly.

My assumption that Webpack was somehow to blame was a complete red herring, but on the plus side I now have a nice clean asset build with esbuild, dart-sass and phoenix-copy, and a much cleaner node_modules directory! Somewhat reminiscent of when I migrated the same app from brunch to webpack, and from bootstrap 4 to 5 a few years ago, only to find that the thing that was actually causing brunch to fail had been a missing semicolon in a config file somewhere :man_facepalming:t2:.

2 Likes

I’m also experiencing a similar issue with our application, where we’ve chosen to style our <body> tag as overflow-hidden, breaking the scroll behavior on the window object. Being able to specify a phx-viewport-container with a string that works with document.querySelector fixes the issue. @chrismccord , would you be open to a Pull Request allowing an optional phx-viewport-container attribute to be specified? The default would still use the window object.

1 Like

I got it working by copy / pasting LiveView’s InfiniteScroll hook into my own project, and modifying the code a bit (without adding a new html param).

The key aspect, is to use this snippet to find the scrollable container:

let findScrollContainer = (el: HTMLElement) => {
  if (["scroll", "auto"].indexOf(getComputedStyle(el).overflowY) >= 0) return el;
  if (document.body === el) throw Error("Could not find a scrollable container");
  return findScrollContainer(el.parentElement);
};
mounted() {
    ...
    this.scrollContainer = findScrollContainer(this.el);
    ...
    this.scrollContainer.addEventListener("scroll", this.onScroll);
}

I haven’t battle tested this code, that’s why I don’t share it completely, but this is 95% of what’s important.

Oh, another consideration is this: Yoou need to change the html params, so normal phoenix doesn pick up the default code (it’s looking for viewport-top.

      let topEvent = this.el.getAttribute(this.liveSocket.binding("viewport-top-tt"));
      let bottomEvent = this.el.getAttribute(this.liveSocket.binding("viewport-bottom-tt"));

By the end you should see something like this:

            <div
              id="sidebar-list"
              phx-update="stream"
              phx-hook="InfiniteScroll"
              phx-viewport-top-tt={not @beginning_of_timeline? && "load-top"}
              phx-viewport-bottom-tt={not @end_of_timeline? && "load-bottom"}
              class={[
                "sidebar-list",
                if(@beginning_of_timeline?, do: "reached-top"),
                if(@end_of_timeline?, do: "reached-bottom")
              ]}
            >

2 Likes

Thanks @fceruti for the nudge in the right direction - I’d fought with LV’s InfiniteScroll hook myself for a while before arriving at the same conclusion: it doesn’t work unless the scrolling container is at the window root. This is a pretty severe limitation of the hook implementation; I’ve done the same as you and vendored the LV hook into our own code, and modified it so that it finds the nearest scrollable parent container as per your example.

@chrismccord I’d add another vote for a PR to the hook - I’d be happy to submit my implementation; it’s a pretty minimal diff.

[edit] PR submitted: Attach InfiniteScroll listener to the nearest parent scroll container by simoncocking · Pull Request #2754 · phoenixframework/phoenix_live_view · GitHub

2 Likes

I’m glad you found it useful :wink:

My implementation is pretty much the same as yours, but the reason I didn’t create a PR, or shared it beyond the core idea, was that I saw/smelled some edge cases that I wouldn’t be using and didn’t make time for them, but should be covered in the framework’s solution. So thanks for taking responsability on this.

I believe that if the scroll container is very small in height, you’ll start to see the limitations of your current implementation of scrolltop. My intuition is that it should receive the scroll container as a parameter. Probably it’s neighbor functions should receive similar treatment.

I’m also curious how it would behave in a long article page, as a small sidebar ticker, (maybe a widget for stock prices). In all of my use cases the lists are always visible.

Another consideration I’m not so sure about, is how supported by browsers is getComputedStyle, when it was added, and does it match Phoenix browser support (is there such a thing?). In my cases, my users use reasonably new browsers, so I don’t care about legacy support.

Good points - I’m certain this solution isn’t perfect, but it will allow the hook to be used in more cases than at present. These additional use cases will very likely highlight other shortcomings, which can then be addressed.

We’ve had our own InfiniteScroll hook implemented for some time using IntersectionObserver, which makes the hook significantly less complicated - I’m unsure why the LiveView hook doesn’t leverage this; my intuition says that it would avoid some of the issues you allude to.

1 Like

If you’d like to collaborate with @simoncocking on his PR using intersection observer, I’d love to review a PR Attach InfiniteScroll listener to the nearest parent scroll container by simoncocking · Pull Request #2754 · phoenixframework/phoenix_live_view · GitHub

I didn’t leverage this or support other containers because it is significantly more complicated with all the edge cases and browsers. But if we get a solid contribution that works across browsers and isn’t over complex, I’m all for it! :slight_smile:

5 Likes

Excuse the bikeshed, but should either a new set of directives be added of the current ones be renamed? It’s just that viewport as far as JavaScript goes always means “the browser’s viewport”, never a scrollable element, so it would add some terminology overloading.

My care-level is pretty low here, but I wanted to bring it up.

Regardless, thanks all for this work! I’m happy to do some testing though have no cycles to do any dev work.

Not sure. Can figure it out later :slight_smile:

2 Likes