ux icon indicating copy to clipboard operation
ux copied to clipboard

[Bug] File upload lost in subsequent AJAX requests after LiveComponent re-render

Open woweya opened this issue 3 weeks ago • 5 comments

When using LiveComponent with file upload fields, after a validation error causes the component to re-render, the file input loses its binding to the correct LiveAction. The file is then sent in the next unrelated AJAX request (e.g., a text field change) instead of the final form submission.

Environment

  • Symfony: 7.2
  • Symfony UX LiveComponent: [2.x]
  • PHP: 8.2

Steps to Reproduce

  1. Create a LiveComponent with a form containing:

    • Text fields
    • A file upload field (DocumentType or similar)
    • Validation constraints on the file (e.g., mime type, size)
  2. Submit the form with an invalid file (triggers validation error)

  3. Component re-renders showing validation errors

  4. Upload a new valid file after re-render

  5. Modify a text field (triggers AJAX request for live update)

  6. Submit the form

Expected Behavior

  • The file should be sent only with the final form submission (LiveAction save)
  • Text field changes should not include the file in their AJAX payload

Actual Behavior

  • After re-render, the file is sent with the first AJAX request after selecting it (e.g., text field blur/change event)
  • The final form submission does not include the file
  • The file input appears to be "consumed" by the wrong request

STEP 0: Submit form → Validation Error (file invalid) → Component re-renders STEP 1: Select new file in file input STEP 2: Modify a text field → AJAX request fires → FILE IS SENT HERE (wrong!) STEP 3: Click Submit → AJAX request fires → FILE IS MISSING

Code Example

LiveComponent: In my php file:

#[AsLiveComponent('TestLiveComponent', template: '...')]
class TestLiveComponent extends AbstractController
{
    use DefaultActionTrait;
    use ComponentWithFormTrait;

    #[LiveAction]
    public function save(Request $request): Response
    {
        $this->submitForm();
        $form = $this->getForm();
        
        // File is NULL here after re-render + new file selection
        $uploadedFile = $request->files->get('my_form_document_type')['document'] ?? null;
        // ...
    }
}

In my twig file, simply we have:

    {{ form_widget(form.document) }}

Workaround Attempted

Manually resetting file input after re-render: Did not work Using data-live-ignore on file input: Prevents re-render but breaks validation display

So, I would ask to you: Is there a recommended pattern for handling file uploads with validation errors in LiveComponent? Should file inputs be excluded from live updates and only processed on explicit form submission? Is there a way to preserve file input state across re-renders?

Thanks a lot!

woweya avatar Dec 12 '25 08:12 woweya

Maybe something related here: https://github.com/symfony/ux/pull/2628

smnandre avatar Dec 12 '25 22:12 smnandre

Re-reading your issue i'm wondering: maybe you did not follow these steps ?

Files aren't sent to the component by default. You need to use a live action to handle the files and tell the component when the file should be sent:

To send a file (or files) with an action use files modifier. Without an argument it will send all pending files to your action.

https://symfony.com/bundles/ux-live-component/current/index.html#uploading-files

smnandre avatar Dec 12 '25 22:12 smnandre

I followed the documentation as written. The only difference is that the docs use a static file input:

<input type="file" name="my_file" />

Whereas in my case I’m using Symfony Forms:

{{ form_widget(form.document) }}

Which corresponds to:


// Symfony Form Builder
$builder->add('document', FileType::class);

And, when inspecting the rendered HTML, it correctly produces:

<input type="file" name="form_document" />

Of course, I didn't include the whole twig file, but obviously this is the pattern:

<div {{attributes}}>
    {{ form_start(form) }}

         {{ form_widget(form.document) }}
         <button data-action="live#action" data-live-action-param="files|myAction">Upload</button>

    {{ form_end(form) }}
</div>

Another important difference is that the documentation examples assume a single, static file input. In my case, however, I’m dealing with a CollectionType, where multiple items each need their own file upload field. For example:


<div {{attributes}}>
    {{ form_start(form) }}

         {% for collectionItem in collection %}
              Document: {{ form_widget(collectionItem.document) }}
         {% endfor %}

         <button data-action="live#action" data-live-action-param="files|myAction">Upload</button>

    {{ form_end(form) }}
