Background
From https://drafts.csswg.org/css-mixins/#result-rule
The mixin result is a scoped style rule, with the scoping root being the element the mixin’s styles are being applied to. (Unlike a traditional @scope rule, the scoping root here can be a pseudo-element, if the mixin is being applied to one.) There are no scoping limits.
One of the motivations for @macro was that because it does not return a scoping style rule, it's not bound by the same restrictions and can have more complex :has() conditions, sibling selectors etc.
IIRC the scoping style rule was introduced to make sure that local variables can be reliably used throughout the mixin. If the mechanics were that of naïve transclusion, this would not work:
@mixin --foo(--bar) {
@result {
border-color: var(--bar); /* works */
& + p {
border-color: var(--bar) /* --bar is not defined here */
}
}
}
Why? Suppose --bar gets hygienically rewritten to --local-c5f2d35, the actual applied rule would look like this (value is the value passed via the mixin):
& {
--local-c5f2d35: value;
border-color: var(--local-c5f2d35);
& + p {
border-color: var(--local-c5f2d35);
}
}
But --local-c5f2d35 would not inherit within & + p, so it would not be available.
Hence, the scoping.
However, I think having mixin rules be silently dropped because it's a scoping rule is incredibly confusing.
Consider this:
@mixin --foo(--arg <length>) {
@result {
width: var(--arg);
& + p { margin-inline-start: 0 }
}
}
Here, & + p would get dropped, even though it does not even use --arg!
Edit: I posted two polls on social media to test if my intuition reflects the majority expectation:
While we cannot reliably distinguish between A and B or C and D, since most authors missed the <length>, we can compare the two groups, and that gives a very strong signal that having rules be ignored is incredibly confusing and not natural to anyone. It is simply not an acceptable tradeoff IMO.
Brainstorming (edited)
But is the scoping rule the only way to address this problem? Conceptually, what authors are trying to do is pretty clear.
Take 1: Inject rewritten declaration in every nested rule
One solution would be to inject the rewritten properties in every rule within the mixin, i.e. rewrite to this:
& {
--local-c5f2d35: value;
border-color: var(--local-c5f2d35);
& + p {
--local-c5f2d35: value;
border-color: var(--local-c5f2d35);
}
}
Note that this would need to be done for nested rules as well. If the local declaration were only injected at the top level, this wouldn't work as expected:
@mixin --foo(--bar) {
@result {
border-color: var(--bar); /* works */
& + p {
border-color: var(--bar) /* works! */
& + p {
border-color: var(--bar) /* doesn't work! */
}
}
}
}
The downside of this is that we lose inheritance: All values are now applied literally in every rule. However, these two facts help us:
- We can't override local vars inside
@result
- We can't have regular rules outside of it (only conditional rules)
So the main issue is not around clobbering inherited values, but that the same literal value can have different meanings when used directly vs when inherited.
Take 2: Use inherit() to preferentially use the inherited value
One solution to this could be to preferentially use the inherited value, i.e. instead of injecting --local-c5f2d35: value;, inject --local-c5f2d35: inherit(--local-c5f2d35, value);. Since the custom property cannot possibly be present anywhere else (as it is hygienically rewritten to something that is guaranteed to avoid clashes with the outside world), this naturally sets it at the topmost level of each root and lets it inherit down naturally.
So the mixin above would inject this rule:
& {
--local-c5f2d35: inherit(--local-c5f2d35, value);
border-color: var(--local-c5f2d35); /* works */
& + p {
--local-c5f2d35: inherit(--local-c5f2d35, value);
border-color: var(--local-c5f2d35) /* works! */
& + p {
--local-c5f2d35: inherit(--local-c5f2d35, value);
border-color: var(--local-c5f2d35) /* works! */
}
}
}
Take 3: Double hygienic rewriting
But what is value? When is it resolved to a <length>? If it gets resolved at @apply we get very weird dependencies where ancestors get computed values from their children (thanks @andruud!)!
@mixin --sync-width(--arg <length>) {
@result {
width: var(--arg); /* 100px */
#ancestor:has(&) {
/* If 100px you have ancestors that depend on the computed values of their children?! */
width: var(--arg);
}
.child {
width: var(--arg);
}
}
}
#ancestor {
font-size: 20px;
}
#target {
font-size: 10px;
@apply --sync-width(10em);
.child {
font-size: 30px;
}
}
One solution to that would be to resolve at the injected declarations, and otherwise pass 10em around as an untyped token stream. Conceptually, this would behave as if we had two hygienically rewritten properties: one untyped and one typed:
& {
--arg-c5f2d35: inherit(--arg-c5f2d35, 10em); /* unregistered, so it stays 10em */
--arg-c5f2d35-length: var(--arg-c5f2d35);
width: var(--arg-c5f2d35-length);
#ancestor:has(&) {
--arg-c5f2d35: inherit(--arg-c5f2d35, 10em); /* unregistered, so it stays 10em */
--arg-c5f2d35-length: var(--arg-c5f2d35);
width: var(--arg-c5f2d35-length);
}
.child {
--arg-c5f2d35: inherit(--arg-c5f2d35, 10em); /* unregistered, so it stays 10em */
--arg-c5f2d35-length: var(--arg-c5f2d35);
width: var(--arg-c5f2d35-length);
}
}
This would make #ancestor have a width of 200px and #target would still get 100px since the property inherits as a token stream, not a typed value. However, .child would get 300px. Is that desirable?
Take 4: Override in @scope
Take 3 above gives us typed properties that resolve differently on every element. Is that what we want though? It is inconsistent to how typed custom properties behave, as they inherit after being resolved.
We can't just use --arg-c5f2d35-length: inherit(--arg-c5f2d35-length, var(--arg-c5f2d35));: then #target would get #ancestor's inherited value, which is incredibly confusing.
It seems that, ideally, I think we'd want .child and #target to both get 100px, but #ancestor (and siblings, and generally anything outside #target's subtree) should resolve 10em using its own context, to avoid weird dependencies. Can we do that?
We can depend on the fact that that anything inside & is able to inherit both of these custom properties, the problem is that we cannot statically only inject the --arg-c5f2d35 declarations inside rules that are not in &'s subtree. But what if we injected an override that is guaranteed to take priority?
& {
--arg-c5f2d35: inherit(--arg-c5f2d35, 10em); /* unregistered, so it stays 10em */
--arg-c5f2d35-length: var(--arg-c5f2d35);
width: var(--arg-c5f2d35-length);
#ancestor:has(&) {
--arg-c5f2d35: inherit(--arg-c5f2d35, 10em); /* unregistered, so it stays 10em */
--arg-c5f2d35-length: var(--arg-c5f2d35);
width: var(--arg-c5f2d35-length);
}
.child {
--arg-c5f2d35: inherit(--arg-c5f2d35, 10em); /* unregistered, so it stays 10em */
--arg-c5f2d35-length: var(--arg-c5f2d35);
width: var(--arg-c5f2d35-length);
}
@scope {
& {
/* Rules mirror the structure in the actual mixin, but @scope ensures rules outside &'s subtree are ignored */
#ancestor:has(&),
.child {
--arg-c5f2d35-length: inherit;
}
}
}
}
Since we're using the same selectors and we control the declarations on both ends, the override is guaranteed to apply, and @scope scopes it to just the target's subtree.
Note that we'd probably need to mirror the tree for applying the original declarations as well, otherwise layers etc could throw this off.
Take 5: inherit(--rewritten, value) everywhere except &
This seems like it would cover all cases, but what about this?
@mixin --foo(--arg <length> {
@result {
width: var(--arg);
& + p {
width: var(--arg);
img {
width: var(--arg);
}
}
}
}
#target {
font-size: 10px;
@apply --foo(10em);
}
#target + p {
font-size: 20px;
}
img {
font-size: 30px;
}
Based on the algorithm above, the @scope would not cover #target img, so it would get 300px, not the expected 200px from its own ancestor!
We basically want the following:
- Typed properties always resolve on mixin target based on its own context no matter what
- Descendants of mixin target get inherited resolved values based on target's context
- All subtrees outside mixin target resolve the typed property using the root's element context, and then it inherits down.
So inherited --arg-c5f2d35-length always takes precedence except on &. We can do that with a slightly different override:
- Use
--arg-c5f2d35-length: inherit(--arg-c5f2d35-length, value) everywhere
- Override with
--arg-c5f2d35-length: value on &, in a way that is guaranteed to take precedence over any mixin declarations (e.g. theoretically a mixin could include nested rules for &&& {} that just target &, that should not override).
Anyhow, hopefully this may give folks ideas to take this further!
Background
From https://drafts.csswg.org/css-mixins/#result-rule
One of the motivations for
@macrowas that because it does not return a scoping style rule, it's not bound by the same restrictions and can have more complex:has()conditions, sibling selectors etc.IIRC the scoping style rule was introduced to make sure that local variables can be reliably used throughout the mixin. If the mechanics were that of naïve transclusion, this would not work:
Why? Suppose
--bargets hygienically rewritten to--local-c5f2d35, the actual applied rule would look like this (valueis the value passed via the mixin):But
--local-c5f2d35would not inherit within& + p, so it would not be available.Hence, the scoping.
However, I think having mixin rules be silently dropped because it's a scoping rule is incredibly confusing.
Consider this:
Here,
& + pwould get dropped, even though it does not even use--arg!Edit: I posted two polls on social media to test if my intuition reflects the majority expectation:
While we cannot reliably distinguish between A and B or C and D, since most authors missed the
<length>, we can compare the two groups, and that gives a very strong signal that having rules be ignored is incredibly confusing and not natural to anyone. It is simply not an acceptable tradeoff IMO.Brainstorming (edited)
But is the scoping rule the only way to address this problem? Conceptually, what authors are trying to do is pretty clear.
Take 1: Inject rewritten declaration in every nested rule
One solution would be to inject the rewritten properties in every rule within the mixin, i.e. rewrite to this:
Note that this would need to be done for nested rules as well. If the local declaration were only injected at the top level, this wouldn't work as expected:
The downside of this is that we lose inheritance: All values are now applied literally in every rule. However, these two facts help us:
@resultSo the main issue is not around clobbering inherited values, but that the same literal value can have different meanings when used directly vs when inherited.
Take 2: Use
inherit()to preferentially use the inherited valueOne solution to this could be to preferentially use the inherited value, i.e. instead of injecting
--local-c5f2d35: value;, inject--local-c5f2d35: inherit(--local-c5f2d35, value);. Since the custom property cannot possibly be present anywhere else (as it is hygienically rewritten to something that is guaranteed to avoid clashes with the outside world), this naturally sets it at the topmost level of each root and lets it inherit down naturally.So the mixin above would inject this rule:
Take 3: Double hygienic rewriting
But what is
value? When is it resolved to a<length>? If it gets resolved at@applywe get very weird dependencies where ancestors get computed values from their children (thanks @andruud!)!One solution to that would be to resolve at the injected declarations, and otherwise pass
10emaround as an untyped token stream. Conceptually, this would behave as if we had two hygienically rewritten properties: one untyped and one typed:This would make
#ancestorhave a width of200pxand#targetwould still get100pxsince the property inherits as a token stream, not a typed value. However,.childwould get300px. Is that desirable?Take 4: Override in
@scopeTake 3 above gives us typed properties that resolve differently on every element. Is that what we want though? It is inconsistent to how typed custom properties behave, as they inherit after being resolved.
We can't just use
--arg-c5f2d35-length: inherit(--arg-c5f2d35-length, var(--arg-c5f2d35));: then#targetwould get#ancestor's inherited value, which is incredibly confusing.It seems that, ideally, I think we'd want
.childand#targetto both get100px, but#ancestor(and siblings, and generally anything outside#target's subtree) should resolve10emusing its own context, to avoid weird dependencies. Can we do that?We can depend on the fact that that anything inside
&is able to inherit both of these custom properties, the problem is that we cannot statically only inject the--arg-c5f2d35declarations inside rules that are not in&'s subtree. But what if we injected an override that is guaranteed to take priority?Since we're using the same selectors and we control the declarations on both ends, the override is guaranteed to apply, and
@scopescopes it to just the target's subtree.Note that we'd probably need to mirror the tree for applying the original declarations as well, otherwise layers etc could throw this off.
Take 5:
inherit(--rewritten, value)everywhere except&This seems like it would cover all cases, but what about this?
Based on the algorithm above, the
@scopewould not cover#target img, so it would get300px, not the expected200pxfrom its own ancestor!We basically want the following:
So inherited
--arg-c5f2d35-lengthalways takes precedence except on&. We can do that with a slightly different override:--arg-c5f2d35-length: inherit(--arg-c5f2d35-length, value)everywhere--arg-c5f2d35-length: valueon&, in a way that is guaranteed to take precedence over any mixin declarations (e.g. theoretically a mixin could include nested rules for&&& {}that just target&, that should not override).Anyhow, hopefully this may give folks ideas to take this further!