The Abstraction

Share this post

Magic template methods

www.theabstraction.space

Magic template methods

Using meta-programming to handle method name conflicts in Ruby

Joel Drapper
Oct 21, 2022
2
2
Share this post

Magic template methods

www.theabstraction.space

Part of the interface for Phlex views is that they define an instance method for the template. The template method is called when rendering a view to determine the output.

For example, this view has a template method that renders an <h1> tag with the content “Hello”.

class Hello < Phlex::View
  def template
    h1 { "Hello" }
  end
end

As you can see, HTML elements are created by calling instance methods — the h1 method here creates an <h1> tag — but this presents a problem: the HTML spec includes an element named template. Calling template with a template is expected to create a <template> element but instead, it creates an infinite loop because it references itself.

The natural solution is just to find another name, but after searching and pondering on this for quite some time, I haven’t been able to come up with anything I’m remotely happy with.

The only other option that comes close in my mind is render, but unfortunately the render instance method is also taken — it’s used to render other views inside the template.

Without an alternative name, I started to explore options for changing what the template method does depending on where it’s used. When it’s called from inside a template, it should create a <template> tag; otherwise it should render the view template.

Tracking rendering state in an instance variable

The first idea was to prepend a module that overrides the template method and tracks the rendering state in an instance variable on the view.

module TemplateOverride
  def template(**kwargs, &block)
    if @rendering
      template_tag(**kwargs, &block)
    else
      @rendering = true
      super
    end
  end
end

The first time this method is called, @rendering will be nil, so we’ll hit the else condition which calls the original template method, super. But right before we do that we set @rendering to true.

If this method is called again from within the template, @rendering will be true, so it redirects to the template_tag method for our <template> tag. We can use the inherited hook to prepend this override whenever Phlex::View is subclassed.

class Phlex::View
  def self.inherited(child)
    child.prepend(TemplateOverride)
  end
end

This technique has a performance cost because every template render now goes through an extra method which allocates a Hash when it picks up **kwargs for the potential template tag attributes, but it works. You can define a template method that calls the template method and outputs a <template> tag rather than an infinite loop.

class Modal < Phlex::View
  def template
    template do
      ...
    end
  end
end

Unfortunately, this technique has some problems.

If we subclass an abstract view and don’t define a template method, we end up with two prepended template methods stacked on top of each other.

class A < Phlex::View
  def template
    h1 { "Hello" }
  end
end

class B < A; end

If we look at B.ancestors, we’ll see the ancestry looks like this:

TemplateOverride < B < TemplateOverride < A < Phlex::View < Object

And because B doesn’t itself define a template method, B’s template method and its super method are both overrides. When the first override calls super, it hits the second override but this time with @rendering set to true. So the second override thinks template has been called from within the template and redirects to the template_tag method.

B’s output will be <template></template>, not <h1>Hello</h1>.

Tagged blocks

We need a way to communicate our calling intentions (whether we want to render or whether we want the tag) with stacked overrides that can be passed up the chain from one override to the next. But overrides don’t know if their super method is another override, so whenever we send a call up the super chain, it can’t interfere with the interface of the actual user-defined template method. We can’t use an argument otherwise framework users would need to write their own template methods to handle that argument.

There is, however, one special argument that you can always send to any method: the block argument. Whenever we want to actually render the template that the user defined, we can call template with a block that we’ve tagged with an instance variable so our override knows it’s us.

Phlex renders views by calling template from the call method. We can update that method like this.

def call
  ...

  # Create a no-op Proc if we don't already have a block
  block ||= -> (*args) {}

  # Tag the block with an instance variable
  block.instance_variable_set(:@render, true)

  # Call template with our tagged block
  template(&block)
end

Now we can update our TemplateOverride to look for this instance variable. If it’s true, we want the template method defined by the user, otherwise we must want to render a <template> tag.

module TemplateOverride
  def template(**kwargs, &block)
    if block.instance_variable_get(:@render)
      super(&block)
    else
      template_tag(**kwargs, &block)
    end
  end
end

This technique works, even when two or more overrides are stacked, because the tagged block gets passed up all the overrides. But it has another problem.

Sometimes we want to define a view template that depends on the super view template.

Here’s an example:

