Skip to content

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

Open
smnandre wants to merge 1 commit into
symfony:3.xfrom
smnandre:sa/live-component-downloads
Open

[LiveComponent] Add file downloads from a LiveAction#3761
smnandre wants to merge 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% 📈

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: Needs Review Needs to be reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[LiveComponent] Live Actions cannot handle file downloads

2 participants