ux icon indicating copy to clipboard operation
ux copied to clipboard

[LiveComponent] Re-read url query params when re-render?

Open Nayte91 opened this issue 1 year ago • 5 comments

Hello,

Currently, imagine you have a page with a FacetMenu LC and another ArticleResult LC. FacetMenu write and read the URL query params (searchTerm, status, page, ...), and ArticleResult read them.

When you first load your page with some query params, everything is OK; Both components are able to read the query params. But when I modify an input in the facet menu, here how I make it work for now:

#[AsLiveComponent]
class FacetMenu
{
    use DefaultActionTrait;
    use ComponentToolsTrait;

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

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


    #[LiveProp(writable: true, url: true, onUpdated: 'onChange')]
    public int $page = 1;


    #[LiveAction]
    public function reset(): void
    {
        $this->name = '';
        $this->status = '';
        $this->page = 1;

        $this->onChange();
    }

    #[PostMount]
    public function onChange(): void
    {
        $this->page = 1;
        $this->emit(
            'facetSetted',
            array_filter(
                get_object_vars($this),
                fn($value, $key) => in_array($key, ['name', 'status', 'page']),
                ARRAY_FILTER_USE_BOTH
            )
        );
    }
}

#[AsLiveComponent]
class ArticleResults
{
    use DefaultActionTrait;
    use ComponentToolsTrait;

    #[LiveProp(url: true)]
    public ?string $name = null;
   
    #[LiveProp(url: true)]
    public ?string $status = null;

    #[LiveProp(url: true)]
    public ?int $page = 1;

    private ItemsPage $articlesPage;

    public function __construct(private readonly QueryBus $queryBus) {}

    #[LiveListener('facetSetted')]
    public function reload(
        #[LiveArg] string $name,
        #[LiveArg] string $status,
        #[LiveArg] int $page,
    ): void {
        $this->name = $name;
        $this->status = $status;
        $this->page = $page;

        $this->getArticlesPage();
    }

    public function getArticlesPage(): ItemsPage
    {
        return $this->articlesPage ??= $this->searchArticles();
    }

    private function searchArticles(): ItemsPage
    {
        $filter = new Filter(
            $this->page,
            $this->name,
            $this->status
        );

        return $this->queryBus->query(new GetArticlesPaginated($filter));
    }
}
  • a #[PostMount] public function onChange(): void method emits an event with the whole inputs as an associative array,
  • the ArticleResult LC has a method with #[LiveListener('emitedFromFacet')] method, that welcome all the parameters with #[LiveArg]. properties. Then, it defines each $this->givenProperty to force the LC at the same state than when it reads the URL (example of property: #[LiveProp(writable: true, url: true)] public ?string $status = null;), then call the method to do the logic.

I feel like packing properties from SearchMenu, serializing them to send them through JS, then recall them one by one as method parameters, and reallocate them before doing logic, is rewriting each property basically 3 times, for components that already know about the properties, as they welcome them from URL when first loading.

So do you know a way to tell, before doing logic in a given method, to "re-read" the url as it can have changed? Or do you see another way to achieve this? I know about the UX icons implementation, but the search bar AND the results div are on the same component; So another way to ask my question is "how would you design the icons page if you have to do 2 separate components for SearchBar and IconResults? How would you pass the parameters between them?"

  1. change the behavior of "url:true" LiveProps to check again the values from the URL when re-render?
  2. propose a readUrl() method (maybe make a UrlTrait) that will make the component re-read the URL and re-hydrate the LiveProps?

Part of the discussion about https://github.com/symfony/ux/discussions/2212 (challenge point 2).

Nayte91 avatar Oct 02 '24 18:10 Nayte91

Having two components sharing the same live props (ie: managed as statefull props and stored in the dom) looks a bit to me as some "global state".

Do both of your components really need to have these values as LiveProps ? Could one of them (or a third one) be responsible to store this data and signal when it has some changes ? (i confirm the 3 events system via the browser is probably not the most efficient method)

smnandre avatar Oct 05 '24 00:10 smnandre

Having two components sharing the same live props (ie: managed as statefull props and stored in the dom) looks a bit to me as some "global state".

That's exactly my starting point for this design, as I already encountered it on React; Simple answer is that query is a global state, the mother of all, over the session, over the sharing with friends, over every components on the page.

Do both of your components really need to have these values as LiveProps ? Could one of them (or a third one) be responsible to store this data and signal when it has some changes ? (i confirm the 3 events system via the browser is probably not the most efficient method)

