symfony / ux

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

[LiveComponent] Stimulus controller inside a Live Component does not rerender #2048

Closed matthieumastadenis closed 3 weeks ago

matthieumastadenis commented 1 month ago

Hello,

I have a Live Component containing a search form and a table of results dynamically generated by an embedded stimulus controller.

When I type something in the form, the Live Component is re-rendered as expected, but nothing happens on the child stimulus controller. After reading this issue I added a data-live-id attribute on the controller but it does not solve my problem.

I can see the HTML being succesfully updated by the live component, but the stimulus controller doesn't seem to react to that change at all.

Did I forget something here?


#[AsLiveComponent]
class AdminUsers extends AbstractController
{
    use DefaultActionTrait;
    use ComponentWithFormTrait;

    #[LiveProp]
    public array $initialFormData = [];

    #[LiveProp]
    public array $users = [];

    public function mount(): void
    {
        $this->users = $this->fetchUsers($this->initialFormData);
    }

    public function __invoke(): void
    {
        $this->search();
    }

    #[LiveAction]
    public function search(): void
    {
        $this->submitForm();
        $this->users = $this->fetchUsers($this->getForm()->getData());
    }

    protected function fetchUsers(array $searchData = []): array
    {
        // fetching users from database using $searchData to filter results...
        return $users;
    }

    protected function instantiateForm(): FormInterface
    {
        return $this->createForm(
            type : AdminUsersSearchType::class,
            data : $this->initialFormData,
        );
    }
}
<div {{ attributes }}>
    <twig:UI:Section :full="false">
        <twig:UI:Title:Section icon="fa6-solid:magnifying-glass">Rechercher</twig:UI:Title:Section>

        {{ form_start(form, {
            method: 'POST',
            attr: {
                'data-model': 'debounce(300)|*',
                'data-action': 'live#action',
                'data-live-action-param': 'search',
            },
        }) }}
        <div class="form_grid form_grid-2_columns">
            {% for child in form %}
                {% if child.vars.name != '_token' %}
                    <div class="form_label">
                        {{ form_label(child) }}
                    </div>
                    <div class="form_field">
                        {{ form_widget(child) }}
                    </div>
                {% endif %}
            {% endfor %}
            <div class="form_row form_row-buttons padding-top_s">
                {% block buttons %}
                    <input type="submit" value="Rechercher">
                {% endblock %}
            </div>
        </div>
        {{ form_rest(form) }}
        {{ form_end(form) }}
    </twig:UI:Section>

    <twig:UI:Section data-live-id="{{ microtime() }}">
        {# The title below is correctly updated each time the Live Component re-renders 
             (so I see the correct number of results according to what I typed in the search form above) #}
        <twig:UI:Title:Section>
            {{ users|length~(users|length > 1 ? ' Résultats' : ' Résultat') }}
        </twig:UI:Title:Section>

        {# The markup of the stimulus controller below is correctly updated each time the Live Component re-renders, 
             but the controller is actually never re-rendered (and so the generated table never changes) #}
        <div id="users_table"
            data-controller="UI--datatable"
            data-UI--datatable-data-value='{{ users|json_encode }}'
            data-UI--datatable-columns-value='{{ [
                    {
                        title: 'E-mail',
                        name: 'email',
                        width: 250,
                    },
                    {
                        title: 'Identifiant',
                        name: 'username',
                        width: 250,
                    },
                ]|json_encode }}'
            data-live-id="{{ microtime() }}"
            class="datatable"
        >
            <div data-UI--datatable-target="table" class="datatable_table"></div>
        </div>
    </twig:UI:Section>
</div>
smnandre commented 1 month ago

The markup of the stimulus controller below is correctly updated each time the Live Component re-renders, but the controller is actually never re-rendered (and so the generated table never changes)

Could you precise what you mean by that ? I'm a bit confused

matthieumastadenis commented 1 month ago

The markup of the stimulus controller below is correctly updated each time the Live Component re-renders, but the controller is actually never re-rendered (and so the generated table never changes)

Could you precise what you mean by that ? I'm a bit confused