class AbstractForm < Phlex::View
  def template(&)
    form do
      input type: "hidden", name: "auth", value: auth_token
      yield_content(&)
    end
  end
end

class ConcreteForm < AbstractForm
  def template
    super do
      input type: "text", name: "foo"
      ...
    end
  end
end

Our ConcreteForm defines a template method that depends on the super template method, calling super with a block. The problem is, that block hasn’t been tagged so, when it hits the override, it is redirected to the template tag method. ConcreteForm now outputs <template></template>.

Okay, okay. But we’ve come so far! Can’t we hack our way around it? Yes we can. Phlex::View could provide an alternative super method for use within a template.

def super_template(&block)
  block.instance_variable_set(:@render, true)
  method(:template).super_method.super_method.call(&block)
end

Our new super_template method has the same interface as the super keyword, but it tags the block first and then jumps two places up the inheritance tree to hand it to a method that is either the intended super target or an override that will pass it on up the tree.

At this point, we’re kind of back where we started in the sense that we have another snowflake to document and explain to framework users — and this one is even more difficult to explain.

Bound method equality

Ruby allows you to compare bound Method objects for equality. One option I explored was having the override compare itself to its super.

method(__method__) == method(__method__).super_method

Unfortunately, it appears that stacked prepended methods have different bindings so it is not possible to compare them like this.

The ol’ Switcheroo

Throw everything else away and start again: what if we let the user define a template method, but then we rename it to something else and rename template_tag to template at the last minute. We can use its new name when we want to render the view (since this happens in call and is never exposed) and super will still work as normal.

Additionally, we can get back to the original performance profile for these methods as there is no indirection and no unnecessary Hash object allocations.

There is, however one final problem. We can’t permanently rename the methods on the classes. If we did, calling super from template in a subclass would point to the renamed tag method in its super class rather than the original. What we can do is rename the methods on the singleton class instead. That way, template will always be defined as a sub-method of the original template method in the super class rather than the renamed template tag method.

Back in our call method, we can open the singleton class and use alias_method to alias view_template to the original template. Then, we can alias template to template_tag.

def call
  ...

  class << self
    alias_method :view_template, :template
    alias_method :template, :template_tag
  end

  view_template(&block)

  ...
end

For slightly better performance, we can actually omit this first alias and save the original template method to a variable instead.

def call
  ...

  view_template = method(:template)

  class << self
    alias_method :template, :template_tag
  end

  view_template::(&block)

  ...
end

Here we call the view_template variable using the () method, which is an alias for call.

The final cost of all this is about 5.73% worse performance than before. I expect this is because a cache in Ruby is invalidated when we do the alias on our singleton class. That’s an unacceptable performance cost unfortunately.

Compilation

In the future, Phlex views will be transparently compiled by default. I’ll share more about that in the next few weeks but the TL;DR is the compiler gives us an opportunity to completely optimise this problem away. We can use SyntaxTree to target template calls inside Phlex views and replace them with calls to template_tag instead.

My goal for the Phlex compiler is to speed up view rendering without inventing a new language. That means I don’t want the compiler to let you do something you otherwise couldn’t do without it. But since we have a solution in pure Ruby, this optimisation is now technically in scope.

It’s still a tough call whether it would be worth the added complexity in the end. Unfortunately, you’ll have to continue to use template_tag not template when you want a <template> tag. At least for now.

Maybe one day I’ll find a new name for the template method instead. 🤷‍♂️

2
Share this post

Magic template methods

www.theabstraction.space
Previous
Next
2 Comments
Neil Tyler
Oct 29, 2022Liked by Joel Drapper

What you could also do, is explicitly document "hey, `template` is reserved, so you can't use it, sorry. Please use tag(:template)" instead. Considering the "popularity" of `template` html tag, I don't think that will be an issue for 99.99% of users. ;) And don't you worry about "being perfect" either, perfect doesn't exist, and race for perfectionism ruined more great works than helped. Keep up the great work! PS. I like this gem soooo much better than ViewComponents... :+1:

Expand full comment
Reply
1 reply by Joel Drapper
1 more comment…
TopNewCommunity

No posts

Ready for more?

© 2023 The Abstraction
Privacy ∙ Terms ∙ Collection notice
Start WritingGet the app
Substack is the home for great writing