I'm not sure if using query params is the most-efficient-method-the-humanity-will-ever-find, maybe it is, probably not, but it's a way to achieve. Pros are:

  • no session/easy to share, refresh/and so on..
  • somehow elegantly designed as the search Menu writes, and the Result compo reads
  • quite DRY compliant with props only defined "twice", once on the Menu and once on the Result. Potentially even less with #2142
  • because of previous, JS calls are lighter, because values are passed by the Menu once, to url, and not anywhere else.

It's totally possible that it exists a better optimized design, I would gladly use it if so 😅. For now, I feel like if you want to pass an arbitrary (potentially long) number of params, where no one is "hidden", and you want it to be intuitive for end user, it's a good candidate 🙏.

Why do you think that going up to browser then down could be not efficient? Could be slow? When you tell "be responsible to store this data", which solution of sharing the prop between LCs you have in mind?

Nayte91 avatar Oct 05 '24 09:10 Nayte91

somehow elegantly designed as the search Menu writes, and the Result compo reads

This is certainly your intention, but you currently configure both your components to read _ and write_ the query string.

Simple answer is that query is a global state, the mother of all, over the session, over the sharing with friends, over every components on the page.

Im not talking about using the query string ;) I was mentionning using it twice as a storage, with possibilities for endless loops.

But this is philosophical, and if you want to do it that way i'm not the one who's gonna prevent you to 😅

Personnaly, i'd use only one component for this, and delegate / pass this data to other ones, but again, preferences :)

Why do you think that going up to browser then down could be not efficient? Could be slow?

You are going to make 3 XHR calls every time you change something in the URL, i would try to avoid that (because networking is the most failure-prone part of any web app)

which solution of sharing the prop between LCs you have in mind?

Sharing data can be done with nested components, events, shared "registry-of-any-kind", local storage.

But sharing "props" feels to me (i insist on the feel part) an anti-pattern. In my mind, a prop cannot be a prop if it is shared with someone else.

Or, to be precise, a read/write prop.

smnandre avatar Oct 05 '24 12:10 smnandre

This is certainly your intention, but you currently configure both your components to read _ and write_ the query string.

Oh that's a nice catch! You're right, I can remove "writable: true" on the Result LC. It works, I appreciate! Example on previous post updated accordingly.

You are going to make 3 XHR calls every time you change something in the URL, i would try to avoid that (because networking is the most failure-prone part of any web app)

Why 3 ?

  1. One from Menu LC to update URL,
  2. one from Menu LC to tell the Results LC that there's changes,
  3. one from Results LC to read updated URL?

Sharing data can be done with nested components, events, shared "registry-of-any-kind", local storage.

You're maybe right, I will definitely try to build this upon two or three components: one parent that will "centralize" the $filter, and child for menu and/or results. That's maybe better! Keep you in touch.

Nayte91 avatar Oct 07 '24 18:10 Nayte91

Thoughts: there should be a global event like dom navigate I can listen when url changes. Maybe we can relate a stimulus action to this event, pushing back the data to the LC? Something setted automatically like #[LiveProp(url: true, urlListener: true)]? I need to test on my side how it works manually, to begin with. If I programmatically change the url, is the event fired by the browser, and how it looks like?

Edit: I failed to do it :sob: Not sure how to suscribe to this event window.navigation.addEventListener("navigate") with stimulus and send the action to a LC!

Nayte91 avatar Oct 08 '24 09:10 Nayte91

Thank you for this suggestion. There has not been a lot of activity here for a while. Would you still like to see this feature? Every feature is developed by the community. Perhaps someone would like to try? You can read how to contribute to get started.

carsonbot avatar Apr 11 '25 12:04 carsonbot

Could I get an answer? If I do not hear anything I will assume this issue is resolved or abandoned. Please get back to me <3

carsonbot avatar Apr 25 '25 12:04 carsonbot

OK I took some time to go back on my usecase with fresh mind. Here below my example code state:

#[AsLiveComponent]
class FacetMenu
{
    use DefaultActionTrait;
    use ComponentToolsTrait;

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

    #[LiveProp(writable: true, url: true, onUpdated: 'onChange')]
    public int $page = 1;

    #[LiveAction]
    public function reset(): void
    {
        $this->name = '';
        $this->page = 1;

        $this->onChange();
    }

    #[PostMount]
    public function onChange(): void
    {
        $this->page = 1;
        $this->emit(
            'facetSetted',
            array_filter(
                get_object_vars($this),
                fn($value, $key) => in_array($key, ['name', 'page']),
                ARRAY_FILTER_USE_BOTH
            )
        );
    }
}

