Skip to content
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

CSS encapsulation #677

Closed
wants to merge 25 commits into from
Closed

CSS encapsulation #677

wants to merge 25 commits into from

Conversation

joelhawksley
Copy link
Member

Please read this pull request as a proposal that can and should change.

This pull request introduces a prototype implementation of CSS encapsulation. We've been running this code in production for about a month without issue.

Wading into this area is not something we take lightly, and we recognize the significant amount of prior art and context to take into account.

I've included extensive documentation in the form of an Architectural Decision Record. I'd love to see us use that document as the grounds of debate for this proposal.

I'd love to hear your thoughts at all abstraction levels, from the technical details to the broad concepts.

@joelhawksley joelhawksley marked this pull request as ready for review March 25, 2021 17:29
Copy link
Collaborator

@elia elia left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just had a quick read, super interesting, but this is just to notify about a couple of nits.
One of the two is that the first commit is missing a : after Co-authored-by.

adr/0677-css-encapsulation.md Outdated Show resolved Hide resolved
@jaredcwhite
Copy link
Contributor

This seems like a very interesting approach to solving for the use cases described. My comment is more a philosophical one: should this be part of ViewComponent at all? I can think of some reasons why VC could simply provide a few hooks in the base class(es) so that this particular CSS solution would function through a separate gem. That way (a) it's optional, and (b) others could build different solutions in a similar fashion. To be perfectly honest, I never use CSS modules (aka fingerprinting class names) nor HTML-embedded stylesheets, and prefer custom properties, custom elements, shadow DOM, etc. for encapsulation techniques…and so this isn't a solution I personally would use.

@Spone
Copy link
Collaborator

Spone commented Mar 26, 2021

