Skip to content

Instantly share code, notes, and snippets.

@kiwiupover
Last active February 22, 2023 20:15
Show Gist options
  • Save kiwiupover/df28862ce9e97224b6a063a1fdf0d274 to your computer and use it in GitHub Desktop.
Save kiwiupover/df28862ce9e97224b6a063a1fdf0d274 to your computer and use it in GitHub Desktop.
Engineering Design Document for ember-scoped-css

Scoped CSS

Executive Summary

As we work towards adopting Embroider and the V2 addon structure, ember-css-modules has become a blocker due to a lack of embroider support. This has allowed us to re-evaluate our use of ember-css-modules and hopefully replace it with something more performant.

Motivation & Background

The primary goal up to now for the use of ember-css-modules was to eliminate the cascade of CSS. By eliminating the cascade and coupling the CSS to the component that it is used in, we can write better-structured and more maintainable CSS.

When ember-css-modules was first adopted, the application was still required to support IE11. CSS Modules and its @value declarations supported re-usable variables without the use of custom properties and without the order-driven madness that SCSS provided. We have since stopped supporting IE11 and stopped using the@value declaration. We now exclusively use native custom properties.

While ember-css-modules has also provided many features around importing and accessing CSS classes in JS + composing CSS classes, we have never fully adopted them as best practices, and their use in our app is very limited.

Adopting a module approach to CSS has not been without a cost. ember-css-modules is a run-time solution that significantly impacts our bundle size. It achieves its scoping behaviour by renaming every class name in CSS and exporting a map of the old class to the new class in JS. Thus when you try to assign a class name to an element, it will do a runtime lookup to find the updated scoped class name.

Any alternative implementation should be a purely build-time system.

Proposed Alternative Implementation

We propose creating a new Ember add-on called ember-scoped-css that isolates CSS to one component, not allowing styles to leak outside that component or down into any child components.

The benefit of this is that every developer can name their classes as they want without worrying about name collisions.

For example, if there is bootstrap used in the application, you can still name your component class .alert without worrying that it would clash in any way with the bootstrap class. The developer can be sure that the styles will be applied only to their component and not the whole application.

The philosophy is to stick with the CSS and HTML as much as possible and not introduce new syntax or concepts.

The key ideas of ember-scoped-css are:

  • CSS files will be co-located with components so the addon will know which CSS file belongs to which component.
  • all CSS selectors will be renamed at build time to prevent styles from leaking out of the component
  • there will be a set of lint rules to check if all selectors are valid and if they are used in a template to simplify the developer experience
  • there will be support for scoped css with .gjs files using a <style> tag
  • :global(.text-center) can be used to target global classes or classes from any third party CSS

Here is a simple example of how ember-scoped-css works. There is a more detailed explanation later in this document.

Input

<!-- components/first.hbs -->
<p class="my-class">...</p>
<div>...</div>
/* components/first.css */
.my-class{ ... }
div{ ... }

Output

<!-- components/first.hbs -->
<p class="my-class_generated-first">...</p>
<div class='generated-first'>...</div>
/* components/first.css */
.my-class_generated-first{ ... }
div.generated-first{ ... }

Detailed explanation of the workings of ember-scoped-css

  1. Styles don't leak out of components or into components from global styles
  2. Nested components can only be styled by passing a class into a component
  3. Ability to use selectors in unit tests
  4. Ability to target global classes or classes from the third party CSS
  5. Incremental refactoring to ember-scoped-css from ember-css-modules
  6. Ability to statically analyse all styles in CSS files and JavaScript
  7. Support scoping CSS styles for .gjs files

1. Styles don't leak out of components or into components from global styles

The main point of the ember-scoped-css addon is to isolate CSS to one component and not allow styles to leak outside the bounds of that component. The only way to do this is to:

  • rename classes (with suffix)
  • rename keyframes (with suffix)
  • adding generated classes to element selectors

You can see an example of leaking styles into components from a global CSS rule here.

Note in the demo application, the method titled simple-append is what ember-scoped-css will be using.

In scoped CSS files all classes and keyframes will be renamed (unless decorated with :global) regardless if they exist in the template or not to prevent unintentional leaking of styles. In the templates, only classes and element selectors existing in the scoped CSS file will be renamed.

Note: it's probably a bad idea to have unused CSS selectors in scoped CSS files so this is why we propose adding a linting rule to help catch this.

As an example, the following input code:

