symfony / ux

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

[Live] Triggered actions result in UnprocessableEntityHttpException due to "Invalid or missing checksum" error in LiveComponentHydrator #537

Closed manuelkiessling closed 1 year ago

manuelkiessling commented 2 years ago

I've just upgraded from UX 2.4.0 to 2.5.0 in my Symfony 6.1.7 / PHP 8.1.11 project, and all UX Live Component actions no longer work - they do trigger a POST as expected, but the backend responds with HTTP status 422 Unprocessable Entity, due to an exception thrown by LiveComponentHydrator.php:129 with message Invalid or missing checksum. This usually means that you tried to change a property that is not writable: true..

I've done the Composer update in isolation, i.e. nothing else changed, and it goes back to working if I do a downgrade to 2.4.0.

Here's the result of the Composer update:

% composer update --with-dependencies
Loading composer repositories with package information
Info from https://repo.packagist.org: #StandWithUkraine
Restricting packages listed in "symfony/symfony" to "6.1.*"
Updating dependencies
Lock file operations: 0 installs, 5 updates, 0 removals
- Upgrading doctrine/doctrine-bundle (2.7.0 => 2.7.1)
- Upgrading sensio/framework-extra-bundle (v6.2.8 => v6.2.9)
- Upgrading stripe/stripe-php (v9.8.0 => v9.9.0)
- Upgrading symfony/ux-live-component (v2.4.0 => v2.5.0)
- Upgrading symfony/ux-twig-component (v2.4.0 => v2.5.0)
Writing lock file
Installing dependencies from lock file (including require-dev)
Package operations: 0 installs, 5 updates, 0 removals
- Upgrading doctrine/doctrine-bundle (2.7.0 => 2.7.1): Extracting archive
- Upgrading sensio/framework-extra-bundle (v6.2.8 => v6.2.9): Extracting archive
- Upgrading stripe/stripe-php (v9.8.0 => v9.9.0): Extracting archive
- Upgrading symfony/ux-twig-component (v2.4.0 => v2.5.0): Extracting archive
- Upgrading symfony/ux-live-component (v2.4.0 => v2.5.0): Extracting archive

When I trigger an action on version 2.4.0, the POST payload looks like this:

{
  "data": "1ed5ada4-e0e0-6e9e-a430-fd47a6d79fa4",
  "editModalIsOpen": false,
  "shareModalIsOpen": false,
  "deleteModalIsOpen": false,
  "shareUrl": "http://127.0.0.1:8000/v/dxsh-2Sv3DW",
  "doneCtaMustRedirectToOverview": false,
  "formName": "video",
  "video": {
    "title": "Dies ist ein Test",
    "videoOnlyPresentationpageTemplate": "1ed5567e-d2bf-6a90-9bee-fd4a081f390a",
    "_token": "1b98129085706f.rbqRVEz6VFedLBrRDuE7JGZ-L5wBr9uSiPxtFEMlGI0.z5e8FwW9Ejzne0m7I7gJbQwTVsxU6Y3X_aY7QxtUcNvm3c4VCpc_Lvt6Sw"
  },
  "isValidated": false,
  "validatedFields": [],
  "_checksum": "C8V5E5gARfsauKzyYW0nMwmGj0/SPPRc8Me27T/S0K8="
}

But when I trigger the same action on version 2.5.0, it looks a lot slimmer:

{
  "video": {
    "title": "Dies ist ein Test",
    "videoOnlyPresentationpageTemplate": "1ed5567e-d2bf-6a90-9bee-fd4a081f390a",
    "_token": "aae77d559daee12a243f9.tLLZvZtpxG57cxHV7LX_OWOIj99RBaKVqTvjBO6l0tc.1p_0_tIuggUBJEK_wezNcAnl9o8EQ_TQ3GG1U7bUuoH_1Yb83QSvFx0lQA"
  },
  "isValidated": false,
  "validatedFields": []
}

The action is simply triggered through a click on a button which looks as follows:

<button
        class="p-2 pl-4 pr-4 w-full h-full text-left text-sm"
        data-action="live#action"
        data-action-name="showDeleteModal"
>...

I've seen that there has been quite some Changed Behaviours and BCs, so maybe there are changes I need to do. Nevertheless, putting this here in case others run into the same issue.

xaviermarchegay commented 2 years ago

