Skip to content

[LiveComponent] Add file downloads from a LiveAction - #3761

Merged
smnandre merged 1 commit into
symfony:3.xfrom
smnandre:sa/live-component-downloads
Aug 26, 2026
Merged

[LiveComponent] Add file downloads from a LiveAction#3761
smnandre merged 1 commit into
symfony:3.xfrom
smnandre:sa/live-component-downloads

Conversation

@smnandre

Copy link
Copy Markdown
Member
Q A
Bug fix? no
New feature? yes
Deprecations? no
Documentation? yes
Issues Fix #1516
License MIT

TL;DR;

Live Components can now trigger a file download from a LiveAction without losing their state: the download happens with the re-render, so anything the action changed is still applied on the page:

  • LiveResponse::downloadUrl() points the browser at a URL it downloads on its own;
  • LiveResponse::downloadFile() sends the file with the response, for content no URL can serve.

Why

Returning a file from a LiveAction has no supported answer today.

The documented workaround is to redirect to a dedicated route, which replaces the render. The download works, but the component never updates.

This picks back up on #2483, which stalled after some time. That PR returned a BinaryFileResponse from the action, and while writing tests for it I hit the reason it could not work: a binary response has nowhere to carry data-live-props-value.

Every LiveProp the action changed was lost, silently.

#[LiveAction]
public function export(): BinaryFileResponse
{
    ++$this->exportCount;                              // lost
    $this->lastExportedAt = new \DateTimeImmutable();  // lost

    return new BinaryFileResponse($file);
}

So the file has to come with the render, not instead of it.

This PR offers two ways to do that.

Introducing LiveResponse

Two ways to get the file to the browser.

Point the browser at a URL: LiveResponse::downloadUrl()

Argument Accepts Notes
$url string Any URL the browser can fetch. Required.

The action returns a URL. The component renders as usual. The browser downloads the file on its own.

#[LiveAction]
public function export(UrlGeneratorInterface $urlGenerator): LiveResponse
{
    ++$this->exportCount;              // kept: the component still renders

    return LiveResponse::downloadUrl($urlGenerator->generate('app_report_download'));
}

Nothing magic here. The response is a normal render plus one header.

The download is a native one: no memory used, a progress bar, range requests, resuming. And the URL is yours, so you can access-control it and log it.

Use this whenever a route can serve the file. It covers most cases, pre-signed S3 links included.

I believe this is the one we should point people to first.

Send the file with the response: LiveResponse::downloadFile()

Argument Accepts Notes
$content string, \SplFileInfo, resource, \Closure A string is the contents, never a path. A closure echoes them or returns an iterable.
$filename string Required, except with an \SplFileInfo: its basename is used.
$contentType string Defaults to application/octet-stream. Never guessed from the content.
$size int Deduced from a string and an \SplFileInfo. Pass it for a stream or a closure to get a Content-Length, and a progress bar.

Sometimes no URL can serve the content. The action builds it, and exposing it would mean storing it first. That is what most requests on this topic are about.

#[LiveAction]
public function export(): LiveResponse
{
    ++$this->exportCount;

    return LiveResponse::downloadFile($this->buildCsv(), 'report.csv', 'text/csv');
}

The response then carries both the render and the file, one after the other, with a header saying where to cut.

Everything but a string is streamed, so the file never sits in memory on the server.

This one will not please everyone. The browser buffers the file before saving it. It fits reports and exports, not huge archives. That is why the docs point to downloadUrl() first.

It does work though, and the format can grow. The offset is known before the body is read. So the client could later stream it with a reader instead of buffering, report progress, or write straight to disk once the File System Access API is everywhere. None of that touches the server side.

How it works

LiveComponentSubscriber reads the directive from getControllerResult() in onKernelView. LiveResponse deliberately does not extend Response: if it did, onKernelView would never run and there would be no render at all, which is the original problem.

For downloadUrl(), the render is untouched and carries an X-Live-Download-Url header.

For downloadFile(), the file is appended to the HTML in the same body, with the byte offset where the HTML ends:

Content-Type: application/vnd.live-component+html
X-Live-Html-Length: 581
X-Live-Download-Filename: r%C3%A9sum%C3%A9.csv
X-Live-Download-Type: text/csv
Content-Length: 592                      (when the size is known)

<div data-live-props-value="…">…</div>····raw file bytes····

Length-prefixed rather than multipart: the server knows both sizes when it writes, so an offset says strictly more than a delimiter, with no escaping to worry about, and it still works with a global Content-Length.

The frontend splits on bytes and decodes only the HTML side, so a file that is not valid UTF-8 survives. The filename is percent-encoded in a header, which sidesteps the RFC 5987 Content-Disposition handling that was the blocking bug in #2483.

The Content-Type stays the usual vendor type, so the existing frontend check is untouched.

Notes

Guard rails

A LiveResponse can only be returned from a LiveAction or a LiveListener, over POST.

Returning one from the default action throws: that action runs on every re-render, so a component with data-poll would fire a download every few hundred milliseconds. Returning one from a GET throws too, since a GET is meant to be replayable by prefetching or crawling.

This is not only our rule. Browsers already treat a download as something the user asked for: they block automatic ones and prompt before a second file in a row. Our trick, a hidden link clicked from JS, works today because a click started the whole thing, but it is not something a spec promises us. If that side moves, it will get stricter, not looser. So a download that does not follow a real user action may work today and stop working tomorrow, whatever we do here.