<!-- components/first.hbs -->
<p class="my-class">...</p>
<div class="text-center">...</div>
/* components/first.css */
.my-class{ ... }
@keyframe blink { ... }
div{ ... }

will turn to

<!-- components/first.hbs -->
<p class="my-class_generated-first">...</p>
<div class="text-center generated-first">...</div>
/* components/first.css */
.my-class_generated-first{ ... }
@keyframe blink_generated-first { ... }
div.generated-first{ ... }

The .text-center class is not renamed because it is not present in the CSS file.

2. Nested components can only be styled by passing a class into a component

The first version of ember-scoped-css won't allow CSS to target selectors in nested components. If there is a need to style a nested child component, then it will be possible to pass a class down into a component as long as that component has implemented ...attributes, and that class will also be transformed in a similar way to the rest of the classes Input:

<!-- app/components/foo.hbs -->
<div class="alert">
  <Bar class="nested-class"/>
</div>
<Bar/>
/* app/components/foo.css */
.alert.nested-class { ... }
@mminkoff mminkoff 4 days ago
I think you mean

Suggested change
.alert.nested-class { ... }
.alert .nested-class { ... }
Reply...
<!-- app/components/bar.hbs -->
<div class="alert" ...attributes></div>
/* app/components/bar.css */
.alert{ ... }

Output:

<div class="alert_generated-foo">
  <div class="alert_generated-bar nested-class_generated-foo"></div>
</div>
<div class="alert_generated-bar"></div>
.alert-generated-foo .nested-class_generated-foo { ... }
.alert_generated-bar { ... }

3. Ability to use selectors in unit tests

Sometimes you need to test if a selector was applied to specific parts of a component, ember-scoped-css will provide a test-helper function generated(selector: string, pathToCssFile: string) : string to allow you to tap into the same system for renaming classes. This will also feed into the “unused selector” lint rule because you are required to pass a selector into this function so we can know when it is being used in a JavaScript file. Note: this function will be transformed at build time (and used for static analysis), so it will not support passing variables into the function. It must always be used with a string literal. This also means you cannot use a template string in this function. Input

import { generated } from 'ember-scoped-css';
const aSelector = generated('.alert p', 'components/foo.css');

Output

import { generated } from 'ember-scoped-css';
const aSelector = '.alert_generated-foo p.generated-foo';

4. Ability to target global classes or classes from the third party CSS

Sometimes you need to interact with classes provided by 3rd parties outside your control. We can indicate a class should not be rewritten by wrapping it with :global(selector). Input

/* components/foo.css */
.alert :global(.text-primary){ ... }

Output

/* components/foo.css */
.alert_generated-foo .text-primary{ ... }

5. Incremental refactoring to ember-scoped-css from ember-css-modules

Incremental refactoring is essential for refactoring bigger projects to ember-scoped-css. There is a clear path for refactoring:

  1. All colocated CSS files will be renamed, adding an old_ prefix.
  2. ember-css-modules config will be adjusted to process only CSS files with the old_ prefix.
  3. ember-scoped-css will process regular colocated files without the old_ prefix.

6. Ability to statically analyse all styles in CSS files and JavaScript

As styles are co-located with templates, it is easy to check if classes and element selectors from the CSS file are used in the template. When you use the function const aSelector = generated('.bar', 'components/bar.css'), it is also clear how to transform and check if the styles are used in the template or even if the file still exists. This information will be used by the “unused selector” linting rule to determine if a portion of the CSS can be deleted.

7. Support scoping CSS styles for .gjs files

Scoped CSS support for .gjs files will be available with a <style> tag anywhere in the file. There will only be support for a single <style> tag, and adding a second will break the build with a message. All components exported from a single .gjs file will share the same CSS scope. CSS styles inside the .gjs file will be transformed at build time the same way as CSS styles inside co-located CSS files and will be included in the main CSS bundle. Here are a few examples of how .gjs files will be transformed:

Example 1: style and template tag

Input

<!-- app/components/foo.gjs -->
<style>
  .alert { ... }
</style>
<template>
  <div class="alert"></div>
</template>

Output

<!-- app/components/foo.gjs -->
<style>
  .alert_generated-foo { ... }
</style>
<template>
  <div class="alert_generated-foo"></div>
</template>

Example 2: style tag and multiple template tags

Input

<!-- app/components/foo.gjs -->
<style>
  .alert { ... }
</style>
export const First = <template><div class="alert">First</div></template>
export const Second = <template><div class="alert">Second</div></template>
export const Third = <template><div class="alert">Third</div></template>