I had a similar issue until I compiled the assets (the javascript was updated too).

manuelkiessling commented 2 years ago

@xaviermarchegay I've also run npm run update and npm run build, but this doesn't change the situation for me. I've verified that the NPM dependencies have also been updated to the 2.5.0 version.

weaverryan commented 2 years ago

Thanks for the very clear bug report including the payloads before and after! And yes, there were quite a few BC-breaking changes, but what you're experiencing should not be happening.

Your 2.5 payload, indeed, looks way too small. Apart from many of your models being missing, it's obviously missing the _checksum. What does your root component element look like when it's rendered? Specifically, the data-live-data-value attribute would be interesting. It should, among other things, contain the _checksum. Also, when you hit this error, is it the first Ajax call that your component has made? Or has it made any others?

Cheers!

manuelkiessling commented 2 years ago

It's the first one. I've digged into it for a while now, but still have no idea where this issue comes from. Therefore, allow me to share some more code.

First, here is the gist of my LiveComponent:

<?php

#[AsLiveComponent(
    'videobasedmarketing_recordings_video_manage_widget',
    '@videobasedmarketing.recordings/video_manage_widget_live_component.html.twig'
)]
class VideoManageWidgetLiveComponent
    extends AbstractController
{
    use DefaultActionTrait;
    use ComponentWithFormTrait;

    #[LiveProp(fieldName: 'data')]
    public ?Video $video = null;

    #[LiveProp]
    public bool $editModalIsOpen = false;

    public function mount(
        Video $video
    )
    {
        $this->denyAccessUnlessGranted(
            VotingAttribute::Edit->value,
            $video
        );

        $this->video = $video;
    }

    #[LiveAction]
    public function showEditModal(): void
    {
        $this->editModalIsOpen = true;
    }

    #[LiveAction]
    public function hideEditModal(): void
    {
        $this->editModalIsOpen = false;
    }

    protected function instantiateForm(): FormInterface
    {
        return $this->createForm(
            VideoType::class,
            $this->video
        );
    }
}

Here is - again only the "gist" - of the component template:

