[Rendering] Custom renderer support #234 #241
Replies: 2 comments 2 replies
-
|
Hi @anuj-pal27 This looks amazing! I really like the addition of the centralised
I agree that treating the result of the renderer block as the response body is probably the optimal approach. What if we also allowed to call
Rage relies on dynamic code generation a lot. Is there a way to avoid using
We would also need to support objects that respond to class ERBRenderer
def initialize
@templates_cache = {}
end
def call(path)
@templates_cache[path] ||= ERB.new(Rage.root.join("app/views/{path}.html.erb").read)
@templates_cache[path].result(binding)
end
end
# register
Rage.configure do
config.renderer :erb, ERBRenderer.new
end |
Beta Was this translation helpful? Give feedback.
-
|
hii @rsamoilov, I've gone through all the points and here's what I'm thinking:
Simple case (Phlex, CSV) — block just returns a string: config.renderer(:phlex) do |object|
headers["content-type"] = "text/html"
object.call # returns a string, framework puts it in body
endAdvanced case (SSE) — block calls render directly: config.renderer(:sse) do |stream|
render sse: stream # render handles everything itself
endBoth cases are handled by this pattern: result = dynamic_method_name(*args, **kwargs)
unless @__rendered
# render was NOT called inside the block
# so treat the return value as the body
@__rendered = true
@__body << result.to_s
@__status = status || 200
end
# if @__rendered is already true here,
# render was called inside the block — we do nothingSo the default behavior stays the same — block returns a string, framework handles the rest. But now streaming renderers can also call
# already exists in lib/rage/controller/api.rb
def define_dynamic_method(block)
name = @@__dynamic_name_seed.next.join
define_method("__rage_dynamic_#{name}", block)
endRage already uses this pattern for So instead of this: # what we had before — instance_exec on every request
result = instance_exec(*args, **kwargs, &block)We can do this: # convert block into a named method ONCE at boot time
dynamic_method_name = RageController::API.define_dynamic_method(block)
# then generate render_phlex that just calls that named method directly
RageController::API.class_eval <<~RUBY
def render_#{name}(*args, status: nil, **kwargs)
result = #{dynamic_method_name}(*args, **kwargs)
# ...
end
RUBYThe complexity is low since this pattern already exists in the codebase — we're just applying it to custom renderers.
I'll update the proposal with all of this once I get your thoughts on the callable objects context question! |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Design Proposal: Custom Renderers
Public API
I'll keep the interface as proposed in the issue. The one addition I'd make is an optional
status:keyword on every generatedrender_<name>method, to stay consistent with howrender json:andrender plain:already work.Registration looks like this:
And usage in a controller:
The
status:keyword is handled by the generated method itself — it never gets passed into the renderer block. So your renderer block stays clean and focused, and you can still set a custom status code whenever you need one.How renderer procs will be stored
A
@renderershash onRage::Configuration, keyed by renderer name (as a symbol), holding the generated dynamic method name:How controller methods will be defined
At the end of
Rage.configure, the framework callsconfig.__finalize. That's where we loop through the registered renderers and define the corresponding methods onRageController::API.Instead of using
instance_execon every request, we follow the same pattern Rage already uses forbefore_action,after_action, andrescue_from— convert the block into a named method once at boot time usingdefine_dynamic_method, then generate therender_<name>method as a string usingclass_evalthat just calls that named method directly:This follows the exact same pattern that
render json:andrender plain:use — guard against double renders via@__rendered, append to@__body, resolve status symbols throughRack::Utils::SYMBOL_TO_STATUS_CODE, and default to200.Execution context
Since we use
define_dynamic_methodto convert the block into a named method onRageController::API, the block naturally runs withselfbeing the controller instance. So inside any renderer block, you have access to:headers— to set content typerequest— to inspect incoming headers (important for Inertia'sX-Inertiadetection)params,cookies,session— if you ever need themThis is the same approach the codebase already uses for
before_actionandafter_actionblocks, so there's nothing new here mechanically.Return value semantics
Whatever the renderer block returns becomes the response body by default. The generated method checks
@__renderedafter the block runs — not before. This means two cases are supported naturally:Simple case (Phlex, CSV) — block just returns a string:
Advanced case (SSE) — block calls render directly:
Both cases handled by this pattern:
So the default behavior stays the same — block returns a string, framework handles the rest. But now streaming renderers can also call
renderdirectly inside the block without hitting a double render error.Edge cases
Duplicate names — fail at registration time with a clear
ArgumentError. Better to blow up at boot than to silently overwrite a renderer:Collision with existing methods — before calling
class_eval, we checkRageController::API.method_defined?(:"render_#{name}"). This prevents accidentally clobbering any current or future framework methods.Code reloading in development — this isn't really a concern.
RageController::APIlives inside the gem, not underapp/, so Zeitwerk never touches it. Methods defined on the base class at boot time survive reloads. User-defined subclasses likeArticlesControllerdo get reloaded, but they inheritrender_phlexfrom the base class through normal Ruby inheritance — no special handling needed.No block given — raise
ArgumentErrorimmediately at theconfig.renderercall.Proc returns nil —
result.to_sgives"", which means an empty body with status200. That's consistent with howrender plain: nilbehaves today.Files to change
The only file that needs to be touched is
lib/rage/configuration.rb— we add therenderermethod, the@renderershash, and theclass_evalloop inside__finalize. Nothing in the router, code loader, or middleware needs to change.Tests should cover the main scenarios: registering a renderer, calling it from a controller, the
status:keyword, duplicate name errors, method name conflicts, making sure the block has access to controller context, nil return values, SSE rendering viarenderinside the block, and that subclasses properly inherit the generated methods.Happy to adjust anything here based on feedback. Let me know if anything needs more discussion and I'll get started on the implementation once this is approved.
Inertia.js compatibility
Since renderer procs have full controller context, the Inertia use case works naturally:
Controllers just call
render_inertia MyComponent, props: { ... }— clean and simple.Happy to adjust anything here based on feedback. Let me know if anything needs more discussion and I'll get started on the implementation once this is approved.
Beta Was this translation helpful? Give feedback.
All reactions