Output

<!-- app/components/foo.gjs -->
<style>
  .alert_generated-foo { ... }
</style>
export const First = <template><div class="alert_generated-foo">First</div></template>
export const Second = <template><div class="alert_generated-foo">Second</div></template>
export const Third = <template><div class="alert_generated-foo">Third</div></template>

Example 3: style tag and template inside class-backed component

Input

<!-- app/components/foo.gjs -->
import Component from '@glimmer/component';
<style>
  .alert { ... }
</style>
export class First extends Component {
  <template>
    <div class="alert"></div>
  </template>
}
export class Second extends Component {
  <template>
    <div class="alert"></div>
  </template>
}

Output

<!-- app/components/foo.gjs -->
import Component from '@glimmer/component';
<style>
  .alert_generated-foo { ... }
</style>
export default class First extends Component {
  <template>
    <div class="alert_generated-foo"></div>
  </template>
}
export default class Second extends Component {
  <template>
    <div class="alert_generated-foo"></div>
  </template>
}

Complete end-to-end Example

The following example is a complete end-to-end representation of how the system will work. It shows an example of input and the output that it would generate. We also link to a codepen that shows the rendered result to give you a better idea of how it all works together.

Input

/* app/styles/app.css */
.text-primary{
  color: green;
}
<!-- app/components/foo.hbs -->
<div class='wrapper'>
  <h1>Heading</h1>
  <p class='text-primary'>The first paragraph.</p>
  
  <Bar class="nested-class">
</div>
/* app/components/foo.css */
@keyframe blink { 
  50% { opacity: 0; }
}
.wrapper { 
  background-color: #242424;
  padding: 15px;
  animation: blink 1s linear infinite;
}
.wrapper :global(.text-primary) {
  /* :global will not replace .text-primary class */
  font-style: italic;
}
.wrapper .nested-class {
  /* :generated function will replace .bar class the same way as in app/components/foo.css */
  color: green;
}
h1 { 
  text-decoration: underline;
}
<!-- app/components/bar.hbs -->
<p class='important' ...attributes>
  A paragraph.
</p>
/* app/components/bar.css */
.important { 
  color: red;
}
<!-- app/templates/application.hbs -->
<Foo />
<Bar />

Output

.text-primary{
  color: green;
}
@keyframe blink_generated-foo { 
  50% { opacity: 0; }
}
.wrapper_generated-foo { 
  background-color: #242424;
  padding: 15px;
  animation: blink_generated-foo 1s linear infinite;
}
.wrapper_generated-foo .text-primary{
  font-style: italic;
}
.wrapper_generated-foo .nested-class_generated-foo {
  color: green;
}
h1.generated-foo { 
  text-decoration: underline;
}
.important_generated-bar { 
  color: red;
}
<!-- app/templates/application.hbs -->
<div class="wrapper_generated-foo">
  <h1 class="generated-foo">Heading</h1>
  <p class="text-primary">The first paragraph</p>
  <p class='important_generated-bar nested-class_generated-foo'>
    A paragraph.
  </p>
</div>
<p class='important_generated-bar'>
  A paragraph.
</p>

Rendered example

Codepen Link

Appendix

Layers

In a previous version of this document, it was suggested that we would wrap the output of ember-scoped-css in a CSS @layer. The way that the CSS is scoped with ember-scoped-css should prevent the need for using CSS layers, but if it is still required, then we will make sure to document how to incorporate them when using this addon. We assume that it will be more of a concern of the application (when integrating ember-scoped-css) and not something that this addon needs to support explicitly. There will be some experimentation to verify that Ember and Embroider will support this workflow.

Other requirements

  • ids #ids are not rewritten and left alone.
  • Hot reload Changing the CSS file (in a way that wouldn't require a template to change) should support a hot reload of the CSS without requiring the app to reload.
  • App + addon usage The processor should work in both apps and v2 addons.
  • No JS file required It should not depend on a component having a backing class to work.

Scoped Route CSS

The first version of ember-scoped-css will focus only on components. There is no technical reason we cannot support routes in the first version, but there is a “conceptual problem”. The most logical place to co-locate a route's CSS would be with its template, but that would mean that there would be a disconnect from the current file structure in a default app, i.e. the controller, route, and template for a route are defined in app/controllers/route-name.js, app/routes/route-name.js, and app/templates/route-name.hbs respectively and it would seem odd to place the CSS co-located with the template in app/templates/route-name.css

@kiwiupover
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment