Stream with send_data from controller doesn't work

How do you handle sending back files from turbo_stream?

 respond_to do |format|
        format.turbo_stream{  send_data service.xls_string, filename: service.file_name, type: "application/vnd.ms-excel" }
end

I’m not sure this is possible. What are you trying to do?

We have our legacy code which opens modal and submit form to download generated XLS file.

Now I have universal (bootstrap) modal which is driven by Turbo frame and stream which works so far ok but not for the send_request.

To cleanup the view code I have helpers to render things.

  def link_to_modal(name = nil, options = nil, html_options = nil, &block)
    default_options = {data: {"turbo-frame": :modal_containers}}.deep_merge(options || {})
    link_to(name, default_options, html_options, &block)
  end
 def render_turbo_modal(id, label:, format: nil, turbo: {}, modal: {}, &block)
    formats = {html: "text/html", turbo_stream: "text/vnd.turbo-stream.html"}
    request_format = formats.fetch(format, nil) || request.format.to_s

    case request_format
    when "text/html"
      turbo_frame_tag(:modal_containers) { turbo_modal_frame(id, label: label, turbo: turbo, modal: modal, &block) }
    when "text/vnd.turbo-stream.html"
      turbo_stream.update(:modal_containers) { turbo_modal_frame(id, label: label, turbo: turbo, modal: modal, &block) }
    end
  end
 def turbo_modal_frame(id, label:, turbo: {}, modal: {}, &block)
    turbo_defaults = {id: id}.merge(turbo)
    modal_defaults = {title: label, id: "#{id}_modal", size: nil,
                      data: {controller: "application--modal", auto_open: true}}.deep_merge(modal)

    render("application/modal_frame", turbo: turbo_defaults, modal: modal_defaults, &block)
  end

Have you found a solution ?

I would really like to send a pdf through turbo_stream to be able to update UI when file is rendered.

At the moment I am trying to populate the PDF file made with Grover (Puppeteer) into a dataset element and then have file-saver.js (file-saver - npm) convert it back into a file but the file is twice as big as the original PDF and not recognized by my PDF reader. Would someone have an idea ?

my controller : (HTML route workes perfectly fine)

def display_pdf_att
    authorize @resume

    @pdf = print_to_pdf(resume_record: @resume)
    pdf_first_page_to_jpg(pdf_file: @pdf, resume: @resume, width_target: "150")

    respond_to do |format|
      format.html { send_data(@pdf, disposition: "attachment", filename: "resume.pdf", type: "application/pdf")}
      format.turbo_stream
    end
    
  end

my view display_pdf_att.turbo_stream.erb :

<%= turbo_frame_tag "resume-attachment-JS-creator" do %>
  <div data-controller="saver" data-pdf-content="<%= @pdf %>"></div>
<% end %>

And finally the Stimulus controller “saver” :

import { Controller } from "@hotwired/stimulus"
import { SaveAs } from 'file-saver'

export default class extends Controller {

  connect() {
    var blob = new Blob([this.element.dataset.pdfContent], {type: "application/pdf", responseType:"arraybuffer"})
    saveAs(blob, "resume.pdf")
  }

}

Ok found the solution : need to Base64 encode my @pdf instance variable instead of printing it directly as a data attribute (probably messes up with the page layout ?)

<%= turbo_frame_tag "resume-attachment-JS-creator" do %>
  <div data-controller="saver" data-pdf-content="<%= Base64.encode64(@pdf) %>"></div>
<% end %>

or better

<%= turbo_stream.update "resume-attachment-JS-creator" do %>
  <div data-controller="saver" data-pdf-content="<%= Base64.encode64(@pdf) %>"></div>
<% end %>

and the Stimulus controller

var b64string = this.element.dataset.pdfContent

    var byteCharacters = atob(b64string);
    var byteNumbers = new Array(byteCharacters.length);
    for (let i = 0; i < byteCharacters.length; i++) {
        byteNumbers[i] = byteCharacters.charCodeAt(i);
    }
    var byteArray = new Uint8Array(byteNumbers);

    var blob = new File([byteArray],"resume.pdf",{type: "application/pdf"})
    saveAs(blob, "resume.pdf")

I got inspiration on how to decode from Base6’ here : Creating a BLOB from a Base64 string in JavaScript - Stack Overflow

I don’t see any performance problem but my PDFs are about 100KB.

Not sure this is possible whith bigger files without causing any browser issue.
Overall seems a bit hacky but well …

Embedding a PDF in the HTML does seem a little odd. If using with Turbo, turbo could cache your pages with embedded PDF (unless you are cleaning up the base64 encoded PDF before caching).
Seems it would be better to use a URL instead of a blob.

import { Controller } from "@hotwired/stimulus"
import { SaveAs } from 'file-saver'

export default class extends Controller {
  connect() {
    saveAs(this.element.dataset.pdfUrl, "resume.pdf")
  }
}

I need to study the caching a bit more but either with default behavior or no-cache when I use history to travel pages, I end up in my SPA in the original state. So I can’t see the turbo-frame containing the file. I need to click again on a specific resume to pull back page and attached downloading frame. (which is then empty)

Also I clean up the string in the Stimulus controller after the file is created so the Turbo-frame is left empty after the file is saved.

I will try to test all that but my solution avoids a round trip to the back-end and then save me the network latency. So I am wondering if relying on how things would be done in a different front end framework like React is still the right way to go …

I will definitely monitor all this …

If concerned about 2nd round trip, and already using a stimulus controller, I might consider capturing the get URL before sending it to the server and pass the URL into saveAs.

Is this still the best known way? Has no one else found a way to get the benefits of a turbo post request (such as disabling the button) AND to treat the response in light of content-disposition header (such as save the file to the Downloads directory)?

It is probably not the best solution.
Though, afaik the browser is unable to determine when a file is downloaded. You can still both call a route in the Stimulus controller to download a file (then header would be that of file, instead of a Turbo Stream I guess, or just save the file as advised by @tleish ) and also disable your button in the same go.
Though in my case, I want to display something when the file is !!successfully!! downloaded. So I still use this solution to pass the file into a random element dataset… Very ugly I admit

My scenario was that of generating a report, which took more than a few seconds. I wanted to use a broadcast stream to force the download, when it was done. Instead, when the user clicks the “Download” (really a “Generate Report” button), I both enqueue the job AND display a progress modal. The job broadcasts progress updates to the modal, and when complete, also includes a link to a file. The file is made available between my background job (where it originated) and the server (following that link I just put in the modal) via a database table (used expressly for sharing files between background jobs and web server).

It has a pleasing effect.

I just wish it were easier.