Replace `to_partial_path` calls with a more abstract `to_renderable`

The to-partial-pathable duck type enables the rendering of otherwise non-renderable objects such as models. Models don’t respond to render_in, but they can be associated with a renderable partial through the to_partial_path method, which is why you can render them like this:

<%= render @article %>

I would like to propose we replace to_partial_path calls with more abstract to_renderable calls.

The default implementation of to_renderable on models could return arguments for render, delegating to the original to_partial_path methods, i.e.

def to_renderable
  { partial: to_partial_path }
end

Adding this layer of abstraction enables the use of other renderables such as ViewComponent and Phlex components.

My overriding to_renderable, for example, this Article model could declare it is to be rendered in the Views::Articles::Article component. A more abstract to_renderable on the ApplicationRecord could derive this component from the name of the model.

class Article < ApplicationRecord
  def to_renderable
    Views::Articles::Article.new(self)
  end
end

I don’t know if there are any other renderable-object based view libraries besides ViewComponent and Phlex, but this change would make it easier to use any alternative view layer while maintaining ActionView defaults.

I’m happy to work on this, but I wanted to get some feedback on the idea before doing anything else.

1 Like

Models don’t respond to render_in

Could models declare their own render_in method to integrate with the changes introduced in rails/rails#36388?

I believe that calling render @article will call Article#render_in if it responds to render_in:

class Article < ApplicationRecord
  def render_in(view_context, &block)
    view_context.render Views::Articles::Article.new(self, &block)
  end
end

That’s a great point. In which case, I think we should update calls to to_partial_path to instead pass the model as a renderable to render directly. render_in on models can then be defined as

def render_in(view_context, &block)
  view_context.render(partial: to_partial_path, &block)
end

I haven’t tracked down all the call sites, but I know ActionText and some of the turbo streams stuff call to_partial_path directly.

1 Like

I think that’s a great idea! We’d probably want to pass the instance as object: self by default, in addition to the partial: to_partial_path option.

The ActionView::Renderer#render_partial_to_object method implementation accounts for the various argument types and edge cases.

2 Likes

That isn’t as easy as this.

Models can be anything that confirm the Active Model API, what you are proposing here is to expand the Active Model API to also require classes to implement an render_in method. I think this changes too much the API to push presentation concerns inside the Model layer. Note that before, to_partial_path would only return a string. With your proposal the model would be responsible for rendering itself, which isn’t a responsibility of the Model layer usually.

This same problem is also existent in the original proposal, but I’m more inclined to it since it has no knowledge about the view_context of the render methods of views, and it is only responsible to wrap itself in the presentation object.

1 Like

I think the hooks should not be on the model layer but on the presentation layer.

The Article example can continue to return "articles/article" for the partial path, but ActionPack could call out to a view layer component that is registered with it to map that to the actual object that would do the rendering. After all "articles/article" is basically an (app-private) resource locator to locate the resource that can handle the rendering for an article. The mapping of that locator to the actual renderer is the responsibility of the presentation layer.

If Phlex is registered and responds with “Oh, I know how to render articles/article, use my Views::Articles::Article class to do the rendering”, then ActionPack can use that. If no custom view layer has a renderer registered for that partial path then ActionPack can use its current partial rendering mechanism.

I’m inclined to agree with Rafael as well as Joel’s original proposal. The mechanics of render_in feel like they belong squarely in the view layer. All that to_renderable would do is return a representational object related to the model, which the view layer can then render via render_in. What this component object does in particular, and how the view layer renders it, never becomes the direct concern of the model.

Where would you add that hook @Ufuk_Kayserilioglu? It should also be possible to have some resources rendered from ActionView while others are rendered from ViewComponent, etc. because migrating anything like this is a long process.

The one thing I don’t like about to_renderable in my original post is it doesn’t always return a renderable object. In the case of rendering a partial, it returns a hash of arguments that can be passed into the render method only because the render method accepts the partial: keyword argument as a Hash.

One option might be to have some kind of partial renderer object returned instead:

def to_renderable = PartialRenderer.new(to_partial_path)

Where renderable is something like this

class PartialRenderer
  def initialize(partial_path)
    @partial_path = partial_path
  end

  def render_in(view_context, &)
    view_context.render(partial: @partial_path, &)
  end
end