{# @var this \App\VideoBasedMarketing\Recordings\Presentation\Component\VideoManageWidgetLiveComponent #}
{# @var TwigHelperService \App\Shared\Presentation\Service\TwigHelperService #}

<div {{ attributes }}>
    <button
            class="p-2 pl-4 pr-4 w-full h-full text-left text-sm"
            data-action="live#action"
            data-action-name="showEditModal"
    >
        <span class="flex flex-row justify-between items-center gap-2">
            <span>
                Aufnahme bearbeiten
            </span>
            <span>
                {# Heroicon: pencil-square solid #}
                <svg
                        class="h-4 w-4 text-indigo-900"
                        xmlns="http://www.w3.org/2000/svg"
                        fill="currentColor"
                        viewBox="0 0 24 24"
                >
                    <path d="..." />
                    <path d="..." />
                </svg>
            </span>
        </span>
    </button>
</div>

And finally, here is the result, fresh from the rendered DOM:

<div data-controller="live" data-live-url-value="/_components/videobasedmarketing_recordings_video_manage_widget"
     data-live-data-value="{&quot;video&quot;:{&quot;title&quot;:&quot;Dies ist ein Test&quot;,&quot;videoOnlyPresentationpageTemplate&quot;:&quot;1ed5567e-d2bf-6a90-9bee-fd4a081f390a&quot;,&quot;_token&quot;:&quot;bf951bf705cf7eccdb7.0m9PHUQoJcx4de_J8RRB5NvjURu1XB2zA2JmWxUuH1M.tgw_TwYfcZo3GIi5sG0jp-OCKVTyMmmLWTU-EEEYcAOCMCpVM2lKvScGug&quot;},&quot;isValidated&quot;:false,&quot;validatedFields&quot;:[]}"
     data-live-props-value="{&quot;data&quot;:&quot;1ed5ada4-e0e0-6e9e-a430-fd47a6d79fa4&quot;,&quot;editModalIsOpen&quot;:false,&quot;shareModalIsOpen&quot;:false,&quot;deleteModalIsOpen&quot;:false,&quot;shareUrl&quot;:&quot;http:\/\/127.0.0.1:8000\/v\/dxsh-2Sv3DW&quot;,&quot;doneCtaMustRedirectToOverview&quot;:false,&quot;formName&quot;:&quot;video&quot;,&quot;_checksum&quot;:&quot;C8V5E5gARfsauKzyYW0nMwmGj0\/SPPRc8Me27T\/S0K8=&quot;}"
     data-live-csrf-value="7.IG9PJq5-8-uWF3TKAa7KSJMwsz1IJLEs24SnT-YoNRs.FRsmQtxGvZ_vQS64YJePHdR7-nA-a9NdjeHVA7ZtVCgQQi5xwxCkjcc6Gw">

    <button class="p-2 pl-4 pr-4 w-full h-full text-left text-sm" data-action="live#action"
            data-action-name="showEditModal">
        <span class="flex flex-row justify-between items-center gap-2">
            <span>
                Aufnahme bearbeiten
            </span>
            <span>
                <svg class="h-4 w-4 text-indigo-900" xmlns="http://www.w3.org/2000/svg"
                                             fill="currentColor" viewBox="0 0 24 24">
                    <path d="M21.731 2.269a2.625 2.625 0 00-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 000-3.712zM19.513 8.199l-3.712-3.712-8.4 8.4a5.25 5.25 0 00-1.32 2.214l-.8 2.685a.75.75 0 00.933.933l2.685-.8a5.25 5.25 0 002.214-1.32l8.4-8.4z"></path>
                    <path d="M5.25 5.25a3 3 0 00-3 3v10.5a3 3 0 003 3h10.5a3 3 0 003-3V13.5a.75.75 0 00-1.5 0v5.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5V8.25a1.5 1.5 0 011.5-1.5h5.25a.75.75 0 000-1.5H5.25z"></path>
                </svg>
            </span>
        </span>
    </button>

</div>
manuelkiessling commented 2 years ago

To corner the problem, I have just re-created the "random number widget" by following the docs to the letter, and the result works, with the resulting DOM looking like this:

<div data-controller="live"
     data-live-url-value="/_components/random_number"
     data-live-data-value="{}"
     data-live-props-value="{&quot;_checksum&quot;:&quot;IW1BQOmstmWWSSuxaDMazCkM0yGiETn4Ko\/tAYqi2HU=&quot;}"
     data-live-csrf-value="377.AmS_z21vKAoM6vGl1a5VZ_JLWdKhkEdbBzYSwBUzlxU.dwHZtgAXbkxGnZnnhckFMIsTA4fo2RIvaWVhmF4E4UZ0Hc2gHQR3R2eSmg">
    <strong>973</strong>

    <button data-action="live#$render">Generate a new number!</button>
</div>
manuelkiessling commented 2 years ago

Ok, things look a tad better now, although it's still not back in a working state.

The "way-to-slim" POST payload was likely due to me not updating the Node dependencies via NPM (I've only now realized that I ran into another issue after doing so, not realizing that the error now lies elsewhere).

The 2.4 payload looks like this:

{
  "data": "1ed5ada4-e0e0-6e9e-a430-fd47a6d79fa4",
  "editModalIsOpen": false,
  "shareModalIsOpen": false,
  "deleteModalIsOpen": false,
  "shareUrl": "http://127.0.0.1:8000/v/dxsh-2Sv3DW",
  "doneCtaMustRedirectToOverview": false,
  "formName": "video",
  "video": {
    "title": "Dies ist ein Test",
    "videoOnlyPresentationpageTemplate": "1ed5567e-d2bf-6a90-9bee-fd4a081f390a",
    "_token": "1c7ec31404.Kfv42Enjr35GTa1xrMIToIN-EzwXimz953CqAnOee9U.TZiIigvU-ygJIMoB7btx47sfa3NQ5BjFvSfySSeoFIV5pJ2QPqLADxk--A"
  },
  "isValidated": false,
  "validatedFields": [],
  "_checksum": "C8V5E5gARfsauKzyYW0nMwmGj0/SPPRc8Me27T/S0K8="
}

The 2.5 payload, after updating the PHP side via Composer AND the JS side via NPM, looks like this:

{
  "data": {
    "data": "1ed5ada4-e0e0-6e9e-a430-fd47a6d79fa4",
    "editModalIsOpen": false,
    "shareModalIsOpen": false,
    "deleteModalIsOpen": false,
    "shareUrl": "http://127.0.0.1:8000/v/dxsh-2Sv3DW",
    "doneCtaMustRedirectToOverview": false,
    "formName": "video",
    "_checksum": "C8V5E5gARfsauKzyYW0nMwmGj0/SPPRc8Me27T/S0K8=",
    "video": {
      "title": "Dies ist ein Test",
      "videoOnlyPresentationpageTemplate": "1ed5567e-d2bf-6a90-9bee-fd4a081f390a",
      "_token": "5b8da170bb4638ed4b00e0543.uOOhAZNAoWW_jx71kKTQLVOoT8TIQjyD4EQO_cre49g.3IDRU9F39TPw4nmF0d2ybmvJN4uPLEi7uhNWtp7ojIjovMRJ5AHOFOD8Sw"
    },
    "isValidated": false,
    "validatedFields": []
  },
  "childrenFingerprints": {
    "live-1045461726-0": ""
  },
  "args": {}
}

Symfony UX on the backend handles this payload without issues and returns the updated HTML. However, now the client side runs into

Uncaught (in promise) DOMException: Failed to execute 'replaceChild' on 'Node': The node to be replaced is not a child of this node.
    at http://127.0.0.1:8000/build/app.js:1350:21
    at Array.forEach (<anonymous>)
    at executeMorphdom (http://127.0.0.1:8000/build/app.js:1342:19)
    at Component.processRerender (http://127.0.0.1:8000/build/app.js:1856:7)
    at _callee2$ (http://127.0.0.1:8000/build/app.js:1810:26)
    at tryCatch (http://127.0.0.1:8000/build/app.js:157:1357)
    at Generator.<anonymous> (http://127.0.0.1:8000/build/app.js:157:4174)
    at Generator.next (http://127.0.0.1:8000/build/app.js:157:2208)
    at asyncGeneratorStep (http://127.0.0.1:8000/build/app.js:158:103)
    at _next (http://127.0.0.1:8000/build/app.js:159:194)

which is triggered by https://github.com/symfony/ux/blob/ecf124f1f219135990f625903d58e79d250f7a93/src/LiveComponent/assets/dist/live_controller.js#L1197

weaverryan commented 2 years ago

Glad you got this much closer when you updated the deps completely. The code causing the error is, indeed, part of a whole new system from 2.5 related to child components. Do you have a child component setup - where you render a component inside of another? And is it possible that, when a parent component Re-renders, one or more of the children is removed/not present anymore (this is a valid thing to do - just trying to read into the error and find the cause)?

manuelkiessling commented 1 year ago

@weaverryan Ok, so I could solve it. The cause, however, is either rather curios, or I'm overlooking something obvious.

The problem indeed occured if one Live component contained another Live component. But only if the outermost DOM node of the "inner" Live component is not a div.

Thus, this works:

<div
        {{ attributes }}
        {% if this.shouldPoll %}
            data-poll
        {% endif %}
>
    {% if this.duration is not null %}
        {{ this.duration }}
    {% else %}
        ...
    {% endif %}
</div>

while the following results in the aforementioned Failed to execute 'replaceChild' on 'Node' error:

<span
        {{ attributes }}
        {% if this.shouldPoll %}
            data-poll
        {% endif %}
>
    {% if this.duration is not null %}
        {{ this.duration }}
    {% else %}
        ...
    {% endif %}
</span>

I also tried the p, aside, and footer tags for good measure - nope, only div does it. That was not the case with the 2.4 version of Live Components.

As far as I can see, neither the docs at https://symfony.com/bundles/ux-twig-component/current/index.html nor those at https://symfony.com/bundles/ux-live-component/current/index.html say anything about "allowed" or "disallowed" root DOM nodes in the templates.

However, at least the UX Twig Component docs use not only div elements in the examples, while the UX Live Component doc examples seem to stick to only div.

Maybe this limitation only applies for component-in-component situations?

weaverryan commented 1 year ago

Thanks for the clarification! It's definitely a bug - there is no limitation. But we're doing some fancy things in the latest version with the "outer tag" and child components, and apparently there is a problem with some of that. I'll check into it more deeply.

weaverryan commented 1 year ago

See #537 for the fix!

kbond commented 1 year ago

See https://github.com/symfony/ux/issues/537 for the fix!

That's this issue

weaverryan commented 1 year ago

Booo! See #593