Of course. By looking at the code I provided, you can see that the list of users is passed to the stimulus controller with data-UI--datatable-data-value='{{ users|json_encode }}'. By using the inspector in my browser, I can see that this code is correctly refreshed when the component re-renders, meaning I can see that the array injected into stimulus contains the expected list of users, according to what I typed in the search form. More precisely with the data I use to test the component: by default I have a list of two users, and after searching for a precise username or email with the form, I can see that an array of only one user is now injected.

This is the expected behavior, so I'm satisfied with the "live component" part, which seems to work properly. But somehow that's not enough for the stimulus controller to react to that change. I even put a console.log() in the connect() method of the controller, and I only see the result once in the console (when I load the page for the first time), but never after any re-rendering of the live component.

smnandre commented 1 month ago

Could you share your component code ?

matthieumastadenis commented 1 month ago

Could you share your component code ?

Here it is, it's very simple and uses jspreadsheet-ce :

import { Controller } from '@hotwired/stimulus';
import jspreadsheet from 'jspreadsheet-ce';
import 'jsuites/dist/jsuites.min.css';
import 'jspreadsheet-ce/dist/jspreadsheet.min.css';
import '../../styles/entries/spreadsheets.scss';

/* stimulusFetch: 'lazy' */
export default class extends Controller {
    static values = {
        data: {
            type: Array,
            default: [],
        },
        columns: {
            type: Array,
            default: [],
        }
    }

    static targets = [ 'table' ];

    connect() {
        // This is only displayed in the console once (after initial page load)
        // I expect to see it each time the live component re-renders:
        console.log('connected '+this.element.dataset.liveId)

        jspreadsheet(this.tableTarget, {
            data: this.dataValue,
            columns: this.columnsValue,
            filters: true,
            allowInsertRow:false,
            allowManualInsertRow:false,
            allowInsertColumn:false,
            allowManualInsertColumn:false,
            allowDeleteRow:false,
            allowDeleteColumn:false,
        });
    }

}
matthieumastadenis commented 1 month ago

I found a solution but it's a bit ugly, I still think this should work without such a hack:

1 - I added a has-datatable attribute on my live component root element:

<div has-datatable {{ attributes }}>
</div>

2 - I modified my stimulus controller so it can find that component using the added attribute, then listen to its render event and then recreate the table:

import { Controller } from '@hotwired/stimulus';
import { getComponent } from '@symfony/ux-live-component';
import jspreadsheet from 'jspreadsheet-ce';
import 'jsuites/dist/jsuites.min.css';
import 'jspreadsheet-ce/dist/jspreadsheet.min.css';
import '../../styles/entries/spreadsheets.scss';

/* stimulusFetch: 'lazy' */
export default class extends Controller {
    static values = {
        data: {
            type: Array,
            default: [],
        },
        columns: {
            type: Array,
            default: [],
        }
    }

    static targets = [ 'table' ];

    connect() {
        this.spreadsheet = jspreadsheet(this.tableTarget, {
            data: this.dataValue,
            columns: this.columnsValue,
            filters: true,
            allowInsertRow:false,
            allowManualInsertRow:false,
            allowInsertColumn:false,
            allowManualInsertColumn:false,
            allowDeleteRow:false,
            allowDeleteColumn:false,
        });
    }

    async initialize() {
        const parentComponent = this.element.closest('[has-datatable]');

        if (parentComponent) {
            this.component = await getComponent(parentComponent)
            this.component.on('render:finished', component => {
                if (this.spreadsheet) {
                    this.spreadsheet.destroy();
                    this.connect();
                }
            });
        }
    }
}

This works, but if anybody knows a simpler solution I'm still interested.

smnandre commented 1 month ago

I think you can leverage the value change callbacks here: https://stimulus.hotwired.dev/reference/values#change-callbacks

matthieumastadenis commented 1 month ago

I think you can leverage the value change callbacks here: https://stimulus.hotwired.dev/reference/values#change-callbacks

Thanks, I just tried this but the callback doesn't seem to be called, I don't know why. Looks like the controller doesn't react at all when the parent component is re-rendered.