</div>

This results in multiple, distinct elements being rendered.

Looking at the UX Upload documentation and demos (e.g.https://ux.symfony.com/demos/live-component/upload ), files are data-bound using a static input like:

<label for="multiple-files" class="form-label">Multiple files:</label>
<input
    type="file"
    class="form-control-file"
    name="multiple[]"
    autocomplete="off"
    multiple
    id="multiple-files"
/>

And then handled in PHP with a LiveProp such as:

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

This approach works well when a single file input is expected (even with multiple=true). However, things become more complex when you have a collection of form items, each rendering its own FileType field inside a loop.

In this scenario, the current data-binding approach does not seem to cover the use case where multiple FileType widgets are rendered dynamically as part of a collection, and each one needs to handle uploads independently.

woweya avatar Dec 13 '25 13:12 woweya

You'd need to share some code or a reproducer, if you wish for others to have a look at your current situation.

Depending on how you implemented your collection there is plenty of reasons to not work ... or it also can be entirely non related to your code.

But I'm not sure many people would try to get/help/fix if they do not have all the information, this is my point :)

--

Side note : so your form send in POST multiple input files at once ?

I think I would split the upload and the form sending.

smnandre avatar Dec 13 '25 14:12 smnandre

Okay, I'll try to explain in detail.

Detailed scenario

We have a LiveComponent that exposes a form with:

  • Several text fields (e.g. title, description)
  • A file upload field (document) with validation constraints (MIME type, max size, etc.)
  • A save LiveAction that performs the form submit

The problem occurs with the following sequence:

  1. First submit with an invalid file → the form shows validation errors and the component is re‑rendered.
  2. After the re‑render we select a new valid file in the document field.
  3. We modify a text field (e.g. title), which triggers a Live AJAX update.
  4. In that AJAX request, the file is “consumed” and is sent along with the text‑field update.
  5. On the next submit (save), the file is no longer available in $request->files nor in the form; it is null.

Code example

FormType

// src/Form/DocumentUploadType.php
namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints as Assert;

class DocumentUploadType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('title', TextType::class, [
                'required' => true,
                'constraints' => [
                    new Assert\NotBlank(),
                    new Assert\Length(['max' => 255]),
                ],
            ])
            ->add('document', FileType::class, [
                'required' => true,
                'mapped' => false, // we also see the issue with mapped=true
                'constraints' => [
                    new Assert\NotNull(),
                    new Assert\File([
                        'maxSize' => '10M',
                        'mimeTypes' => [
                            'application/pdf',
                            'image/png',
                            'image/jpeg',
                        ],
                        'mimeTypesMessage' => 'Please upload a valid PDF or image file',
                    ]),
                ],
            ])
        ;
    }
}

LiveComponent

// src/Twig/Components/TestLiveComponent.php
namespace App\Twig\Components;

use App\Form\DocumentUploadType;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\UX\LiveComponent\Attribute\LiveAction;

#[AsLiveComponent(
    name: 'TestLiveComponent',
    template: 'components/test_live_component.html.twig'
)]
class TestLiveComponent extends AbstractController
{
    use DefaultActionTrait;
    use ComponentWithFormTrait;

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

    #[LiveAction]
    public function save(Request $request): Response
    {
        // full form submit via LiveAction
        $this->submitForm();

        $form = $this->getForm();

        // This is where the issue is visible:
        // after re-render + selecting a new file,
        // $uploadedFile is NULL
        $uploadedFile = $request->files->get('document_upload')['document'] ?? null;

        if (!$form->isSubmitted() || !$form->isValid()) {
            // re-render the form with errors
           return null;
        }

        // handle file (store, process, etc.)

        // ...
        return $this->redirectToRoute('...');
    }
}

Component template (Twig)

