The DoubleRenderError Trap: Rendering a Rails View to PDF

A design that passes a quick manual test and raises AbstractController::DoubleRenderError on every single request once it meets a real Rails app — and the one-line Rails internals detail that explains why.

The design that looks reasonable

Say you want a controller action's own HTML, as a string, so you can convert it to a PDF. The action hasn't run yet when a before_action fires, so a natural-looking approach is: build a second instance of the same controller, copy the request/response/params onto it, call the action method on that copy directly, copy its instance variables back onto the real controller, then render_to_string the view using those variables:

def html_to_pdf
  shadow = self.class.new
  shadow.request  = request
  shadow.response = response
  shadow.params   = params
  shadow.send(action_name)

  shadow.instance_variables.each do |ivar|
    instance_variable_set(ivar, shadow.instance_variable_get(ivar))
  end

  html = render_to_string(template: "#{controller_name}/#{action_name}")
  send_data convert_to_pdf(html), disposition: "attachment"
end

Called directly against a bare ActionController::Base instance in an isolated script, this works. Run it as a real before_action against a real Rails dispatch, and send_data raises AbstractController::DoubleRenderError — every single time — before it ever gets a chance to send anything.

Why: response= is not a plain setter

ActionController::Metal#response= is documented, in Rails' own source, as:

# Assign the response and mark it as committed. No further processing will
# occur.
def response=(response)
  set_response!(response)

  # Force `performed?` to return true:
  @_response_body = true
end

It isn't a generic attribute writer. Assigning a response is Rails' own signal that this controller instance should be treated as already-rendered — @_response_body = true is set unconditionally, the instant you assign it. shadow.response = response in the design above does exactly that to the shadow instance. Harmless on its own — the shadow is thrown away — except for the very next step:

shadow.instance_variables.each do |ivar|
  instance_variable_set(ivar, shadow.instance_variable_get(ivar))
end

shadow.instance_variables includes every ivar Rails itself uses internally, not just the ones the action set — @_response_body among them, now true. Copying it back onto the real controller sets its @_response_body to true too. performed? (response_body || response.committed?) now returns true on the real controller, before send_data ever runs. And ActionController::Rendering#render — which send_data calls internally — starts with exactly one guard:

def render(*args)
  raise ::AbstractController::DoubleRenderError if response_body
  super
end

response_body is already truthy. The error fires immediately, on every request, with a message about calling render/redirect twice — even though, from the action's own code, it looks like render was never called at all.

Why a quick test doesn't catch it

Call the method directly against a bare controller instance — no real request dispatch, no callback chain — and nothing ever sets performed? in the first place, because nothing in that harness exercises Rails' actual callback/render machinery closely enough to trip the guard. The bug is specifically about what response= does inside a real ActionController::Metal object mid-dispatch; a script that merely calls the method in isolation never puts the object in the state where that matters. It takes a real ActionDispatch request — an integration test, or an actual HTTP request — to surface it at all.

There's a second, quieter problem with the same design, visible without needing DoubleRenderError at all: the shadow controller never ran any of the real controller's other callbacks. A before_action :set_record declared elsewhere in the chain never touches the shadow — so whatever instance variable it would have set is simply absent when the view renders, silently, with no error to point at the cause.

The fix: around_action, not before_action

The actual problem being solved is sequencing: "act after the action runs" from a hook that, as a before_action, runs before it. An around_action solves that directly — yield runs the rest of the callback chain and the real action, on the real controller instance, in its normal place, with every other callback and instance variable exactly as it would be for an ordinary request:

def html_to_pdf
  return yield unless request.format == :pdf

  original_format = request.format
  request.format = :html   # so Rails' own implicit render resolves the .html template,
  begin                    # not a .pdf one that doesn't exist
    yield
  ensure
    request.format = original_format
  end

  html = render_to_string(template: "#{controller_name}/#{action_name}")
  pdf_bytes = convert_to_pdf(html)

  send_file_headers!(disposition: "attachment", filename: "export.pdf")
  self.status = 200
  self.response_body = pdf_bytes   # not send_data/render — see below
end

Forcing request.format to :html around the yield matters on its own: without it, Rails' implicit-render machinery tries to resolve a template for whatever format the request actually came in as — .pdf — finds none, and raises ActionController::UnknownFormat before the action's own render ever gets a chance to happen. That discarded render is intentional; the real HTML capture is the explicit render_to_string call afterward, once format is restored and every instance variable the real chain set is in place.

One more wrinkle: the discarded yield render already set performed? to true on the real controller (this time correctly — it rendered the real template), and self.response_body = nil does not clear it back (Rails' own response_body= only clears the Rack response body in the falsy branch, not the internal @_response_body ivar performed? actually reads) — so a second call to render/send_data would still raise DoubleRenderError, correctly this time. The way out is the same either way: don't call render/send_data again at all. Setting self.response_body = directly (as above) sets the Rack response body and the ivar together, without going through render's guard a second time.

Further reading