Could it be related to the fact that my stimulus controller is actually nested into a child component (the twig:UI:Section one) ? Yet I added the data-live-id attribute on it, and as I was saying previously I can see its markup changing...

smnandre commented 1 month ago

Not sure if related, but this should be lowercase

-            data-UI--datatable-columns-value='{{ [
+            data-ui--datatable-columns-value='{{ [

That beeing said, for stimulus i think your component does not deconnect / connect, as the id is the same.

matthieumastadenis commented 1 month ago

Not sure if related, but this should be lowercase

-            data-UI--datatable-columns-value='{{ [
+            data-ui--datatable-columns-value='{{ [

I tried with the lowercase attributes, it doesn't solve my problem but thanks for the advice. I still have to use data-controller="UI--spreadsheet" for the controller name because it's in a UI folder in uppercase.

That beeing said, for stimulus i think your component does not deconnect / connect, as the id is the same.

I tried with id="{{ microtime() }}" on the controller element, but this doen't solve the problem either. The controller never reconnects.

CMH-Benny commented 1 month ago

Does it use morphing? Afaik a controller should disconnect and reconnect automatically, but morphing might not remove the whole tag but only replace deltas and if controller doesn't change it remains running as it is?

https://symfony.com/bundles/ux-live-component/current/index.html#overwrite-html-instead-of-morphing

WebMamba commented 1 month ago

This is the expected behavior, so I'm satisfied with the "live component" part, which seems to work properly. But somehow that's not enough for the stimulus controller to react to that change. I even put a console.log() in the connect() method of the controller, and I only see the result once in the console (when I load the page for the first time), but never after any re-rendering of the live component.

@matthieumastadenis This is the expected behavior. The live components don't reload the embedded stimulus controller. Your stimulus controller is connected on page load once, and then run for ever even if components change. This is one of the main think of stimulus, to force you to write javascrit that is load once and run for ever. This is design like that for performance and maintenance reasons. If you want to update your stimulus controller on your LiveComponent changes you have to listen to events. You can create your own browser events: https://symfony.com/bundles/ux-live-component/current/index.html#dispatching-browser-javascript-events Or listen to already made js events: https://symfony.com/bundles/ux-live-component/current/index.html#working-with-the-component-in-javascript

smnandre commented 1 month ago

The UI--datatable Stimulus controller is not on a live component here.

But any DOM update on its values data attribute (and @matthieumastadenis explained us the DOM was indeed correctly updated with the expected values) should trigger the value change callbacks from Stimulus.

I'm thinking this is something else.

@matthieumastadenis could you create a small / minimal reproducer (a git repository we can just pull and run locally) to better help you ?

Good article from SymfonyCast: https://symfonycasts.com/blog/symfony-reproducer Symfony doc: https://symfony.com/doc/current/contributing/code/reproducer.html

matthieumastadenis commented 1 month ago

Does it use morphing? Afaik a controller should disconnect and reconnect automatically, but morphing might not remove the whole tag but only replace deltas and if controller doesn't change it remains running as it is?

https://symfony.com/bundles/ux-live-component/current/index.html#overwrite-html-instead-of-morphing

This works actually, thanks @CMH-Benny !

@smnandre if you're still interested by a reproducer I'll do it, but I'm not sure I'll have the time today sorry, it may be only next week

smnandre commented 1 month ago

As you want, if you're happy with your working solution there is no need!

matthieumastadenis commented 3 weeks ago

@smnandre Sorry for the delay.

Since last time I moved on other things and now it's not easy to find enough time to focus on this issue again. The workaround suggested by @CMH-Benny is good enough for me, so I'm closing this issue.

If I'm stuck with something similar in the future I may open a new issue, in that case I'll add a link to this one. But at the moment everything is good for me.

Thanks again everyone for your help.

Nek- commented 2 weeks ago

We're 2 just today running into this issue in the canal #ux of the Symfony slack. I think this issue should be re-opened to be addressed correctly.

smnandre commented 2 weeks ago

@Nek- let's open a new one, as the code will not be the same and this won't bother @matthieumastadenis with notifications.

You can link to this one in the message if you want to link them :)