downloadFile() also rejects a $size that contradicts the real one (an inexact Content-Length truncates the response), a $contentType containing a line break, a missing filename, and an unsupported content type.

An action returns either a LiveResponse or a redirect, never both, so that combination is impossible by construction rather than something to detect.

Design decisions

  • Batch: sub-requests are not rendered, so BatchActionController carries the directive up to the final render. Unlike a redirect, a download does not interrupt the batch; the last one wins.
  • A string is never a path. Detecting a path with is_file() would make behaviour depend on the filesystem and would turn any user-supplied string into an arbitrary file read. The path goes through \SplFileInfo, so the type carries the intent.
  • No mime guessing: symfony/mime is not a dependency, so $contentType defaults to application/octet-stream rather than being inferred.
  • The streamFile() design and the filename handling come from @kbond's work on [LiveComponent] Add support for downloading files from LiveActions (Experimental) #2483.

Next steps

Documentation is included in this PR.

A demo for the website is almost ready and will follow in its own PR.

Same with the apps/e2e tests: getting those to run locally gave me enough dependency headaches that I left them out of this one 😅

@carsonbot carsonbot added Documentation Improvements or additions to documentation Feature New Feature LiveComponent Status: Needs Review Needs to be reviewed labels Aug 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📊 Packages dist files size difference

Thanks for the PR! Here is the difference in size of the packages dist files between the base branch and the PR.
Please review the changes and make sure they are expected.

FileBefore (Size / Gzip)After (Size / Gzip)
LiveComponent
live_controller.d.ts 7.54 kB / 1.96 kB 7.72 kB+2% 📈 / 2 kB+2% 📈
live_controller.js 82.79 kB / 18.4 kB 84.46 kB+2% 📈 / 18.86 kB+3% 📈

@Kocal Kocal left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👏🏻

@carsonbot carsonbot added Status: Reviewed Has been reviewed by a maintainer and removed Status: Needs Review Needs to be reviewed labels Aug 22, 2026
@Kocal

Kocal commented Aug 22, 2026

Copy link
Copy Markdown
Member

Let's document that emit() and dispatchBrowserEvent() from a removing action are silently ignored (LiveComponentSubscriber.php:364-368)?

@smnandre

smnandre commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

Let's document that emit() and dispatchBrowserEvent() from a removing action are silently ignored (LiveComponentSubscriber.php:364-368)?

This was not intended for this package, I suppose ?

(they work here)

(@Kocal )

@Kocal

Kocal commented Aug 26, 2026

Copy link
Copy Markdown
Member

Oh, yeah sorry it was for #3773

You can merge whenever you want, a small rebase before will be appreciated 🙂

@smnandre
smnandre merged commit 9c96e0c into symfony:3.x Aug 26, 2026
32 checks passed
smnandre added a commit that referenced this pull request Aug 31, 2026
…omponent deletion (smnandre)

This PR was merged into the 3.x branch.

Discussion
----------

[LiveComponent] Add LiveResponse::remove() to trigger component deletion

| Q              | A
| -------------- | ---
| Bug fix?       | no
| New feature?   | yes
| Deprecations?  |  no
| Documentation? | yes
| Issues         | Fix #...
| License        | MIT

> [!WARNING]
> Based on #3761, which introduces `LiveResponse`. The diff shown here includes that
> PR's commit until it is merged. Only the second commit belongs to this one.

--

A `LiveAction` can already ask the browser for a download alongside the re-render. It
cannot ask for the opposite: that the component leave the page.

```php
#[AsLiveComponent]
class NotificationBanner
{
  use DefaultActionTrait;

  #[LiveProp]
  public Notification $notification;

  #[LiveAction]
  public function dismiss(NotificationRepository $repository): LiveResponse
  {
      $repository->markAsRead($this->notification);

      return LiveResponse::remove();
  }
}
```

The server answers `204` with `X-Live-Remove: 1` and skips the render altogether,
`PreReRender` hooks included, since nothing will be shown.

Nothing is deleted server-side. This ends the component on the page, and says nothing
about your data.

### Tearing down before removing

On the client the component is torn down **first**, before the element goes: polling
stops, it leaves the registry, its props are stripped from the element, and it refuses
any further request.

That ordering is what makes the removal safe rather than merely tidy. The element keeps
its event listeners until it is actually dropped, so without the teardown a click during
that window would send an action for a component that is already gone.

### Animating it out

The element carries a `data-live-removing` attribute on its way out, and is only dropped
once whatever the page animates on it has finished:

```css
.notification {
  transition: opacity 300ms, translate 300ms;
}

.notification[data-live-removing] {
  opacity: 0;
  translate: 2rem 0;
}
```

Nothing to declare beyond the CSS. With no animation on `[data-live-removing]` there is
nothing to wait for and the element goes on the next frame, so the default costs a frame
and no configuration. An endless animation is ignored, as it would keep the element on
the page forever.

### Notes

`LiveResponse::remove()` replaces the render rather than riding alongside it, unlike the
download responses, so a property the action changed is never displayed. Like them, it
can only be returned from a `LiveAction` or a `LiveListener`, over POST.

Commits
-------

0a6311f [LiveComponent] Add LiveResponse::remove() to trigger component deletion
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Documentation Improvements or additions to documentation Feature New Feature LiveComponent Status: Reviewed Has been reviewed by a maintainer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[LiveComponent] Live Actions cannot handle file downloads

3 participants