symfony / ux

Symfony UX initiative: a JavaScript ecosystem for Symfony
https://ux.symfony.com/
MIT License
820 stars 297 forks source link

[LiveComponent] In a batch request, the backend only returned that view for the last call #2018

Closed NoopNetwork closed 1 month ago

NoopNetwork commented 1 month ago

Hi all,

I apologize in advance if this is not a bug and I am just very stupid.

Using UX Live Component, whenever there are multiple actions called in the JS Controller roughly at the same time, a batch request to the backend is done that translates into multiple Sub Requests in the HttpKernel. All Sub Requests are executed fine, however, only the view rendering for the last Sub Request is returned to the browser.

To give you more context: I am basically following the Infinite Scroll - 2/2 Tutorial (https://ux.symfony.com/demos/live-component/infinite-scroll-2), however, as I have a complex layout and want to be able to scroll to specific parts of the page quickly, I am setting up dummy elements for all potentially-to-be-loaded content right at the beginning and have Intersection Observers on all of the dummy elements to load in the real content once they come into the viewport. Every time I quickly scroll to a specific part of the page that has multiple dummy elements in view, the batch call is initiated, but is only loading in the content for the last element.

One additional piece of information: I am working with Pimcore. Not sure, if they changed Symfony’s standard behavior in the HttpKernel.

Thank you! Fabian

smnandre commented 1 month ago

It may be a bug, or a documentation problem, so i don't think you are to blame here... and there are no stupid questions :)

Let's try to narrow things down :)

Do your dummy elements belong to the same LiveComponent or are they different ones ?

Trying to understand what you expect regarding scrolling to "specific part of the page" Let's say your page is 2000px height and your viewport only 200px. When you sroll to the bottom of the page, do you want all elements to be loaded or "only" those in the last 200px visible ?

NoopNetwork commented 1 month ago

Hi smnandre,

Thank you for your support!

What I am trying to do is basically replicate the behavior of Photo-Gallery apps on mobile phones. In those, you can usually scroll to any point in time and the respective images are loaded. I grouped all my images by day, setting up the page with an empty div for each day and when I scroll to a specific point, the images for the placeholder divs in the viewport should be loaded. So, in your example, only the images for those days corresponding to the dummy div elements which are in the bottom 200px of the page should be loaded. This is all done in one LiveComponent. It works fine when there is only one of the dummy divs in the viewport. However, when the JS controller sends a batch request, all of the sub requests are handled by the PHP controller separately, but only the view rendering for the last handled sub request is send back to the JS controller. In that case, the last day is loaded correctly for the user, but the other days are not loaded but remain as dummies.

Here is the skeleton of my PHP controller:

<?php

namespace App\Twig\Components;

use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent]
class Timeline
{
    use DefaultActionTrait;

    #[LiveProp(writable: true, url: true)]
    public string $day = '';

    public bool $isAsycUpdate = false;

    private array $validDays = [];

    public function getDay() : string
    {
        return $this->day;
    }

    #[LiveAction]
    public function changeDay(#[LiveArg] string $day) : void
    {
        $this->day = $day;
        $this->isAsycUpdate = true;
    }

    public function getAssetsForDay() : ?Asset\Listing
    {
        // get images for the current day from the DB
    }

    public function getAllValidDays() : array
    {
        // get all days with immages attached from the DB and save them in $this->validDays
        return $this->validDays;
    }
}

Here the twig html corresponding to it:

<div id="timeline-component" {{ attributes.defaults(stimulus_controller('timeline')) }}>

    {% if this.isAsycUpdate %}

        <div
            id="timeline--{{ this.getDay }}"
            data-timeline-target="currentDay"
            data-live-ignore
            data-skip-morph
        >
            <!-- HTML to display all images for a given day using this.getAssetsForDay() -->
        </div>

    {% else %}

        {% for currentDay in this.getAllValidDays %}

                <div
                    id="timeline--{{ currentDay }}"
                    style="height: 250px;"
                    data-timeline-target="loader"
                    data-live-ignore
                    data-skip-morph
                    data-action="loadDay->live#action"
                    data-live-action-param="changeDay"
                    data-live-day-param="{{ currentDay }}"
                >
                    <!-- Empty placeholder for each day to load content later -->
                </div>

        {% endfor %}

    {% endif %}

</div>

And the JS controller:

import { Controller } from '@hotwired/stimulus';

/* stimulusFetch: 'lazy' */
export default class extends Controller {
    static targets = ['loader', 'currentDay'];

