lookbook-hq/lookbook

Support for Phlex builder-style components

willcosgrove opened this issue · 8 comments

Describe the bug

Phlex views that use a builder-style API are rendering in unbuffered mode, as though they were called from an ERB template.

To Reproduce

Steps to reproduce the behavior:

Given a pair of Phlex components defined like this:

class List < Phlex::HTML
  def template
    ul { yield }
  end

  def item(...)
    render Item.new(...)
  end
end

class Item < Phlex::HTML
  def template
    li { yield }
  end
end

And a preview defined like this:

class ListPreview < Lookbook::Preview
  def default
    render List.new do |list|
      list.item { "Hello" }
      list.item { "World" }
    end
  end
end

You will get an output that looks like this:

<ul>
  <li>World</li>
</ul>

This is because when Rails' render method is used (which gets turned into render_in), Phlex will turn the component into an "unbuffered" representation. This means that now each method that normally would have added output to the internal buffer and returned nil, will now not append anything to the internal buffer, and instead return the string it would have appended.

So in our above example the line by line evaluation looks like this:

render List.new do |list|
  list.item { "Hello" } # => "<li>Hello</li>
  list.item { "World" } # => "<li>World</li>
end

When the List component finishes it's block, it sees that it's own internal buffer has not been modified and assumes that the return value of the block is supposed to be appended to the buffer, which is why we get the final list item only.

This is how Phlex must work when it's being rendered from inside another template

<%= render List.new do |list| %>
  <li>First</li>
  <%= list.item { "Hello" } %>
  <%= list.item { "World" } %>
  <li>Last</li>
<% end %>

In this context, the buffer is being managed by ERB.

Expected behavior

My expectation was that, because I am rendering my Phlex component in a (for lack of a better term) "ruby context"— not inside of a template, that the component would behave the same way it does when I render it within other Phlex views.

It's the view of the Phlex author that render_in should remain adapted to the Rails view rendering context, and that to render Phlex components in a "ruby context" the call method should be used.

Version numbers

Please complete the following information:

  • Lookbook: 2.0.0.beta.9
  • Phlex: 1.6.1
  • Rails: 7.0.4.3
  • Ruby: 3.2.1

Hey @willcosgrove, thanks for this. I have a few questions if you don't mind - I'm not very familiar with the Phlex codebase so I'm just trying to get my head around the issue. I'm also far from an expert when it comes to things like output buffers so apologies if I'm a little slow on the pick up!

So, as a bit of background info, the render method in the Lookbook::Preview class is actually a bit of misdirection. The components do not actually get rendered there - the render method generates a hash of data, which includes the instantiated component that was passed to the render method initially.

The actual component rendering is done via a controller action, which renders this template: https://github.com/ViewComponent/lookbook/blob/5ad0219a882dc42f5ff86a2963daccb1d440c41e/app/views/lookbook/previews/preview.html.erb#L1-L5

The @render_args variable here contains the hash of data generated by the Lookbook::Preview#render method.

So, the instantiated component here is actually being rendered in an ERB template... but I guess the issue is that the block itself is not rendered as ERB so the list.item calls are not outputting to it's internal buffer. Have I got that correct?

I assume therefore (and I realise that it is unlikely that anyone would do this, but just for my own understanding) that if one instantiates a component in a controller action, passes that to the view and renders it there, that will also not work? For example:

class FooController < ApplicationController
  def show
    @list = List.new do |list|
      list.item { "Hello" }
      list.item { "World" }
    end
  end
end
<!-- foo/show.html.erb -->
<%= render @list %>

and that to render Phlex components in a "ruby context" the call method should be used

Do you think you could provide me with a few pointers as to what this might look like? I'm going to try to dig into the Phlex codebase now to try and figure out what to best do about this but any suggestions would be much appreciated.

@willcosgrove okay so after a bit of trial and error this change to the preview template seems to do the trick:

<% if @render_args[:component] %>
  <% if @render_args[:component].is_a?(Phlex::HTML) %>
    <%== @render_args[:component].call(view_context: self, &@render_args[:block]) %>
  <% else %>
    <%= render(@render_args[:component], @render_args[:args], &@render_args[:block]) %>
  <% end %>
