New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Allow templates to define which locals they accept #45602
Conversation
I like this idea, though I think we should shelve the precompilation discussion as I think that is just a nice (but significant!) side-effect. I think the reason we should do this is the improved developer experience from validating template arguments and having a convenient way to specify defaults. I do really like this idea. The one reservation I can forsee is surrounding use of "significant", "magic" comments, but I think we already have a precedent for that for specifying encoding ( |
actionview/CHANGELOG.md
Outdated
This allows for defining what locals can be passed to a template. For example: | ||
|
||
```erb | ||
# strict: (message:) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This reads a bit odd to me. Should we use <%# strict: (message:) -%>
to match existing magic comments? (we can still search for just # strict: .*
to match other template formats, but don't have to sub!
it from the template)
e1d8ce9
to
9207b20
Compare
@jhawthorn thanks for having a look! I've scaled this back to not mention precompilation- we can handle that later. In the meantime, I'd be curious to get your thoughts on the way |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool! 😃
return unless STRICT_REGEX.match?(source) | ||
|
||
# Look for # strict: (*). If we find one, we'll mark | ||
# the template as strict and set the arguments it | ||
# should accept. | ||
if source.sub!(STRICT_REGEX, "") | ||
@arguments = $1 | ||
|
||
true | ||
else | ||
false | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think return unless
and the else
branch are redundant... no?
actionview/CHANGELOG.md
Outdated
This allows for defining what locals can be passed to a template. For example: | ||
|
||
```erb | ||
<%# strict: (message: "Hello, world!") -%> | ||
<%= message %> | ||
``` | ||
|
||
Default values can also be provided: | ||
|
||
```erb | ||
<%# strict: (message: "Hello, world!") -%> | ||
<%= message %> | ||
``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should the first example here omit "Hello, world!"
?
By default, templates will accept any `locals`. To define what locals a template should accept, use the `strict` magic comment: | ||
|
||
```erb | ||
<%# strict: (message:) -%> | ||
<%= message %> | ||
``` | ||
|
||
Default values can also be provided: | ||
|
||
```erb | ||
<%# strict: (message: "Hello, world!") -%> | ||
<%= message %> | ||
``` | ||
|
||
To disallow locals entirely, set `# strict: (true)`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Floating an idea: what if the magic "comment" was 100% Ruby? e.g.:
<% template_signature { |foo:, bar: 2, baz: (foo + bar)| } %>
That might make the valid syntax of the "comment" more obvious, including when disallowing locals:
<% template_signature { || } %>
And it could leverage syntax highlighting in editors.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Huh that's pretty cool! I love the idea of having it not be a comment. Are you thinking we would we AST this line to extract the method signature for the template?
So I like the feature too, I'm not sure I'd call it "strict" though. What I had in mind when we discussed this was something like As for supporting no arguments There is also the question of wether these arguments should be passed as |
Right, I really want to avoid modifying callers, at least at this point. |
f5a2bf9
to
714326b
Compare
@jhawthorn @byroot @jonathanhefner thanks for the thoughtful feedback! I really like the idea of writing this in valid Ruby vs. as a comment. I suggested an Let me know what you think ❤️ |
I must say I'm on the fence. The pro I see if that since it's not a magic comment, editors will highlight it. The con is that we're kinda lying to the user, as this line of code won't be executed, but striped during compilation. I'm not strongly opposed though. |
@byroot I'm pretty indifferent as well. @jonathanhefner @jhawthorn how do you lean here? |
actionview/CHANGELOG.md
Outdated
For example: | ||
|
||
```erb | ||
<% accepts_locals(message:) -%> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would only be valid syntax in Ruby >= 3.1, right? (And such a method call would imply that message
is already defined.)
My thinking was: by using a block, we link the declared locals syntax to valid params syntax. Linking declared locals syntax to valid argument syntax seems to be mostly equivalent (for Ruby >= 3.1), but that could change in the future.
I think if we don't want to use something with params syntax (because e.g. it doesn't look as clean), then a comment is probably better.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jonathanhefner regardless of the syntax, this line is not executed in the proposed implementation. It's nice to have syntax highlighting, but based on your response I think a comment is a clearer API due to the lack of execution.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 for the comment. I think the Ruby method is confusing because though it's tecnically now valid syntax we aren't actually doing a method call (we are "defining" a method, though even that is a little abstracted to the user). Having this look like regular Ruby is likely to confuse users into thinking they could call the method dynamically.
80e9343
to
9844ad8
Compare
Alright, I switched this back to a magic comment ❤️ |
actionview/CHANGELOG.md
Outdated
<%= message %> | ||
``` | ||
|
||
To disallow locals entirely, set `# accepts_locals()`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In #45602 (comment), I was thinking a label like template_signature
would make it clear that empty brackets denote no locals. But, to me, accepts_locals()
reads as "this template accepts (some unspecified) locals".
One possibility would be to have different comments for each case. For example:
<%# no_locals %>
<%# def_locals (message:) %>
<%# def_locals message: %> perhaps without parens?
Another possibility is to rephrase by adding a colon:
<%# locals: false %>
<%# locals: (message:) %>
That would mirror the syntax of other magic comments.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess what weirds me out a bit, is that most proposals so far use some variation of locals
(which makes sense since it's the parameter you use to pass them), but to me this is more akin to declaring a method signature, hence why I'm more attracted by something like:
<%# def(message:, author: "George") %>
or
<%# sig(message:, author: "George") %>
But that's just my opinion, I don't feel that strongly about it.
return unless EXPLICIT_LOCALS_REGEX.match?(source) | ||
|
||
source.sub!(EXPLICIT_LOCALS_REGEX, "") | ||
|
||
@arguments = $1 | ||
|
||
true |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It probably doesn't matter, but this evaluates the regexp twice. I think it could be written as:
return unless EXPLICIT_LOCALS_REGEX.match?(source) | |
source.sub!(EXPLICIT_LOCALS_REGEX, "") | |
@arguments = $1 | |
true | |
if source.sub!(EXPLICIT_LOCALS_REGEX, "") | |
@arguments = $1 | |
true | |
end |
Note that if we change the regexp to something like /no_locals|def_locals (.+)/
or /locals: (?:false|\((.+)\))/
then $1
could be nil. But, based on the current code, I think that's okay.
@@ -318,6 +318,24 @@ You can also specify a second partial to be rendered between instances of the ma | |||
|
|||
Rails will render the `_product_ruler` partial (with no data passed to it) between each pair of `_product` partials. | |||
|
|||
#### Explicit locals |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
#### Explicit locals | |
#### Explicit Locals |
b032005
to
428ea86
Compare
428ea86
to
abae401
Compare
Ok, all good to me for real this time. Just need you to squash into a single commit, and I'll merge on green. Thank you for bearing with me on this one, I was a bit picky. |
ddf4f37
to
a4f2749
Compare
@@ -8,6 +8,8 @@ module ActionView | |||
class Template | |||
extend ActiveSupport::Autoload | |||
|
|||
EXPLICIT_LOCALS_REGEX = /\# locals: \((.*)\)/ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I hate myself for only noticing it now, be we may need to rework that regexp a bit:
- We should ensure it's the only thing on the line, so add
^
and$
- We can probably allow extra whitespaces in a bunch of places, e.g
\s+
instead of just
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I tested /^\s*\#\s+locals:\s+\((.*)\)$/
on https://rubular.com/, and it seem to do the job
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤔 the existing magic comment regex does not have the exact line match: https://github.com/rails/rails/blob/main/actionview/lib/action_view.rb#L33. I'm guessing this is to allow it to work with non-ERB templating languages? For example, in haml they look like:
-# locals: (foo:)
(https://makandracards.com/makandra/689-know-your-haml-comments#section-ruby-comments)
I swapped in the \s+
👍🏻
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(and for that matter, in ERB we wouldn't be matching the entire line either:
<%# locals: (foo:) %>
Of course, I'm super rusty on regexes so forgive me if I'm missing something here ❤️
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah right, we're paring the source template, not the generated code, that makes sense. My bad!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All good! I greatly appreciate the rigor of your reviews!
a4f2749
to
5f5ce9a
Compare
5f5ce9a
to
bbe7d19
Compare
Thanks for your patience! |
@byroot thanks for the helping hand! FWIW I thought the pace on this PR was just fine. I'd rather take the time to get things buttoned up nicely. |
@@ -318,6 +318,28 @@ You can also specify a second partial to be rendered between instances of the ma | |||
|
|||
Rails will render the `_product_ruler` partial (with no data passed to it) between each pair of `_product` partials. | |||
|
|||
#### Explicit Locals |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@byroot thinking about this some more, what do you think about calling this Strict Locals
instead of Explicit Locals
? Strict
feels better to me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm sorry to chime in so late to this discussion, but as an alternative to a magic comment, have we considered overloading To preserve backwards compatibility, invoking However, when invoked with arguments, it'd invoke the same code that the magic comment results in. To get the point across, imagine a method like: def local_assigns(*arguments, **options): arguments.empty? && options.empty? ? @hash : do_magic(*arguments, **options) Then, partials could call: <% local_assigns :title, body_with_default: "a default value" %>
<%# elsewhere ... %>
<%= local_assigns[:title] %> That one big risk I see with a method instead of a comment is that the method name essentially becomes a reserved word, and risks colliding with anything passed into |
@seanpdoyle you raise a good point. I'm not sure if you saw the buried thread, but we discussed something similar at one point. (I know that GitHub collapses discussions on long PRs like this): #45602 (comment) I see your point and think that there are definitely compromises in both approaches (magic comment vs. interpreted ruby). |
Does this work with other template languages, or is it just limited to ERB templates? For example could I do this with Jbuilder: # locals: (message:)
json.message message It might be worth adding a line to the guide to indicate this (either way). Happy to do this myself. |
It should yes, hence why the regexp for the magic comment is relatively lax. If it doesn't work please open an issue. |
@byroot @adamdebono I think the same issue would exist with the |
In a utility-first approach for styling (e.g. Tailwind), does it make sense to use this new feature instead of ViewComponents? What are the pros / cons of this over ViewComponents? The main advantage that I see with this solution is that you don't need an extra |
It's orthogonal. You can use either or both together. |
Summary
This PR introduces an option that enables templates to have required arguments with default values.
This change will also unlock the ability to pre-compile templates during initialization in the future.
Problem statement
Rails templates have an implicit API, accepting any locals passed to them. This can make template dependencies difficult to reason about. In addition, the underlying ActionView code that enables flexible locals means that templates have to be compiled for each unique combination of locals passed in at runtime.
Proposed solution
In chatting with @tenderlove, @jhawthorn, and @byroot at RailsConf, we came up with the idea of allowing for optional template signatures using a magic comment. We would then compile these arguments as the method signature for the template.
Doing so would allow us to pre-compile templates at application boot time, instead of at runtime in the future. For more information on this issue, see:
Example
Before:
After:
Alternatives considered
Compiling templates to one object per template, using object initializer for arguments
This is more or less what we've built with ViewComponent at GitHub. It's worked quite well for us, but wrapping a template in an object has its downsides when it comes to object allocations in heavy usage and we've had issues with form helpers.
Implicit signature compilation
We could, in theory, generate the signature for a template by traversing its AST or another form of static analysis. This has proven difficult in our experience and still does not allow one to set defaults for arguments or to require arguments.