-
Notifications
You must be signed in to change notification settings - Fork 10
feat!: enforce CSS ownership with cascade layers #234
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
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,39 +1,59 @@ | ||
| # Shell stylesheet must be imported in site.config file. | ||
| # Shell style manifest must be imported in site.config file. | ||
|
|
||
| ## Summary | ||
|
|
||
| A project must import the stylesheet for the application shell in its site.config file. | ||
| A site must import the shell's style manifest (`@openedx/frontend-base/shell/style`) from its site.config file. | ||
|
|
||
| ## Context | ||
|
|
||
| There is a particular quirk of the stylesheet loaders for webpack (style-loader and/or css-loader) where the import of stylesheets into JavaScript files must take place in a JS file in the project, not in library dependency like frontend-base. Further, the stylesheet imported into JS must _itself_ be a part of the project. | ||
| A composing site needs a single, project-owned entry point for its global CSS. Two concerns drive this: | ||
|
|
||
| If, for instance, we try to import a stylesheet from frontend-base (shell, header, footer, etc.) inside a React component inside the shell, webpack silently ignores the import and refuses to load the stylesheet. If we try to import a stylesheet from frontend-base directly into the site.config file in the project, that will also fail with webpack silently ignoring the stylesheet. If, however, frontend-base exports the stylesheet and it's loaded into a SCSS file in the project and _that_ is imported into site.config, everything works correctly. | ||
| 1. **CSS ownership.** Paragon's base CSS declares CSS custom properties on `:root`. If a lazy-loaded app chunk ships its own copy of Paragon's base CSS, its `:root` declarations run after the site's brand overrides and clobber them globally. The composing site must therefore be the **single owner** of shell, Paragon base, and brand CSS; apps must not re-bundle these. Having the site import the shell's style manifest from its site.config makes this ownership explicit and keeps the rule easy to audit. | ||
|
|
||
| This slight indirection through a SCSS file in the project is necessary, and arguably desirable. It ensure as common, unified entry point for SCSS from dependencies of the project. SCSS from the project or micro-frontend itself can be imported into its own components, or can be imported into this top-level SCSS file as desired. Further, this ensures that every aspect of the style of a project or MFE can easily be customized since the stylesheet is supplied through the site.config file. | ||
| 2. **Layer classification by resource path.** The build pipeline wraps each stylesheet in a CSS cascade layer based on where it was resolved from. For that to classify Paragon's base CSS into the `shell` layer rather than the layer of whichever SCSS file happened to `@use` it, each import must be its own webpack module. The shell's style manifest is a small TS file that imports Paragon's CSS and the shell's own SCSS side by side, so every entry is seen by webpack as an independent compilation unit and classified on its own merits. | ||
|
|
||
| 3. **PostCSS-pass scoping.** Paragon exposes responsive breakpoints as `@custom-media` declarations, substituted at build time by `postcss-custom-media`. Substitution only works if the declarations are visible to the same PostCSS pass as the `@media` rule that references them. The shell manifest pulls Paragon's core CSS into the site's build, so any `@media (--pgn-size-breakpoint-*)` reference in a site-level stylesheet resolves correctly. App stylesheets are separate PostCSS passes and handle this themselves; see [the theming guide](../how_tos/theming.md#custom-media-breakpoints) for details. | ||
|
|
||
| An earlier version of this ADR claimed that webpack's stylesheet loaders silently ignore imports sourced from library dependencies. That is no longer accurate with the current loader configuration (see `tools/webpack/common-config/all/getStylesheetRule.ts`). Importing the manifest from site.config remains the correct pattern, but for the ownership, classification, and scoping reasons above rather than a loader quirk. | ||
|
|
||
| ## Decision | ||
|
|
||
| As a best practice, a project should have a top-level SCSS file as a peer to the site.config file. This SCSS file should import the stylesheet from the frontend-base shell application. It should, in turn, be imported into the site.config file. | ||
| A project's site.config file must import the shell's style manifest (`@openedx/frontend-base/shell/style`). If the site has additional global styles, it can import its own SCSS file from site.config alongside (or in place of) a brand package. | ||
|
|
||
| ## Implementation | ||
| The shell manifest must be imported **only once**, by the composing site. Apps that are consumed by a site must not import the manifest (or any other source of Paragon base styles) from their runtime code, because doing so causes lazy-loaded app chunks to re-declare Paragon's `:root` CSS custom properties and clobber the site's brand overrides globally. Apps may still import the manifest from `site.config.dev.tsx` (not shipped in `dist/`) so that they render correctly when run standalone. See [the theming guide](../how_tos/theming.md#css-ownership) and [the migration guide](../how_tos/migrate-frontend-app.md#separate-runtime-styles-from-the-dev-harness) for details. | ||
|
|
||
| The `site.scss` file should import the stylesheet from the shell: | ||
| ## Cascade layers | ||
|
|
||
| ```diff | ||
| + @import '@openedx/frontend-base/shell/app.scss'; | ||
| The build pipeline wraps each stylesheet in a CSS cascade layer based on the resolved resource path: | ||
|
|
||
| // other styles | ||
| ``` | ||
| | Layer | Sources | | ||
| | --------- | ----------------------------------------------------------------- | | ||
| | `paragon` | `@openedx/paragon` | | ||
| | `shell` | `@openedx/frontend-base` | | ||
| | `app` | any other stylesheet resolved from `node_modules` | | ||
| | `site` | stylesheets outside `node_modules` (the composing site's source) | | ||
| | `brand` | `@(open)?edx/brand*` packages | | ||
|
|
||
| The order declared at the top of `shell/style.scss` is `@layer paragon, shell, app, site, brand;` so the cascade resolves in that order: `brand` wins over `site`, `site` wins over `app`, `app` wins over `shell`, and `shell` wins over `paragon`. | ||
|
|
||
| The site.config file should then import the top-level SCSS file: | ||
| `brand` is last because, in production, brand CSS is injected at runtime via `<link>` tags that bypass webpack entirely and therefore land **unlayered**. Unlayered rules beat every layered rule regardless of declared order, so runtime brand wins over the site's own CSS. Putting build-time brand imports (e.g. a dev harness that `@use`s a brand package directly) in the last layer keeps dev harness behavior consistent with production: brand overrides apply on top of everything the site declares. | ||
|
|
||
| This is enforced by a PostCSS plugin (`tools/webpack/common-config/all/postcssWrapLayer.ts`) applied as the final step of the CSS pipeline. `@charset`, `@import`, `@use`, `@forward`, and existing `@layer` nodes are preserved at the root; everything else is moved into the layer block. | ||
|
|
||
| Apps must still follow the ownership rule above. The layering is a safety net: if an app chunk does re-ship Paragon's `:root` declarations, its `app`-layer block loses to the site's `site`-layer tokens and any brand overrides. Relying on the safety net rather than the ownership rule still wastes bytes on the wire. | ||
|
|
||
| ## Implementation | ||
|
|
||
| The site.config file should import the shell's style manifest: | ||
|
|
||
| ```diff | ||
| + import './site.scss'; | ||
| + import '@openedx/frontend-base/shell/style'; | ||
|
|
||
| const siteConfig = { | ||
| // config document | ||
| } | ||
|
|
||
| export default siteConfig; | ||
| ``` | ||
|
|
||
| If the site has its own global styles, it can add an `@use` or `@import` of a local SCSS file after the manifest import. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| /* | ||
| * Shell style manifest. Each import is a separate webpack module, which | ||
| * keeps Paragon's CSS and the shell's own SCSS as independent compilation | ||
| * units: the build pipeline wraps each in the `shell` cascade layer by | ||
| * resource path. See ADR 0008. | ||
| */ | ||
| import '@openedx/paragon/dist/core.min.css'; | ||
| import '@openedx/paragon/dist/light.min.css'; | ||
| import './style.scss'; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.