    loaderTargetConnected(element) {
        this.observer ??= new IntersectionObserver((entries) => {
            entries.forEach((entry) => {
                if (entry.isIntersecting) {
                    entry.target.dispatchEvent(new CustomEvent('loadDay', { detail: { entry } }));
                }
            });
        });
        this.observer?.observe(element);
    }

    loaderTargetDisconnected(element) {
        this.observer?.unobserve(element);
    }
}
smnandre commented 1 month ago

However, when the JS controller sends a batch request, all of the sub requests are handled by the PHP controller separately, but only the view rendering for the last handled sub request is send back to the JS controller.

That's expected.. there must be "one" reponse in the end, matching the state of the component (so here, the last day set by the actions).

In the Infinite Scroll demo there is a strong notion of pages, and here you have some "sub-page" level or lazyness.

Depending on what you want to do next in your UI, i can suggest you multiple solutions:

Depending on your app i'd suggest the "I'd recommand a LiveComponent per day" i think

NoopNetwork commented 1 month ago

Thank you again for taking so much time to help me!

I have been playing around with the “Make every "day" a LiveComponent with loading=lazy” solution and have to say, that this is very cool. I somehow missed this functionality when I was reading through the documentation. This is pretty much what I wanted, and it is done automatically for me.

However, with this solution I am losing quite a bit of control as there are no events anymore to hook into (as far as I have seen). There are three points in particular:

  1. I used the render:started event to store the scroll position on the screen and the render:finished event to correct scrolling when new content was loaded above the current position so that there is no sudden jump.
  2. As I was triggering the loading of new data with an IntersectionObserver myself, I used rootMargin to load the content even a bit before it came into view. In combination with point 1., the user would not notice that new content was loaded and could just scroll up and down smoothly.
  3. Furthermore, I only triggered the content-loading when the screen was not scrolled anymore for 200ms so that elements are not loaded when scrolling over them quickly.

I did not find any way to implement those features when using the lazy loading solution.

smnandre commented 1 month ago

I used the render:started event to store the scroll position on the screen and the render:finished event to correct scrolling when new content was loaded above the current position so that there is no sudden jump.

Did it work when the user reloaded the page (and so scrolled automatically mid-page for instance) ?

As I was triggering the loading of new data with an IntersectionObserver myself, I used rootMargin to load the content even a bit before it came into view. In combination with point 1., the user would not notice that new content was loaded and could just scroll up and down smoothly.

Do you feel a big difference ? I would personnaly not load anything before it appears in the viewport, but if you need to you may cheat and use some CSS tricks to expand the placeholder size maybe ?

Furthermore, I only triggered the content-loading when the screen was not scrolled anymore for 200ms so that elements are not loaded when scrolling over them quickly

Does this not contradict the point 2 ? If you load before a div appears in the viewport but still add a 200ms delay, would these two things cancel ?

NoopNetwork commented 1 month ago

The planned behavior was as follows: When the user scrolls to some point on the page quickly (or the page is loaded with an anchor-hash in the URL or is re-loaded from a mid-position, causing the browser to scroll quickly) and then stops for 200ms, all elements in view as well as the elements that are 100px above and 100px below the view are loaded, so that the user can smoothly scroll up and down from this position. When scrolling slowly to look at the images, the view pretty much always stays “un-scrolled” for 200ms, causing the next elements that are 100px above/below the viewport to load. In combination with fixing the scroll position to keep the same elements in view when loading some new elements above the current position, the user will not see at all that stuff is loaded in when he scrolls slowly up or down. When he goes quickly, he will see empty space and after stopping for 200ms, he will see elements loading in for a brief second, however, this is a small price to pay for the full flexibility to jump to any point on the page (meaning to the images of any date). I was planning to implement a calendar that lets the user jump even more quickly to a specific date by just triggering the respective scroll.

I any case, I feel this is a bit too much to ask from the Live Component. This is a very cool tool that is just designed for a different use case. On the bright side, while fiddling with this and through your support, I learned so much that I will be easily able to implement a few lines of JavaScript myself to handle my requirements and just load the content in via a standard Symfony Controller.

So, again, thank you very much for your support! Highly appreciated!

smnandre commented 1 month ago

It's been a pleasure! And feel free to suggest / ask features in the future. We cannot implement all but that is always interesting to get feedback, ideas, etc!