<% else %>
  <%= render(@render_args[:template], **@render_args[:locals], &@render_args[:block]) %>
<% end %>

Does calling the instantiated Phlex component like that look correct to you? Anything I'm missing there that you can spot?

So, the instantiated component here is actually being rendered in an ERB template... but I guess the issue is that the block itself is not rendered as ERB so the list.item calls are not outputting to it's internal buffer. Have I got that correct?

Yes, you're exactly right. The block being passed to render is not from an ERB context, but from the method inside the user's ComponentPreview class.

I looked into having Phlex detect this context but detecting it is error prone (it varies by templating language used) and is slow (it requires inspecting the binding of the block which is not a fast process). Because it would be hard to get this right 100% of the time, and because it would slow down the render path, @joeldrapper decided that it should be the responsibility of the code doing the rendering to indicate what type of context you want the component rendered in.

render_in (which is what Rails is calling when you pass it to render in the view) is going to assume that you're inside a view file, and use an Unbuffered component.

call is what Phlex uses internally to render other Phlex views, and is what should be used to render within a "ruby context".

Here's an example of how you might be able to modify the view code you posted above to make it use call instead of render_in for Phlex views:

<% if @render_args[:component] %>
  <% case @render_args[:component] %>
  <% when Phlex::SGML %>
    <%= raw(@render_args[:component].call(view_context: self, &@render_args[:block])) %>
  <% else %>
    <%= render(@render_args[:component], @render_args[:args], &@render_args[:block]) %>
  <% end %>
<% else %> 
  <%= render(@render_args[:template], **@render_args[:locals], &@render_args[:block]) %> 
<% end %> 

I don't think @render_args[:args] is needed in the Phlex case, but I'm not 100% sure what those would be, so I could be wrong.

@allmarkedup your code looks perfect. The only thing I might suggest is checking for inheritance of Phlex::SGML instead of Phlex::HTML. SGML is higher up the chain, and also includes SVG views.

We may have an even higher abstract class later if Phlex branches out into other types of views, like JSON or XML, but for now Phlex::SGML is at the top of the hierarchy.

@willcosgrove okay great, many thanks for the explanation. And makes sense about checking for Phlex::SGML, good shout.

Appreciate you taking the time to walk me through this and I'll get a fix up for this now 👍

Thanks for writing this up @willcosgrove. @allmarkedup your fix looks good. Let me know if I can be of any help.

Thanks again for the help, I've just merged the PR so will close this down now 👍

I'm facing a similar issue with Lookbook + Phlex:

Phlex allows you to write something like this:

render Views::Phlex::ListExample.new do |list|
  list.item { "Hello" }  
  render(Views::Phlex::Item.new) { "Help!"}
  list.item { "World" }
end

But when we write this example on a Lookbook::Preview class, it doesn't work.

The render(Views::Phlex::Item.new) { "Help!" } returns a hash because it is using render from Lookbook::Preview class and the component isn't rendered. To solve that I could write something like this:

render Views::Phlex::ListExample.new do |list|
  list.item { "Hello" }  
  list.render(Views::Phlex::Item.new) { "Help!"}
  list.item { "World" }
end

But it has two problems:

  • On source tab, will display component usage list.render ..., but it isn't required, so other devs could understand that unnecessary code is required to build the component
  • I cannot use my component helpers. I added helpers to Lookbook::Preview, eg.:
module DesignSystemHelpers
  def my_button(*args, **kwargs, &block)
    render DesignSystem::ButtonComponent.new(*args, **kwargs, &block)
  end
  
  def my_card(...)
    ...
  end
  
  ...
end

# on initializers
config.to_prepare do
  Lookbook::Preview.include DesignSystemHelpers
end

So something like this doesn't work on Lookbook:

def default
  my_card do |card| # this will be rendered
    card.actions do # this will be rendered
      my_button(...) # this will NOT be rendered
      my_button(...) # this will NOT be rendered
    end
  end
end

This is important because I think Lookbook is a source of how developers should use our components.

And the other problem is that I cannot do that:

render Views::Phlex::ListExample.new do |list|
  h1 { "My list" }
  list.item { "Hello" }  
  list.item { "World" }
end

It will raise undefined method 'h1' for an instance of MyComponentPreview.