This is great! I'll take some time to try it next week, but here are some preliminary thoughts after reading the PR:

  • Generator support: will the component generator be updated to generate the sidecar CSS file? (with a --css flag or global setting)
  • Naming conventions: I see that you went with a .Css_0343d_foo style selector for the foo class in StylableComponent. Most of the time, my personal choice would be .stylable-foo (I'm always making sure that my component naming is unique). I guess there are a lot of strong personal preferences on this topic (also, CSS naming conventions such as BEM, SMACSS, SUIT...). How can we make sure this is not an obstacle to adoption?
  • Third-party CSS: let's say I build a ViewComponent that depends on a 3rd-party library such as Flickity, so I need to include the library CSS to my component. How would you approach this?
  • Slim templates: I'm a bit concerned about the readability of having div(class="#{styles['foo']} another-class") instead of .stylable-foo.another-class in my Slim templates, but there's probably a way to improve this


# Generate a short, random-ish token to prevent CSS selector collisions.
def compute_hash
Digest::MD5.hexdigest(@module_name).first(5) # rubocop:disable GitHub/InsecureHashAlgorithm
Copy link
Collaborator

@Spone Spone Mar 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about using the first 5 characters of the Base64 representation of the hexdigest? This way there's more "specificity" in 5 chars, and even less risk of collision.


# Generate a short, random-ish token to prevent CSS selector collisions.
def compute_hash
Digest::MD5.hexdigest(@module_name).first(5) # rubocop:disable GitHub/InsecureHashAlgorithm
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MD5 is pretty good at generating (mostly) collision free hashes, but a couple of points:

  • It's counted as encryption and federal compliance regulations have specific rubrics around encryption standards. Using this for "enterprise" software involves some bureaucratic complexity. It might be easier to migrate from this just to avoid having the conversation.
  • JS does not have MD5 in its standard library. It's unlikely to get it as it's now an obsolete standard. It's also a non-trivial implementation; it requires quite a lot of code to implement or pull in, so if we need to mirror these on the client side we'd be looking at adding 1-2kb of JS code just to do this.

Perhaps a better option is to use a simpler digest mechanism, such as base64? is probably more ubiquitous than MD5, and where it isn't it can be implemented in a dozen or so LOC. It does have a much higher potential for collisions; but given it's used as part of a class selector here, the risk of collisions is likely the same as using MD5.

Copy link
Collaborator

@Spone Spone Mar 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem with simple truncated base64 is that you'll have the same value if components have the same prefix -- it's only encoding the string with a different radix, not hashing it:

Base64.encode64("HelloWorld").first(5)
=> "SGVsb"
Base64.encode64("HelloThere").first(5)
=> "SGVsb"

I'm not sure if there is some other hashing function that does not imply the compliance regulations you mention?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is necessarily a problem as the class names would still be different as @module-name is used both undigested and digested parts of the selector: a full class would be .HelloThere_SGVsb_foo or .HelloWorld_SGVsb_foo. The intent of the compute_hash function seems to be to provide enough entropy in a class name so as not to collide with human authored classes; .HelloThere_foo is more likely to already exist in existing CSS.

Copy link
Collaborator

@elia elia left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So happy to finally see this released! 👏
Just a few considerations/needs I would have as a potential user of ViewComponent::Stylable.

How can we support porting existing SCSS code and running tools like postCSS and CSSLint?

Using params to store the list of compiled CSS might not play well with caching. Using a current attribute with a Set in it would probably look better, but I'm not sure it really solves the problem.

I'm under the impression that for some application would be as fine to just ship the collected CSS of all existing components. That has the advantage of leveraging the browser cache, or not even parsing the bundle more than once by using Turbo. This approach retains most of the properties/advantages as the current solution but simplifies a lot the delivery.

I'd like to see a configurable CSS scoping prefix, ideally I should be able to replicate popular solutions like turning Foo::BarBaz into .foo--bar-baz. As long as the components are stored in global constants any string derived from the class name should work.

That's all I've got for now, hope this feedback will help in shaping a solution that can be effective for apps of all sizes! ✨


PS.

My experience building two e-commerce stores with CSS and components has been great, we implemented a poor man's version of styled components by just using unique CSS class names based on the Ruby class name. In one case it was built from scratch and we only included a reset and a micro CSS framework. In the other there's plenty of legacy CSS, so far the extraction has been quite painless. The only headaches we had were around components accepting blocks which potentially have conflicting CSS class names and clashes with names coming from utility classes.

Copy link
Contributor

@BlakeWilliams BlakeWilliams left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this looks good to me!


We considered allowing for evaluation of Ruby inside CSS files (at compile, runtime, or both) but instead decided to lean on [CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) for injecting dynamic values.

### Bundle splitting
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit more broad since it could also support JavaScript, but is it also worth considering svelte style components? https://svelte.dev/

e.g. Instead of having a sidecar file, you would define the style tag directly in the component and we would automatically extract it.

<%# the component erb template %>
<style>
.my_class {
  color: red;
}
</style>

<h1 class="my_class">Hello world!</h1>

Which could result in something like (omitting the style or link tag):

<h1 class="abc123_my_class">Hello world!</h1>

I don't think the two approaches are mutually exclusive, but thought it might be worth bringing up.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should eventually support "sidecar" components as well as "svelte" encapsulating components. Having both options would allow you to write little encapsulating components and large sidecar ones.

For simplicity's sake, we should only support sidecars in the beginning.


### No Webpacker

The proposed design avoids Webpacker and other build tools. Instead, it uses a Ruby implementation of CSS Modules built by @rmosolgo, enabling greater portability and a convention-over-configuration experience that Rails developers expect.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious how this will scale in Ruby compared to using an external process like webpack or some other build tool. Not a blocker since we can always find ways to optimize, but thought it was worth bringing up.

I also think we may have to revisit this if we look at JS encapsulation. Kind of related to No SCSS, I think that would be a great time to investigate something like postCSS too. 🤔

lib/view_component/stylable.rb Outdated Show resolved Hide resolved
@@ -10,7 +10,7 @@ def compiled?
CompileCache.compiled?(component_class)
end

def compile(raise_errors: false)
def ensure_compiled(raise_errors: false)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we have a separate compile method that always compiles?

Maybe compile!?

def test_it_doesnt_rewrite_bare_element_selectors
before_css = "span {\n background: rbga(255, 255, 255, 0.8);\n}"
after_css = "span {\n background: rbga(255, 255, 255, 0.8); }\n"
assert_rewrite("item", before_css, after_css)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This allows for global styles, can we make this raise to disallow escaping the component scope?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to add some sort of special selector to target global styles like other frameworks do? e.g. :global(span)

self.class.styles
end
end
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
end
end

def commenter_name
commenter.name
def call
content_tag(:div, "Hello, World!", class: styles['foo'])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
content_tag(:div, "Hello, World!", class: styles['foo'])
content_tag(:div, "Hello, World!", class: styles.foo)

Thoughts on using methods instead of hashes? I think API wise, methods are a little easier to use plus it gives us the ability to later "enhance" the style methods with additional functionality. e.g. Finding unused CSS in the test environment

adr/0677-css-encapsulation.md Outdated Show resolved Hide resolved

### No SCSS

We're not very sure about this one, but we're playing with the idea of just using plain CSS, at least to start.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I understanding the code correctly that we are using a SASS compiler to generate and traverse the CSS AST?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it seems. I was also a bit surprised when I saw the added sass dependency.


We considered allowing for evaluation of Ruby inside CSS files (at compile, runtime, or both) but instead decided to lean on [CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) for injecting dynamic values.

### Bundle splitting

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should eventually support "sidecar" components as well as "svelte" encapsulating components. Having both options would allow you to write little encapsulating components and large sidecar ones.

For simplicity's sake, we should only support sidecars in the beginning.


## Consequences

o, avoiding several common errors and resulting in more resilient user interfaces.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like there's text cut off here @joelhawksley?

Co-authored-by: Kristján Oddsson <[email protected]>
@joelhawksley
Copy link
Member Author

joelhawksley commented Apr 8, 2021

@koddsson @BlakeWilliams @jaredcwhite @Spone @elia @keithamus @srt32 thanks for taking the time to review!

Should this be a part of ViewComponent at all?

I can think of some reasons why VC could provide a few hooks in the base class(es) so that this particular CSS solution would function through a separate gem. That way (a) it's optional, and (b) others could build different solutions in a similar fashion. - @jaredcwhite

I think this is a fair point, Jared. As proposed here, one would need to include the Stylable module, but I'd like to see us offer some sort of built-in solution eventually. We should make it easy to opt out of this behavior.

Class naming conventions

There is a lot of subtle complexity here, and the proposed approach is ultimately quite naive. For example, it does not consider the case where a ViewComponent's CSS is updated, but a stale copy of the ViewComponent exists in a view cache. For that reason and others, I expect we'll eventually want to use a hash of the ViewComponent and its sidecar files to generate the fingerprint.

API for Slim templates

@Spone, thanks for bringing this up! We use ERB exclusively at GitHub, so it's easy to miss these kinds of things. Given the desire to have fingerprinted CSS selectors, could you suggest how we might implement them in a more Slim-friendly fashion?

Third-party CSS

@Spone, I don't think this proposed architecture would work in that case. I'd expect a consumer of a library like Flickity to write ViewComponents that wrap its behavior. But perhaps you're suggesting that there might be a way to fingerprint third-party CSS?

Caching

Using params to store the list of compiled CSS might not play well with caching. Using a current attribute with a Set in it would probably look better, but I'm not sure it solves the problem. - @elia

🤦 I had a feeling that the params approach was a bit naive. Let's remove it for now, then.

How can we support porting existing SCSS code and running tools like postCSS and CSSLint?

I think that autoprefixer-rails might be our ticket here. It already uses PostCSS under the hood, and I'd love to see us come up with a convention-over-configuration solution to this problem. Got any ideas here @palkan?

Ship the collected CSS of all existing components

@elia, you're right. For a lot of applications, this is probably the correct approach.

@palkan
Copy link
Contributor

palkan commented Apr 9, 2021

Hey everyone!

I think that autoprefixer-rails might be our ticket here.

I would say "I don't think so". Here is how autoprefixer-rails works: it ships with the prebuilt JS script containing both PostCSS core and Autoprefixer (PostCSS plugin). During assets compilation, it just calls JS runtime (via ExecJS) to run this script against a CSS text.

Packing every single PostCSS plugin this way doesn't sound like a good plan.


Now, let me share my take on other questions.

First of all, I love the idea of using CSS Modules as well as inlining <style> along with the template. That's brilliant.

Should this be a part of ViewComponent at all?

I'm a bit worried about additional runtime dependencies (currently, sass). If we can make this dependency optional (similarly, for example, like Rails does for redis in Action Cable), that would be a good compromise, in my opinion.

However, abstractizing Stylable and #styles also makes sense. The current implementation could be one of the possible adapters. And I already have another one here 🙂.

Class naming conventions

Maybe, there is already the answer to the question in the thread but: why can't we rely on the component name only? E.g., <configurable-namespace>-<component_name.parameterize>-<class>.

API for Slim templates

I've been thinking of using shortcuts for that, smth like:

p.regular-class$container // expands into <p class="regular-class #{styles['container']}"></p>

Not sure that this is possible but I would like to experiment with this.


And the last but not least.

No Webpacker

I'm a big fan of the classic Rails way and HTML-first approach. But ignoring the fact that frontend ecosystem has greatly progressed since the Golden Age of Asset Pipeline (like, about 10 years ago). PostCSS and others changed the way we write CSS. Tailwind is getting more and more popular in the Rails community (and not everyone hates @apply).

I think the statement "enabling greater portability" is over-estimated. A lot of projects won't be able to use Stylable due to the dependency on PostCSS, Autoprefixer or whatever.

That brings us back to the question whether this particular implementation should be the default one or not. Maybe, we can provide a common API to work with both Ruby CSS Modules and postcss-modules? That would allow us to satisfy everyone.

@Spone
Copy link
Collaborator

Spone commented Apr 9, 2021

API for Slim templates

I've been thinking of using shortcuts for that, smth like:

p.regular-class$container // expands into <p class="regular-class #{styles['container']}"></p>

Not sure that this is possible but I would like to experiment with this.

Quick try: Spone/slim#1 (requires some minor changes to the slim parser, see the diff for details)

~text Lorem ipsum
/ <div class="Styled_6c266_text">Lorem ipsum</div>

p~text.is-big LOREM IPSUM
/ <p class="Styled_6c266_text is-big">LOREM IPSUM</p>

@Spone
Copy link
Collaborator

Spone commented Apr 26, 2021

To follow up on Third-party CSS, here is an example to make it a bit more concrete. Let's consider a GalleryComponent that is a wrapper around Flickity, so the component requires both the JS and CSS from Flickity.

Currently, I see 4 possible approaches:

  • #1 Using the provided CDN obviously works out of the box if you add the <script> and <link> to your layout (but it's up to you to find a way to include them only if the component is used on the page)
  • #2 Install the library through NPM, then require it with the Asset Pipeline:
//= require flickity
  • #3 Same as above but with Webpacker, you would then import the assets in the component JS:
// app/components/gallery_component/gallery_component.js
import Flickity from "flickity";
import "flickity/dist/flickity.css";
  • #4 Finally, you can vendor the assets in your component sidecar folder, but then you have a duplication problem when multiple components depend on the library (and you can't update the library easily).

In approaches #2 and #3, you still need a bundle of JS and CSS alongside your inlined components' CSS (and that's very probably what most projects will have anyway, if they have legacy CSS, or use a CSS reset, default values, variables, utility CSS...).

To sum up, I'm not sure we need to tackle third-party CSS encapsulation (ie. alter the selectors), at least not in the initial implementation.

(@joelhawksley you mentioned fingerprinting, in the case of NPM packages, maybe the integrity attribute in package-lock.json or yarn.lock could be of some use?)

@Spone Spone mentioned this pull request May 10, 2021
23 tasks
if !rendered_components.include?(rendered_components_identifier)
request.params["___rendered_view_components"] << rendered_components_identifier

content_tag(:style, self.class._css.html_safe)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I saw that github.com uses the permissive content security policy style-src 'unsafe-inline' github.githubassets.com.

I suppose unsafe-inline is then required for anyone using these styleable view components because of the rendering of <style>...</style> directly into HTML.

@tomasc
Copy link
Contributor

tomasc commented May 16, 2021

I have been running CSS modules with ViewComponents for about a year now — based on this pattern: https://daniel.heath.cc/2018/07/02/css-modules-with-webpacker

Based on my experience an important part of the CSS modules idea is the composes directive — which requires a global index of class names and their encoded forms, so that they can be easily referenced.

Recently I switched back to Sprockets and simply output reference to each components stylesheet tag along with the component. So far works great, reaping the benefits of HTTP/2.

At the same time I had to implement my own CSS modules Sprockets postprocessor which I would be happy to share, along with my update of the above referenced CSS modules implementation, if anyone is interested.

It would be fairly easy to extract both as gems, which could be independent of the view component gem.

@jaredcwhite
Copy link
Contributor

I have to admit a large amount of trepidation around VC recommending a "CSS Modules" pattern, which by-and-large I consider an anti-pattern. I think it's reasonable to say VC shouldn't promote any particular flavor of how to write CSS. But if it were to do so, I would advocate for a design system based on CSS Custom Properties combined with custom element names for individual components to scope styles, plus Shadow DOM where true encapsulation is required. A far better—and more authentically "modern"—pattern, IMHO, and it requires little-to-none additional tooling.

It's fine if you want to disagree with me, but that's actually my point…I don't see why either flavor of CSS should somehow get "blessed" by the project. My 2c FWIW.

@existentialmutt
Copy link

Really cool to see this happening! Scoped CSS is a very compelling feature. I work on a few long-lived apps that have been phasing in ViewComponent, and I would love to start replacing our mess of BEM/SMCSS stylesheet partials with something like this.

Reading over the proposal, here's a few points of feedback I can offer:

  • It would be really nice to have an option for SCSS that could integrate global variables and mixins.
  • How much performance overhead does it add to generate styles in ruby at request time compared to precompiling everything?
  • Maybe I'm missing something, but won't inlining a style tag every time the component is rendered result in redundant CSS for components that are called multiple times on a page?

Our shop only recently transitioned to using webpacker for building css. Long term I'm intrigued by the idea of moving it back to a ruby-based tool. Short term my team is going to prefer a solution that hooks into our webpack setup, with an eye towards transitioning to a ruby-based solution as it matures.

@Spone
Copy link
Collaborator

Spone commented Jun 7, 2021

@existentialmutt good points!

Maybe I'm missing something, but won't inlining a style tag every time the component is rendered result in redundant CSS for components that are called multiple times on a page?

Actually, the clever thing is that it's only inlining the style tag after the first render of the component on the page, see: https://github.com/github/view_component/blob/e0c1e7b4c092eebabea8531227a8c0dd0cf5c294/lib/view_component/styleable.rb#L62-L64

@tomasc
Copy link
Contributor

tomasc commented Jun 8, 2021

@Spone This has its major pitfalls though: Imagine a list of items, style is inlined in the first one, the first one gets removed (for example the user deletes it via UI), and … oops.

@Spone
Copy link
Collaborator

Spone commented Jun 8, 2021

@Spone This has its major pitfalls though: Imagine a list of items, style is inlined in the first one, the first one gets removed (for example the user deletes it via UI), and … oops.

The style tag is not in the component, but just after it, so if you remove the component root element, the style will stick around.

@tomasc
Copy link
Contributor

tomasc commented Jun 8, 2021

@Spone I see, thanks!

@joelhawksley
Copy link
Member Author

👋🏻 Hi folks! Thank you for all the feedback so far. We are continuing to experiment in this space internally, and I hope to have more to share as we try more approaches.

The main concern on my mind at the moment is trying to come with an approach that just works and feels conventional in the way that most of Rails does. I recognize that this is quite a challenge when it comes to assets as there seems to be less consensus on on conventions in that area in the Rails ecosystem.

@collimarco
Copy link

I find really interesting the possibility of using CSS Modules with ViewComponents. This avoid possible conflicts with names in the global namespace.

Are there any updates? Or have you found better solutions?

@tomasc
Copy link
Contributor

tomasc commented Oct 14, 2023

@collimarco https://github.com/rails/tailwindcss-rails

Slightly different approach, but works well with components.

@jaredcwhite
Copy link
Contributor

jaredcwhite commented Oct 17, 2023

@collimarco My 2c: the vanilla CSS way to avoid conflicts in the global namespace is to use custom elements and selectors based off of those (my-app-card > header and that sort of thing)—or for even more control, you can use Declarative Shadow DOM and write truly encapsulated HTML & CSS. I've used both of those techniques along with ViewComponent and also similarly-handled components in the Bridgetown framework. It's rad. ☺️

I also would recommend avoiding Tailwind. I've had nothing but problems with it across multiple projects, and it's particularly ill-suited for use in a stack-agnostic design system built atop modern web standards. Much better to reach for tools like Open Props (a fantastic set of CSS variables you can use to build your own components) or full-on web component libraries like Shoelace—either way future-proofing your efforts for decades to come.

@tomasc
Copy link
Contributor

tomasc commented Oct 17, 2023

@jaredcwhite out of curiosity -- can you please be more specific about what makes Tailwind ill-suited? I have recently switched to Tailwind on a fairly large codebase and so far I am having fun. Curious to hear what issues might lie ahead. Thanks!

@Spone
Copy link
Collaborator

Spone commented Oct 17, 2023

Please, let's not hijack this issue with a Tailwind pros and cons argument 🙂

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.