CSS Variable Namespacing#67362
Conversation
53771f2 to
766a1df
Compare
|
can we please use the official name "CSS Custom Property"? This makes it more discoverable and less ambiguous with SCSS variables |
This allows Angular to apply a namespace to any CSS variables based on runtime configuration. In most situations, the `APP_ID` followed by a separator (`-` or `_`) is likely sufficient as a namespace.
```typescript
bootstrapApplication(Root, {
providers: [
{
provide: APP_ID,
useValue: 'my-app',
},
// Variables like `--foo` will become `--my-app_foo`.
provideCssVarNamespacing('my-app_'),
],
});
```
This is implemented by having the compiler unconditionally transform `--foo` to `--%NS%foo` to ensure a consistent output format, and then replace the `%NS%` at runtime with the namespace given to `provideCssVarNamespacing` (or an empty string for a no-op).
Adds the CssVarNamespacer utility service for prefixing CSS variables with MicA namespace dynamically.
mattrbeck
left a comment
There was a problem hiding this comment.
Taking a first stab at this, sorry for the delay in the review. One question and one nitpick:
This only namespaces styles in Angular components (the
stylesorstyleUrlsproperties in@Component). It does not namespace global styles, which are out of scope for this effort.
I think we should probably also support style property binding. It's admittedly somewhat contrived, but I'd expect this to work.
template: `<p [style.--my-color]="'blue'"> this is blue </p>`,
styles: `
:host { --my-color: red }
p { color: var(--my-color) }
`
but with an additional separator at the end (a
-or_)
I feel like we might always want a separator just for ease of debugging in the browser devtools. Could we just unconditionally stick one in there for the user?
| const _cssColonInPlaceholderReGlobal = new RegExp(COLON_IN_PLACEHOLDER, 'g'); | ||
|
|
||
| // Matches `/* DISABLE_NAMESPACING */` optionally followed by any characters | ||
| // that are not a semicolon or closing brace (e.g. whitespace or a property name), |
There was a problem hiding this comment.
Nit: Semicolon, closing brace, or opening brace.
| // that are not a semicolon or closing brace (e.g. whitespace or a property name), | ||
| // up until it hits a CSS variable `--some-name`. | ||
| const _cssNamespaceRe = new RegExp( | ||
| String.raw`(?:(/\*\s*DISABLE_NAMESPACING\s*\*/)[^;{}]*?)?(--[a-zA-Z0-9_-]+)`, |
There was a problem hiding this comment.
Voicing my preference for --global-some-name one more time. I haven't come up with an example of how this regex might break, but it just feels brittle to me to be scanning the CSS file in this way. With --global-foovar/ we have a very clear picture of what to search for.
I also think using a comment as a pragma will make big blocks of variable references uglier if every other line requires a comment node.
This certainly isn't a hill I'll die on and I understand the concern of having variable references not syntactically matching their declaration (if declared in a global stylesheet), but I feel like the --global- prefix makes more sense to me.
There was a problem hiding this comment.
Just to be clear, you're envisioning that --some-name would get renamed to --%NS%some-name while --global-some-name would get renamed to --some-name?
I don't mind the global-* prefix as an indicator of opt-out, but I do think it's confusing that we're "not namespacing" the value while still renaming it. If we left it alone as --global-some-name, then that would be intuitive to me, but that requires all affected variables to be able to be renamed, which I don't think is a safe assumption given many libraries define their own variables like --primary-color without thinking through this use case.
This can also be confusing if I want to find usages of --primary-color and grep for --primary-color and conclude it's not used only to later find that --global-primary-color was used in an Angular app somewhere.
I agree /* DISABLE_NAMESPACING */ is awkward, but I think the benefit of it is that it still allows you to use the intended name variable name directly in source, which is useful for clarity and "Find references"-style use cases.
There was a problem hiding this comment.
I'm happy to run this by the broader team just to get general opinions on the syntax.
There was a problem hiding this comment.
Totally hear you on the concern for tooling. I'm definitely not in love with either approach, and I'm happy to go with whatever the team prefers.
There was a problem hiding this comment.
Closing the loop here, turns out /* DISABLE_NAMESPACING */ isn't viable because Sass generally wants you to use // DISABLE_NAMESPACING comments which then get elided by the compiler. It's possible to keep a comment like this, but you're generally fighting against the tooling rather than working with it.
We considered a few options, but ultimately landed on --global--* as the best immediate option, so it looks like:
:host {
--global--foo: red;
color: var(--global--foo);
}- This aligns with
:globalselector syntax most frameworks use and-global-*syntax in particular used by Svelte for CSS names. - It's short and straightforward.
- It's local to the usage site of the symbol (no "spooky action at a distance").
- The extra
--between the prefix and variable name makes this still searchable. So grepping for--foowill still find--global--foo. - We'll emit an error for
--global-fooas that seems like a very easy mistake to make.
| // that are not a semicolon or closing brace (e.g. whitespace or a property name), | ||
| // up until it hits a CSS variable `--some-name`. | ||
| const _cssNamespaceRe = new RegExp( | ||
| String.raw`(?:(/\*\s*DISABLE_NAMESPACING\s*\*/)[^;{}]*?)?(--[a-zA-Z0-9_-]+)`, |
There was a problem hiding this comment.
FYI, attribute and class names can contain --. I'm not sure how much this is used in practice or if there are other , but this will break selectors like [--foo] and .--foo. Maybe this is also a problem in keyframes names?
|
|
||
| styles = shimStylesContent(component.id, styles); | ||
| styles = shimStylesContent(component.id, styles).map((s) => | ||
| s.replace(/%NS%/g, cssVarNamespace), |
There was a problem hiding this comment.
Maybe we can do this just once when we first look at the files since the %NS% won't change, unlike the component ID? Do we need to do this replacement on every component instantiation?
| let hasStyles = !!meta.externalStyles?.length; | ||
| // e.g. `styles: [str1, str2]` | ||
| if (meta.styles && meta.styles.length) { | ||
| const namespacedStyles = meta.styles.map((s) => namespaceCssVariables(s)); |
There was a problem hiding this comment.
Apologies if we've talked about this already and I'm forgetting, but have we considered making this a build-time transform rather than compile-time? Could we even just generate a purely unique ID at build time and use it for the entire app, then skip the runtime replacement with %NS% entirely?
There was a problem hiding this comment.
have we considered making this a build-time transform rather than compile-time
I assume you mean "rather than runtime"? I did look into this, and it would probably work in the 3P environment. However, in 1P we have a few extra constraints.
- Changing variable names is a breaking change (hence the need for
DISABLE_NAMESPACING), so we can't enable this unconditionally, we need some kind of opt-in, whether that's a compiler flag or a runtime flag. - The namespace may not be known at build time. In the MicA case, app ID is probably known, but binary ID is not known until the release is published, and that would probably be needed to be truly isolated.
- We're generally limited to having a single set of build flags throughout g3. That's not strictly true, but making this conditional means that you either need to say
enable_css_namepsacing = "my-app"on everyng_module(which would break libraries used by multiple apps who can't choose a single namespace) or you need a Bazel transition and the build story gets a lot more complicated.
I do agree with the general desire that this should be done at build time, but practically speaking that makes the feature much more complicated and I think it would be unlikely to be feasible in google3 based on the way our build tooling works. I'm inclined to start with a runtime transform and then potentially switch to a build-time transform later if/when we have a clearer need and path towards it in google3.
There was a problem hiding this comment.
I assume you mean "rather than runtime"?
👍
Changing variable names is a breaking change (hence the need for
DISABLE_NAMESPACING), so we can't enable this unconditionally, we need some kind of opt-in, whether that's a compiler flag or a runtime flag.
No dispute with the need for a flag.
The namespace may not be known at build time. In the MicA case, app ID is probably known, but binary ID is not known until the release is published, and that would probably be needed to be truly isolated.
Does the namespace need to be the app/binary ID, or is any ID sufficient so long as it's unique to a single build of the app? Maybe we could just use an ID we generate at build time?
you need a Bazel transition and the build story gets a lot more complicated
I agree that this is the tricky part. I'm not sure how we'd define a custom ID in g3 that would be available across the whole build. You're much more knowledgable here than I am, so I trust your thoughts on it.
I'm inclined to start with a runtime transform
Seems totally reasonable to me.
There was a problem hiding this comment.
Does the namespace need to be the app/binary ID, or is any ID sufficient so long as it's unique to a single build of the app? Maybe we could just use an ID we generate at build time?
MicA has to account for SSR-ing two versions of the same application at the same time while in the middle of a deployment (one request happens to go to app version X, another request happens to go app version X + 1). In that scenario, I don't think any information known at build time would be sufficient. That's a very MicA-specific concern, but it's the constraints we're working within.
I agree that this is the tricky part. I'm not sure how we'd define a custom ID in g3 that would be available across the whole build. You're much more knowledgable here than I am, so I trust your thoughts on it.
I forgot to link to the relevant part of the internal doc which discussed this. A transition is definitely plausible (we did it for Ivy), but also very much complexity we'd rather avoid. If we receive sufficient push back from g3 customers, then that's a sign that the performance impact is significant enough to consider re-evaluating the complexity here.
|
That is a good point, I hadn't considered that case. I'll call that out of scope for this PR, but I do agree that's a valid use case which should be renamed. I'll follow up on that one. I remember seeing this and was going to respond with it but couldn't find the link, so thanks for sharing that. I honestly don't have strong feelings about the naming as long as it's clear what it refers to and |
| // that are not a semicolon or closing brace (e.g. whitespace or a property name), | ||
| // up until it hits a CSS variable `--some-name`. | ||
| const _cssNamespaceRe = new RegExp( | ||
| String.raw`(?:(/\*\s*DISABLE_NAMESPACING\s*\*/)[^;{}]*?)?(--[a-zA-Z0-9_-]+)`, |
There was a problem hiding this comment.
AGENT: The current regex (--[a-zA-Z0-9_-]+) will match -- anywhere in the CSS string, not just at the start of a variable declaration or var() call. This means it will incorrectly match and replace BEM style CSS classes (e.g., .block--modifier becomes .block--%NS%modifier which breaks the styles). We should ensure the -- is bounded appropriately (e.g., not preceded by another word character or hyphen). Also, adding a test case in shadow_css_spec.ts for classes with -- in the middle would be a great way to prevent this from regressing.
There was a problem hiding this comment.
This feels unlikely to be a big problem imo. One more reason I'm investigating using a real parser, though 👀
|
|
||
| styles = shimStylesContent(component.id, styles); | ||
| styles = shimStylesContent(component.id, styles).map((s) => | ||
| s.replace(/%NS%/g, cssVarNamespace), |
There was a problem hiding this comment.
AGENT: Regarding the %NS% placeholder discussed above: Another edge case to consider is if a user has %NS (or %NS%) naturally occurring in their CSS content strings (e.g. content: "100%NS") or background URLs. Doing a global replacement might inadvertently modify those strings as well. Using a highly-unique continuous token like __NG_CSS_VAR_NAMESPACE__ would completely mitigate this risk without adding noticeable overhead.
This adds CSS variable namespacing support to Angular.
This allows multiple apps to coexist on the same page with isolated CSS variables, meaning one can use
color: var(--primary-color);without worrying about accidentally inheriting the primary color of a different app which happens to set it on an ancestor element.To enable this feature, call
provideCssVarNamespacingin yourapp.config.ts. Typically you want to configure this with the same value asAPP_ID, but with an additional separator at the end (a-or_):This only namespaces styles in Angular components (the
stylesorstyleUrlsproperties in@Component). It does not namespace global styles, which are out of scope for this effort.Namespacing does naturally break any JavaScript references to CSS variables, therefore this PR also introduces
CssVarNamespacerwhich allows you to automatically namespace variables based on what is configured in the application.Libraries should consider always using the namespacer when referring to CSS variables, as they may be consumed by applications which enable namespacing.
Namespacing works by having the compiler unconditionally prepend
%NS%to CSS variables (--foo->--%NS%foo) and then at runtime replaces%NS%with a namespace specified byprovideCssVarNamespacing('my-app_')(--%NS%foo->--my-app_foo).Internal bug: b/485672083