#[AsLiveComponent]
class ArticleResults
{
    use DefaultActionTrait;
    use ComponentToolsTrait;

    #[LiveProp(url: true)]
    public ?string $name = null;

    #[LiveProp(url: true)]
    public ?int $page = 1;

    private ItemsPage $articlesPage;

    public function __construct(private readonly QueryBus $queryBus) {}

    #[LiveListener('facetSetted')]
    public function reload(
        #[LiveArg] string $name,
        #[LiveArg] int $page,
    ): void {
        $this->name = $name;
        $this->page = $page;

        $this->getArticlesPage();
    }

    public function getArticlesPage(): ItemsPage
    {
        return $this->articlesPage ??= $this->searchArticles();
    }

    private function searchArticles(): ItemsPage
    {
        $filter = new Filter(
            $this->page,
            $this->name,
        );

        return $this->queryBus->query(new GetArticlesPaginated($filter));
    }
}

Twig's rendered face menu:

<aside id="facet" {{ attributes }}>
    <h3>Filter</h3>

    <hr />

    <details open>
        <summary>Name</summary>
        <p>
            <label>
                <span class="sr-only">Enter name to search</span>
                <input
                    name="name"
                    id="name"
                    type="search"
                    data-model="debounce(500)|name"
                    placeholder="Enter name to search"
                    style="height: 2rem; font-size: 0.75rem"
                />
            </label>
        </p>
    </details>

    <button type="reset" class="button" data-action="live#action" data-live-action-param="reset">Reset</button>
</aside>

And comments:

  • FacetMenu writes the url, ArticleResults only read it. Both have 2 params (name and page) that belong in URL.
  • When you change something in facetmenu's rendered form:
    1. It uses "data-model" technique to write into FacetMenu object's property.
    2. Then, because property has url=true, it changes url, using history.pushState() method internally if I'm right.
    3. Because URL changed, it uses the onUpdated: 'emitChange' param.
    4. "emitChange" method fires a custom event, "FacetSetted", where it passes all the url params' states.
    5. ArticleResults object listen to the "FacetSetted" event and triggers its reload() method, where it populates properties, and work.

I would tell that points 1 2 & 3 act OK, it seems well designed and optimally executed. Point 5 is the real deal, as it forces point 4 to send a serialized list of params with the custom event. Reason is, if I go to the tiniest feature's, that I have no way to force a Live Template to re-render from the PHP side; Documentation says that I can from the HTML with Stimulus help, and for my very component. But, an elegant way to deal with my need, should be to:

  • have a $this->rerender() method on PHP side, probably in DefaultActionTrait,
  • Ensure that, when a LC that has "url: true" properties, a rerender will re-read url params, (internally with a JS' "new URL(window.location)" as the back will not know that url changed).

This angle is sexy, but has a problem; It's not possible to achieve this with LC easily. With ReactJS, as everything happens on the browser's side with JS, it's convenient to read URL, write props as url params, re-render a child component when parent's state change, or use a global state on the logic & redering sides effeciently. Here, I assume that when a LC renders for the first time and it has a url prop, this one is populated on the back side, maybe with SF's RequestStack. But when rerendering, it should reads url on the JS side. So because there is no built in event for a url change with history.pushState(), we should send a custom event when an url param is written, and every component with an url: true attribute should listen to this event by default.

For the event emitting, it's quite straightforward, as a wrapper function is enough:

function pushStateWithEvent(state, title, url) {
  const oldUrl = window.location.href;
  
  history.pushState(state, title, url);

  const urlChangeEvent = new CustomEvent('urlchange', {
    detail: {
      state: state,
      oldUrl: oldUrl,
      newUrl: window.location.href,
    },
    bubbles: true
  });
  
  window.dispatchEvent(urlChangeEvent);
}

Then tweak the construct of a live component with a url attribute to listen to it, and call a rerender when get an update.

WDYT?

Nayte91 avatar Apr 30 '25 21:04 Nayte91

Thank you for this suggestion. There has not been a lot of activity here for a while. Would you still like to see this feature? Every feature is developed by the community. Perhaps someone would like to try? You can read how to contribute to get started.

carsonbot avatar Oct 31 '25 12:10 carsonbot

Could I get an answer? If I do not hear anything I will assume this issue is resolved or abandoned. Please get back to me <3

carsonbot avatar Nov 14 '25 12:11 carsonbot

Hey,

I didn't hear anything so I'm going to close it. Feel free to comment if this is still relevant, I can always reopen!

carsonbot avatar Nov 28 '25 12:11 carsonbot