Add ColocatedCSS with component-scoped CSS support (poc)#4114
Add ColocatedCSS with component-scoped CSS support (poc)#4114green-david wants to merge 7 commits intophoenixframework:mainfrom
Conversation
Note: The version of Firefox used by playwright is still to old to support @scope
| @type tag_meta :: %{closing: :self | :void} | ||
| @type heex_ast :: {tag(), attributes(), children(), tag_meta()} | binary() | ||
| @type transform_meta :: %{env: Macro.Env.t()} | ||
| @type transform_meta :: %{scope: String.t(), env: Macro.Env.t()} |
There was a problem hiding this comment.
I'm not super convinced by passing this in the transform_meta as it is so specific to ColocatedCSS specifically but this was the most immediately obvious way to propagate this information into the MacroComponent for use to actually generate the scoped CSS content
| end | ||
|
|
||
| defp target_dir do | ||
| default = Path.join(Mix.Project.build_path(), "phoenix-colocated-css") |
There was a problem hiding this comment.
This PR uses an entirely new subdirectory, phoenix-colocated-css, rather than going in phoenix-colocated, as ColocatedJS seems to excercise total control over the contents of phoenix-colocated, so this prevents collisions. However, it would likely make sense to put everything under a common subdirectory and simply namespace them (I think Steffen was going down a similar path in #3725)
There was a problem hiding this comment.
It would be nice to share the folder, but of course it means extra work. If we do it, we could also share common code from colocated js and css into a shared module that we call from the compiler and which traverses all modules only once. No need to change anything about that at this point though!
| :md5 | ||
| |> :crypto.hash(source) | ||
| |> Base.encode32(case: :lower, padding: false) |
There was a problem hiding this comment.
This generates a decently long identifier to put on the tags for scoping, which I can see being a downside of diff sizes over the wire - open to suggestions as to alternate methodologies which generate shorter scope identifiers
There was a problem hiding this comment.
I think that's fine, since scopes are not dynamic. Short scopes would be nice, but I don't see a good way to do that, since compilation can be incremental in development. I guess when building a release for production, you could create short scopes with a counter, but most likely not worth the extra effort.
| end | ||
|
|
||
| defp maybe_add_scope_text(text, scope, previous_tag) do | ||
| scope_root? = not match?({:tag, _, _, _}, previous_tag) |
There was a problem hiding this comment.
This is very likely not the most optimal method to determine whether or not to apply the scope attribute to this element, but it seems to work. The tag_engine code is quite a beast and im sure im missing a much better way to do so!
The goal here is to apply data-phx-css="SCOPE_HERE" to all elements at the outermost nesting level inside of each template / component - that way the @scope rule we apply can use said outermost elements as the scope root, and descendant elements with the data-phx-css attribute as scope limits, as presumably they are their own template / component!
SteffenDE
left a comment
There was a problem hiding this comment.
This looks great!
One difficulty with scoped CSS is that we want it to be able to modify how other elements are rendered in the template. You did this by changing the tag engine specifically for colocated CSS. One reason why we didn't ship colocated CSS yet is because we did not want to do that and instead find a solution that was more generic.
So one idea was to require something like this for scoped CSS:
~H"""
<wrapper :type={Phoenix.LiveView.ColocatedCSS}>
<style>
...
</style>
<!-- scoped CSS applies to all elements here -->
</wrapper>
<!-- scoped CSS does not apply here -->
"""So all the logic could be handled by the macro component. This obviously is not really nice, so we didn't do it. I also wanted to solve components and other HEEx constructs in macro components, so I briefly worked on this PR, but we decided to not go that route either for now.
Another idea we had was to be able to do something like MacroComponent.put_attr("data-phx-css", "value"), which would tell the engine to apply that attribute to all tags in this component from this point on. I think that may be a viable option here. It would need to be a MacroComponent.put_root_attr(key, value) instead, but the idea is the same. Something that is not CSS specific, but possibly useful to other macro components as well. If we want to be more explicit (this would probably require using the process dictionary), it could also be something that could be returned from the transform function:
def transform(ast, meta) do
{:ok, new_ast, data, directives}
endwhere directives (or however we'd call that) could be instructions used for further compilation, like [put_root_attrs: [{"data-phx-css", "foo"]}].
cc @josevalim
|
@SteffenDE First and foremost - thank you for the quick and kind review. It is very much appreciated. I also went down the wrapper element rabbit hole but realized it was essentially a non-starter without #3846 as the entire component contents, slots and dynamic content included, would have to be wrapped, just as you have mentioned. I agree that having to alter the TagEngine specifically to accomplish a particular MacroComponent is fairly anti-thesis to the intent for MacroComponents to be extensions of functionality without editing LiveView itself. The I also had the thought that maybe passing the I will explore a few of these ideas and see if I can make any headway. Again, thank you for the review! |
Correct! I wanted to mention that in my response, but I forgot. I think that's most likely fine though and can be well-documented.
Right, but I don't think that the identifier there is the important part. The question is: when and how do we decide to annotate an HTML tag. I believe we should decide that in the colocated CSS code. But that means - as mentioned above - that we cannot change tags that have already been handled before. And if we do that, we also don't need the tag engine to generate a unique identifier for us, as we can already get one by using the existing meta.env. |
|
Hey @green-david, I've been thinking a bit more about this and talked to @josevalim and I believe the best way forward here would be to do the following, which is similar, but also a bit different compared to what you did with the scope in the tag engine: We add a new ~H"""
<div ROOT_TAG>
<something-else NOT_A_ROOT_TAG />
<.some_component>
<p ROOT_TAG />
<:other_slot>
<p ROOT_TAG />
<div ROOT_TAG>
<span NOT_A_ROOT_TAG />
</div>
</:other_slot>
</.some_component>
</div>
<p ROOT_TAG />
"""I annotated all the "root tags". By default, the root_tag_annotation would be something like Then, if scoped CSS is used, the macro component can return a different root_tag_annotation, for example def transform(ast, meta) do
...
{:ok, new_ast, data, [root_tag_annotation: hash]}
endFor simplicity, we can say that if a macro component returns a directive, which is how I called that fourth return element, we raise if the macro component is not at the very top of the template. This ensures that there can be no confusion about the position of the macro component affecting how things work. Maybe that could change in the future, but I think it's fine to start with that restriction. We make this annotation opt-in with a compile time configuration: config :phoenix_live_view, root_tag_annotation: "phx-r" # or similarThis means that existing apps are not affected, but we can enable it by default for new apps in José also mentioned that people might want to opt in to using parent styles for specific components. I think this should work if we change the closing scope selector to something like Now, I looked at Vue.js's documentation and they decided to explicitly opt-in the root tag of components to be affected by parent styles:
I'm not sure if we need to do that, but - please correct me if I'm wrong - I believe we could also do that by changing the closing selector to
And that could be controlled based on an attribute like I think that's all for now. Let me know what you think! It's very much possible that I'm missing something somewhere and there's a big flaw in that plan :D |
Love this!
Agreed, and I think this restriction actually drives you you to use ColocatedCSS in the most "intuitive" manner and is similar to other frameworks.
I could see this being particularly desirable if you want to make The thought I have here is how to determine whether to apply
I don't think that is a crazy restriction to have. I think with this methodology you could also "compose" the parent styles with additional child styles like so, which seems pretty neat:
Yes!! I think this would be a great addition.
I'll let you know if I encounter any issues, but at first glance the approach seems great and I don't see any flaws. I will update this PR based on this discussion over the next couple of days and give you a ping when its ready for another look. If you think of anything else before I get back to you, please ping me and ill be sure to take a look. You and Jose's feedback is invaluable. Thank you for taking the time and efforts to provide your guidance! |
I think it would be as easy as defp my_helper_component(assigns) do
~H"""
<div phx-inherit-styles>
...
</div>
"""
endSo no need to define any colocated styles if the component doesn't need them. It just affects the CSS scope selector. If the component used colocated CSS, it should also work I believe. Looking at an example: def my_component(assigns) do
~H"""
<style :type={ColocatedCSS}>
p { color: red; }
</style>
<p>Hey</p>
<.helper_component />
"""
end
defp helper_component(assigns) do
~H"""
<style :type={ColocatedCSS}>
p { font-weight: 600; }
</style>
<p phx-inherit-styles>I am a helper</p>
"""
endwould get rendered as <p phx-r="HASH_A">Hey</p>
<p phx-r="HASH_B" phx-inherit-styles>I am a helper</p>And the CSS rules would say @scope [phx-r="HASH_A"] to [phx-r:not([phx-inherit-styles])] {
p { color: red; }
}
@scope [phx-r="HASH_B"] to [phx-r:not([phx-inherit-styles])] {
p { color: font-weight: 600; }
}so the helper
I'd propose to split the work into two parts:
This is exciting, thank you for working on it! :) |
|
marked as draft based on above discussions, separate PRs to come that will hopefully make this one obsolete! |
This commit introduces a compiler for the new unified tree structure introduced in the previous commit. It changes the TagEngine to use the new compiler and also moves macro component handling to the parser. For backwards compatibility, the TagEngine still implements the EEx.Engine behaviour, but it is basically a no-op (it will still parse Elixir expressions twice!) and calls the new compiler at the end. The benefit of this is that we can now have the full tokenized template and handle macro components before compiling inner parts of the template. This allows us to more easily implement directives as explored by #4114 #4116
This commit introduces a compiler for the new unified tree structure introduced in the previous commit. It changes the TagEngine to use the new compiler and also moves macro component handling to the parser. For backwards compatibility, the TagEngine still implements the EEx.Engine behaviour, but it is basically a no-op (it will still parse Elixir expressions twice!) and calls the new compiler at the end. The benefit of this is that we can now have the full tokenized template and handle macro components before compiling inner parts of the template. This allows us to more easily implement directives as explored by #4114 #4116
This commit introduces a compiler for the new unified tree structure introduced in the previous commit. It changes the TagEngine to use the new compiler and also moves macro component handling to the parser. For backwards compatibility, the TagEngine still implements the EEx.Engine behaviour, but it is basically a no-op (it will still parse Elixir expressions twice!) and calls the new compiler at the end. The benefit of this is that we can now have the full tokenized template and handle macro components before compiling inner parts of the template. This allows us to more easily implement directives as explored by #4114 #4116
This commit introduces a compiler for the new unified tree structure introduced in the previous commit. It changes the TagEngine to use the new compiler and also moves macro component handling to the parser. For backwards compatibility, the TagEngine still implements the EEx.Engine behaviour, but it is basically a no-op (it will still parse Elixir expressions twice!) and calls the new compiler at the end. The benefit of this is that we can now have the full tokenized template and handle macro components before compiling inner parts of the template. This allows us to more easily implement directives as explored by #4114 #4116
This commit introduces a compiler for the new unified tree structure introduced in the previous commit. It changes the TagEngine to use the new compiler and also moves macro component handling to the parser. For backwards compatibility, the TagEngine still implements the EEx.Engine behaviour, but it is basically a no-op (it will still parse Elixir expressions twice!) and calls the new compiler at the end. The benefit of this is that we can now have the full tokenized template and handle macro components before compiling inner parts of the template. This allows us to more easily implement directives as explored by #4114 #4116
This commit introduces a compiler for the new unified tree structure introduced in the previous commit. It changes the TagEngine to use the new compiler and also moves macro component handling to the parser. For backwards compatibility, the TagEngine still implements the EEx.Engine behaviour, but it is basically a no-op (it will still parse Elixir expressions twice!) and calls the new compiler at the end. The benefit of this is that we can now have the full tokenized template and handle macro components before compiling inner parts of the template. This allows us to more easily implement directives as explored by #4114 #4116
* Add Phoenix.LiveView.TagEngine namespace This commit moves the Tokenizer into the Phoenix.LiveView.TagEngine.Tokenizer module and also adds a Parser that builds a tree similar to what the HTMLFormatter previously did. We're going to use that node tree for compiling templates in a future commit. The HTMLFormatter and HTMLAlgebra were now use this tree format. * Add Phoenix.LiveView.TagEngine.Compiler This commit also introduces a compiler for the new unified tree structure introduced in the previous commit. It changes the TagEngine to use the new compiler and also moves macro component handling to the parser. For backwards compatibility, the TagEngine still implements the EEx.Engine behaviour, but it is basically a no-op (it will still parse Elixir expressions twice!) and calls the new compiler at the end. The benefit of this is that we can now have the full tokenized template and handle macro components before compiling inner parts of the template. This allows us to more easily implement directives as explored by #4114 #4116
|
closing in favor of #4131 |
ColocatedCSS
This PR adds ColocatedCSS to LiveView in a similar fashion to the existing ColocatedJS/ColocatedHook functionality added in #3810.
Scoped CSS is a concept present in many other frameworks such as Svelte, Vue, and others, and provides the ability to write component-level CSS without the need to worry about interfering with CSS rules defined in other components/templates.
Rather than use an approach like some of the aforementioned frameworks which parses the given CSS and manipulates the selectors at compile-time, this PR takes the approach of utilizing the new CSS-native @scope functionality which is Baseline Available as of the end of 2025 in all major browsers.
Although the implementation of ColocatedCSS provided is scoped-by-default, it also supports a
globalattribute to enable globally scoped ColocatedCSS as well if needed.Prior Art
This PR is heavily inspired by prior art such as garrison's post on ElixirForum and Steffen's prior work in #3725.
Notes
I don't expect this PR to be merged in it's current state (or potentially at all), but figured i'd throw it up as a proof-of-concept implementation to spark discussion and see if there is any interest in going down this route for ColocatedCSS. I believe ColocatedCSS could be a valuable addition to LiveView and be especially attractive to developers familiar with other frameworks with similar functionality.
One of the new JS tests for ColocatedCSS is failing in Firefox in particular as
@scopedid not become available in Firefox until v146, but the latest version of playwright (1.57.0) only supports Firefox v144.