{# templates/components/test_live_component.html.twig #}
{{ form_start(form) }}

    {{ form_row(form.title) }}

    {# File field: no explicit data-live-ignore #}
    {{ form_row(form.document) }}

    <button
        type="submit"
        data-action="live#action"
        data-live-action-param="files|save"
    >
        Save
    </button>

{{ form_end(form) }}

Observed behavior (step by step)

  • STEP 0: Call save with an invalid file → re‑render with validation errors on document.
  • STEP 1: Select a new valid file in the document field.
  • STEP 2: Change title (or trigger a blur/change event) → Live AJAX request fires to update only that field. In this request, we can see the file being sent in $request->files, even though we are not calling the save action.
  • STEP 3: Click the Save button → new Live AJAX request with save as the LiveAction. In this second request, the file is missing: request->files->get('document_upload')['document'] is null.

So it looks like the file input is “consumed” by the first live request that happens after re‑rendering, and then it is no longer available on the final submit.


The first case I described involves a single document and file to upload.

While the case with multiple files:

Entity

Parent Entity

<?php

namespace App\Entity;

use Symfony\Component\Validator\Constraints as Assert;

class MyFormModel
{
    #[Assert\NotBlank]
    private ?string $name = null;

    /**
     * @var Document[]
     */
    #[Assert\Valid]
    private array $documents = [];

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(?string $name): void
    {
        $this->name = $name;
    }

    /**
     * @return Document[]
     */
    public function getDocuments(): array
    {
        return $this->documents;
    }

    /**
     * @param Document[] $documents
     */
    public function setDocuments(array $documents): void
    {
        $this->documents = $documents;
    }

    public function addDocument(Document $document): void
    {
        $this->documents[] = $document;
    }

    public function removeDocument(int $index): void
    {
        if (isset($this->documents[$index])) {
            array_splice($this->documents, $index, 1);
        }
    }
}

Child Entity

<?php

namespace App\Entity;

use Symfony\Component\Validator\Constraints as Assert;

class Document
{
    #[Assert\NotBlank]
    private ?string $title = null;

    #[Assert\File(
        maxSize: '5M',
        mimeTypes: ['application/pdf', 'image/png', 'image/jpeg'],
        mimeTypesMessage: 'Carica un PDF o un\'immagine (PNG/JPEG) valida.'
    )]
    private ?\Symfony\Component\HttpFoundation\File\File $file = null;

    public function getTitle(): ?string
    {
        return $this->title;
    }

    public function setTitle(?string $title): void
    {
        $this->title = $title;
    }

    public function getFile(): ?\Symfony\Component\HttpFoundation\File\File
    {
        return $this->file;
    }

    public function setFile(?\Symfony\Component\HttpFoundation\File\File $file): void
    {
        $this->file = $file;
    }
}

FormType (Parent form)

<?php

namespace App\Form;

use App\Entity\MyFormModel;
use App\Form\DocumentType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class MyFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name', TextType::class, [
                'label'    => 'Nome',
                'required' => true,
            ])
            ->add('documents', CollectionType::class, [
                'entry_type'   => DocumentType::class,
                'by_reference' => false,
                'label'        => false,
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => MyFormModel::class,
        ]);
    }
}

FormType (Document Item)

<?php

namespace App\Form;

use App\Entity\Document;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class DocumentType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('title', TextType::class, [
                'label'    => 'Titolo documento',
                'required' => true,
            ])
            ->add('file', FileType::class, [
                'label'    => 'File',
                'required' => true,
                'mapped'   => false,
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Document::class,
        ]);
    }
}

Template Component (Twig)

{% props %}

<div {{ attributes }}>
    {{ form_start(form, {
        attr: {
            'data-action': 'live#action',
            'data-live-action-param': 'files|save',
            'enctype': 'multipart/form-data'
        }
    }) }}

        <div>
            {{ form_row(form.name) }}
        </div>

        <div class="documents-collection">
            {% for documentForm in form.documents %}
                <div class="document-item">
                    {{ form_row(documentForm.title) }}
                    <div>
                        {{ form_row(documentForm.file) }}
                    </div>
                </div>
            {% endfor %}
        </div>

        <div class="mt-3">
            <button type="submit">
                Save
            </button>
        </div>

    {{ form_end(this.form) }}
</div>

In both cases, this causes file persistence issues between re-renders, or, as I mentioned, after the form is invalid and a new document is reloaded, the document isn't bound to the correct request, but is sent in the first AJAX post it receives.

Currently, I can't avoid having to upload multiple documents at once in a single form POST. So, I'd like to ask you what you mean by "I think I would split the upload and the form sending."

woweya avatar Dec 15 '25 09:12 woweya