w3c/css-houdini-drafts

[css-properties-values-api] Should property registration be scoped?

Closed this issue ยท 16 comments

smfr commented

https://drafts.css-houdini.org/css-properties-values-api-1/

The fact that property registration is document global makes it impossible to use a custom property privately in a shadow tree. Maybe registration should be scoped somehow, optimally to a style sheet or shadow scope.

Like other features that would benefit from scoping (@keyframes, @font-face, ...) it's not clear how they should behave when the scopes interact. I don't have any bright ideas yet, but a few questions off the top of my head:

If limited to a shadow scope:

  • What happens when the property inherits from one scope into another, and the registration details change between scopes?
  • What does it mean to use constructable style sheets, where the one CSSStyleSheet object could be inserted into multiple shadow trees, and the registration differs between shadow trees?

If limited to a style sheet:

  • Does this mean that you can't use that custom property in style="" attributes?
  • What if there are two style sheets that use different registrations, but whose rules apply to the same element?

I strongly agree that it would be great to scope these, in the same way as we intend to scope other CSS values. (And on that point, will someone please either commit to this proposal or offer a credible alternative?)

This means scoping to tree scopes, and the registration you use depends on the scope the stylesheet is in. (Tho this has some issues, which I'll discuss in a sec.)

What happens when the property inherits from one scope into another, and the registration details change between scopes?

I think inheritance should happen with the synthesized token stream, as if you'd just set that token stream directly in the new context. Syntax has only a relatively small effect on registered properties, just making it go iacvt if necessary, and affecting how it reifies in Typed OM.

What does it mean to use constructable style sheets, where the one CSSStyleSheet object could be inserted into multiple shadow trees, and the registration differs between shadow trees?

You already have to track the stylesheet separately in each location it's inserted into, as the separate locations can already clash and have to be resolved based on document order/shadow origin/etc. (Eg a stylesheet can be inserted into both the outer page and a shadow, targeting the same element with a .foo rule from the light side and a :host rule from the shadow side.)


I think the biggest conflict here is: what registration does an element use when it's a shadow host? That is, if the main page registers --foo with syntax:"<length>", and the shadow registers it with syntax:"<color>", if you set "--foo: red;" on a shadow host is it iacvt or a valid red?

Perhaps it matters based on where that rule comes from? Aka, a custom property's value carries the value's scope with it, and once you run the cascade and figure out which value wins, you know how to process it as a computed value? I think this matches with the proposed behavior in w3c/csswg-drafts#1995 for other properties -- setting font-family: foo can refer to either the light or shadow @font { font-family: foo; } based on whether the declaration is from the light or shadow tree.

However, it does mean that a script trying to use the Typed OM to interact with the property might be confused by what it gets! In my example, if the outer page asks for the value, it'll be expecting a CSSNumericValue, but it might instead get a CSSColorValue if the declaration from the shadow wins. Maybe that's okay?

An alternative is that the element uses the declaration of the tree it's in, always. This means that a shadow tree can't reliably register a property and then use that property on the :host element, because if the outer page has a conflicting registration, the property will become iacvt if the shadow rule wins the cascade. I guess we'd then document that shadows should never use custom properties on :host for any reason (whether you register the property or not!), and instead only use them on top-level elements actually in the shadow tree.

I don't think there's any completely ideal answer here.

The Houdini Task Force just discussed Scoping property registrations in the shadows.

The full IRC log of that discussion <myles_> Topic: Scoping property registrations in the shadows
<TabAtkins> github: https://github.com//issues/939
<iank_> scribenick: iank_
<iank_> TabAtkins: Simon brought up, if we want prop registration scooped to shadow trees.
<iank_> TabAtkins: Best case an custom element will unregister a property, and reregister it.
<iank_> TabAtkins: There is a meta issue around things not being scoped in CSS.
<iank_> TabAtkins: 1) What happens when a property registered in the light dom, inherits into the shadow dom, and there is different property registrations.
<iank_> TabAtkins: I suspect that we can basically, it keeps around a synthesized property string, as it goes into the new thing, it can be reinterpreted.
<iank_> TabAtkins: This sounds similar if it wasn't registered.
<iank_> TabAtkins: If you do a size property, it'll probably be a <length> it both places.
<iank_> TabAtkins: You can code defensively, but resetting at the top of the shadow root.
<iank_> TabAtkins: If it crosses the boundary it just gets reinterpreted as a token stream.
<TabAtkins> s/but/by/
<iank_> heycam: A broader problem seems to be what .... there is no good way to partition these properties.
<iank_> heycam: If you have multiple registrations.
<iank_> TabAtkins: Nothing more specific.
<iank_> heycam: I could imagine 2 custom element authors, both coming up with a --theme property independently.
<iank_> heycam: No way to handle that these could be interpreted differently.
<iank_> TabAtkins: As long as the two components are siblings you could set it above the CEs.
<iank_> TabAtkins: If they are nested, the outer CE knows that it nested the child.
<iank_> iank_: Has CE scoping occured yet?
<iank_> masonfreed: Still not solved yet.
<iank_> heycam: If we expect component authors to use suitably named properties to avoid conflicts, then the solution you described sounds fine.
<iank_> heycam: 90% of CP usage I see, is setting the properties on the root.
<iank_> fremy: Another option would be to, have a map of token streams, and when you read it , it can switch based on the reading context.
<iank_> fremy: Its consistent within a property tree, downside if you have slots you can't reset these.
<iank_> fremy: We might want to ask people who are using CEs on how they use them.
<TabAtkins> problem case: Light-dom author sets a theming property expecting it to apply to whole page. Shadow-dom author uses colliding name in their shadow. Shadow version of the property is what the slotted content sees, not the light-author's intended light value.
<iank_> heycam: Can you describe other discussions?
<TabAtkins> https://github.com/w3c/csswg-drafts/issues/1995
<iank_> TabAtkins: Yes
<iank_> TabAtkins: discussion was about font-face but was about other things.
<iank_> TabAtkins: not just font-face, fill: url(#something), etc.
<iank_> TabAtkins: A lot of confusion.
<iank_> TabAtkins: People think that you just walk upwards, but this would be bad, as unreliably interpreted differently.
<iank_> TabAtkins: My idea - each value which has this context depedence, it keeps around a link to where it was declared. As its passed around contexts, it keeps a reference to what defined it.
<iank_> TabAtkins: The reference would be explicitly caught, and reflected in the TypedOM version.
<iank_> TabAtkins: It'll be Document, or ShadowTree, etc, could grab the value, and use it elsewhere.
<iank_> heycam: On font-face specifically not sure how this effects document.fonts
<iank_> TabAtkins: ShadowTree would have a tree.fonts, conceptually.
<iank_> TabAtkins: keyframes rules, etc would also be what was defined within your scope, but would still work if you interited a keyframe across a shadow root boundary.
<iank_> heycam: Does this match what fremy described?
<iank_> TabAtkins: Yes.
<iank_> TabAtkins: That would solve the problem which i minuted earlier. Keeping the simple keeping the value with a single reference. Complexity is a little worrying.
<iank_> smfr: Is any of this written down anywhere?
<iank_> TabAtkins: Only present within that issue thread. As nobody has said this is good.
<iank_> TabAtkins: <snark>
<iank_> smfr: I'll try and get our CSS folks to review it.
<iank_> emilio: I don't think @font-face works within a shadow tree.
<iank_> emilio: keyframes don't work, blink/ff do something different to webkit.
<iank_> TabAtkins: Yeah would like to get a consistent answer.
<emilio> s/don't work/don't work the same across browsers
<iank_> TabAtkins: What registration use when it is a Shadow Host. Light DOM sees it as a normal element, Shadow DOM sees it as a :root.
<iank_> TabAtkins: Which registration should it use?
<iank_> TabAtkins: Not sure.
<iank_> TabAtkins: 1) Uses the reg. of whereever that style came from, outer uses outer reg. for example.
<iank_> Good thing: styles always work, as long as they don't touch at the same time. But unpredictable for what TypedOM returns.
<iank_> fremy: But if you do what I said it can support both.
<iank_> TabAtkins: If we do the multiple scopes, we need to define how to get all the versions of the property.
<iank_> fremy: Yes - that's another question, how do you read the values.
<iank_> TabAtkins: None of my proposals have to worry about what context the script is in.
<iank_> TabAtkins: Big problem is what happens w/ typed om.
<iank_> fremy: I don't have a good idea for that.
<iank_> TabAtkins: 2) Always use the context which the element lives within. This means always get the Light DOM version. Means that you can never set the :host of the shadow DOM.
<iank_> plinss: That seems not good.
<iank_> TabAtkins: I agrree its not great, but simple and pretictable.
<iank_> TabAtkins: Not sure which one I want to do.
<iank_> TabAtkins: If anyone has opinions let me know.
<iank_> TabAtkins: As far as I can tell that is the major issue.
<iank_> TabAtkins: Lets get our browser folks about how this should work.
<iank_> TabAtkins: At some point I'll commit text about how this should work.
<iank_> heycam: What happens with constructable stylesheets.
<iank_> heycam: What happens to style elements which haven't been inserted within the point yet.
<iank_> heycam: ... e.g. you can access these from the TypedOM for these.
<iank_> emilio: This is at specified value time, and the grammer isn't applied yet.
<iank_> TabAtkins: ... yes - only at computed value time this is applied.
<iank_> TabAtkins: I'm good with this issue then, can move only the next one.
<iank_> fremy: Last thing you proposed, it'll use the Light DOM reg., the advantage is that you can always use both. If you are within the light tree, you can't pass information into the shadow tree.
<iank_> fremy: Component - wants to have a custom prop e.g. --grid-type
<iank_> TabAtkins: You can still set it, it won't get corrected until it hits the shadow tree.

CSS Scoping for at-rules has since been added to CSS Scoping, it would be nice to add these rules to the rest of Houdini things.

The worklets should all be easy enough as each shadow dom could just have a "copy" of each worklet which while not neccessarily being a separate Worklet, calling registerSomeClass("some-name" would register it to the associated shadow tree.

CSS properties seems particularly tricky though, cause unlike other at-rules and named worklet things, it's not the value that can be inherited into the shadow tree but the property itself.

Also one would probably want to be able to expose specific custom properties to the light DOM for certain customization, just from a very cursory sampling of components listed on webcomponents.org, many components support styling through CSS custom property names, and often these names are generic. Maybe we could export CSS properties (similar to how we do with elements and ::part) and reference them in stylesheets by a prefixed custom property name or something e.g.:

/* Style the custom element with some exposed properties */
custom-element {
  --color: red; /* This property is the usual light DOM property */
  exposed--color: red; /* This is a property exposed from the Shadow DOM */
  exposed--theme: --disco-lights; /* Ditto */
}
/* Inside the shadow root */
@property --color {
  /* Declare property as exposed so can be set by consumers of component */
  exposed: true;
  syntax: "<color>";
  initial-value: black;
}

@property --theme {
  exposed: true;
  syntax: "--plain | --disco-lights | --moody-evening-lake | --christmas";
  initial-value: --plain;
}

@property --private-use {
  exposed: false; /* Not exposed, the default, so exposed--private-use externally won't work */
  syntax: "<angle>";
  initial-value: 0deg;
}

Argh, yeah, @Property is a problem for scoping. (Sorry, I'm gonna repeat some of your reasoning in laying out my thoughts.)

So, there's two very distinct uses of custom properties in components: allowing the outside page to pass data into the component, and using custom properties purely internally for all the fun uses you can put custom properties to.

The first is fundamentally incompatible with scoping. Properties intended for this usage have to be registered globally, so they have a consistent registration across all the scopes the property is passing over. (In a more full-featured programming language we could be better about lexical scoping and explicitly exposing properties for use, relying on object identity, but CSS's design makes this very difficult.) This'll mean that components have to be mindful about global name collisions and possibly defensively uniquify their names, but I just don't see a way around that.

The second is theoretically compatible with scoping, but has some additional implications. If you have a private registration, then a property inheriting in from the outside by definition can't be using that registration, and thus must be blocked; thus we basically have to block inheritance.

But there's a further problem - the host element is in two scopes at once, and thus can see two conflicting registrations of the same property name. I don't see how to reconcile this; even if we somehow keep both around with an internal uniquifying of the name, it's not clear how to expose that to script when they ask for the value of the property.

Unfortunately, the only way I can see to get around this is to uniquify the names here, too, so there's minimal chance of name overlap. We just don't have the programming primitives necessary to achieve actual usable uniqueness.

And so, if we have to uniquify the names anyway, I don't see a strong need to try and address this directly. Some guidance in a spec is probably warranted, but as far as I can tell anything we do to address this will just be codifying some form of uniquifying without any real reduction in typing for the author.

And so, if we have to uniquify the names anyway, I don't see a strong need to try and address this directly. Some guidance in a spec is probably warranted, but as far as I can tell anything we do to address this will just be codifying some form of uniquifying without any real reduction in typing for the author.

An alternative is lexical namespaces for CSS, other proposals have suggested it for other features. For properties specifically I'd imagine something like:

/* In practice the shared definitions would be put into a sheet which the actual shadow DOM
   sheet imports so that importers of this variable don't need to import the whole sheet */
@export @property $myVar {
    syntax: "<color>";
    initial-value: blue;
}

/* Not exported so can't be used outside of this sheet */
@property $local {
    syntax: "<angle>";
    initial-value: 2deg;
}

:host {
    color: var($myVar);
}

For users of this component they would reference this name from the sheet:

<style>
    @import-names "custom-element/sheet.css" {
      $myVar;
      /* Also we'd allow renaming like JS
      $someVar: $someLocalName;
       */
    }

    custom-element {
      $myVar: blue;
    }
</style>

And this could also integrate into the CSS module feature (just shipped in Chrome) so it would be usable from JS:

import { myVar } from "./sheet.css";

element.style.setProperty(myVar, "green");
element.attributeStyleMap.setProperty(myVar, "green");

From my point of view being able to define a @property inside a shadow DOM, but having it exposed outside of it, would be a very useful scenario.

Right now, CSS custom properties are pretty much the only way to provide a styling API for web components. But there is no easy way to define an API, since you'd mostly have to look for references in the CSS.

Using @property in the web component's shadow DOM would make it quite straightforward, and allow tools to extract such information so it can be used via language services and similar IDE features (I'm already parsing them to generate documentation).

On a related note, right now I'm getting this behavior (Chromium 96):

<!-- template for a custom element with the usual clone node and shadow root -->
<style>
  @property --unique-name {
    syntax: '<color>';
    initial-value: red;
  }
  div { color: var(--unique-name); }
</style>
<div>Test</div>

The div does not get its color set (with or without inherit on the property), but moving the @property outside the shadow DOM makes it get applied.

Shouldn't it at least apply within the shadow DOM's scope, or are @property's currently forbidden in the shadow DOM? I know many of the examples in this discussion are hypothetical, but I'm not quite sure on what the current state allows.

I suppose it would be make sense, considering the JS API for registering is on the global CSS object.

Shouldn't it at least apply within the shadow DOM's scope, or are @property's currently forbidden in the shadow DOM? I know many of the examples in this discussion are hypothetical, but I'm not quite sure on what the current state allows.

I suppose it would be make sense, considering the JS API for registering is on the global CSS object.

The spec currently specifies that @property should be ignored inside Shadow DOM.

This is presumably to allow the door for shadow scoped later, although as @tabatkins points out this probably won't work for --custom-name anyway due to host elements existing in multiple scopes.

Yup, exactly. The host element is visible to both the inner and outer scope, and it's completely reasonable for both to want to use a property on that element which they've registered. That's not getting into the forking inheritance chain, too - the host element's values inherit both into the shadow DOM and into the light DOM children of the element.

Whichever way we decide "wins" and gets to define the registration for the host will cut out completely ordinary and common use-cases when there's a naming conflict. It would be a smaller conflict scope - only direct parent/child trees would care about naming conflicts, rather than all shadows on the entire page, but it would still introduce complexity and possible confusion. Unsure whether the trade-off is worthwhile.

A possible way to cut the knot would be to define a ::shadow-tree pseudo-element that wraps the shadow tree, and then say that the host element always takes its registration from the light DOM. If the light DOM doesn't have a registration, then the (unregistered) property can still be used on the host, and it'll inherit into ::shadow-tree where it gets interpreted by the shadow's registration and then inherited into the rest of the tree. If they do both have registrations, the outer page can just set the value intended for the shadow directly on ::shadow-tree instead, where it won't see the light registration at all.

This would still prevent the shadow from using the registered property on the host element itself, but that's probably a fairly minor restriction in practice?

(Or we could get weird and say that ::shadow-tree is the host element, and cascades all of its non-custom properties together with styles applied to the host element itself, but maintains custom properties separately and resolves var()s accordingly.)

This makes sense, but I'm wondering how this could best be solved:

  1. you distribute a web component as a library
  2. it comes with a set of custom properties so you can adjust its style
  3. if the page (or another web component using it) doesn't do anything (no @property), the web component still behaves properly
  4. users of the web component easily know the available custom properties, their syntax, the defaults, etc.

Points 1 to 3 are pretty much covered by the existing features, so it's really point 4 that I'm interested in.

Right now, you can do var(--some-name, someDefaultValue) which expresses the property's existence and an indirect initial value, but you cannot provide a syntax or any other potentially useful information.

So in a way, it feels like the responsibility for defining the "shape" of the custom property is moved to the page rather than the web component that's really the one that should be defining its behavior. Which, don't get me wrong, from a certain point of view also makes sense.

I think I might be able to make myself clearer if I drew a parallel with a random old-fashioned programming language, for example TypeScript (I know it's not a perfect example because CSS simply doesn't work like that but bear with me).

Right now, defining a property with @property is - to me - roughly the same as:

/*
@property --variable {
  syntax: '<Syntax>';
  initial-value: initialValue;
}
*/
let variable: Syntax = initialValue;
// --variable: otherValue;
variable = otherValue;
// --other-variable: var(--variable, valueIfNotSet);
let otherVariable = variable ?? valueIfNotSet;

Which is already a very nice thing to have.

The web component that uses the property in its shadow DOM is - again to me, and even though passing arguments would be implicit for CSS - roughly the same as:

// background-color: var(--variable, defaultValue);
function webComponentStyle(variable /* no type because no syntax */ = defaultValue) {
  backgroundColor = variable;
  // --local-variable: var(--variable, valueIfNotSet)
  let localVariable /* again no type */ = variable ?? valueIfNotSet;
}

So my points are:

  • the "style parameters" to the web component are not "typed" (e.g. they have no definition like @property offers)
  • there is no actual list of parameters, references to custom properties are scattered in the style sheet or equivalent, and some of them might be internal to the web component and thus always overridden by the web component

Maybe (probably?) @property is not the right tool for everything I'm describing, but having the ability to use it inside the shadow DOM would cover 2 use cases.

The first one being simply using it in the web component itself and it's an internal thing:

/*
in shadow DOM
@property --variable {
  syntax: '<Syntax>';
  initial-value: initialValue;
}
*/
function webComponentStyle() {
  let variable: Syntax = initialValue;
  // --local-variable: var(--variable, valueIfNotSet);
  let localVariable: Syntax /* now typed */ = variable ?? valueIfNotSet;
}

Not much to add there, as that's the same benefits as @property outside the shadow DOM: it can be useful for the web component itself but also for defining properties that are used by other web components inside this web component.

The second one, which is in my opinion more useful when it comes to distributing web components (but where @property seems like it just might not be the best solution, cf. all the discussions above) is defining an API for the web component's styles:

/*
in shadow DOM
@property --variable {
  syntax: '<Syntax>';
}
*/
function webComponentStyle(variable?: Syntax) {
  // background-color: var(--variable, defaultValue);
  backgroundColor = variable ?? defaultValue;
}
*/

So tooling could look at the @property list and use it to generate documentation, offer auto-completion in the IDE (via language services), etc. At the moment I'm extracting var()'s from style sheets to find out about the available custom properties, but this is fairly limited information as there is no syntax, and knowing whether it's an internal property or not is not always straightforward (also parsing nested var(x, var(y, var(z))) is not much fun ๐Ÿ˜…).

Maybe a solution could be @property available in the shadow DOM for use case 1, and a new @exposed-property (or the mentioned @export @property available in the shadow DOM for use case 2?

Maybe a solution could be @Property available in the shadow DOM for use case 1, and a new @exposed-property (or the mentioned @export @Property available in the shadow DOM for use case 2?

How would either of these address the problem I outlined in my previous comment, tho, where the host element is in both the light and shadow scopes simultaneously, and can validly have either the light dom's notion of a custom property and the shadow dom's notion at the same time?

I'm not saying it addresses the problem you outlined, I'm describing an extra problem ๐Ÿ˜

Further musing: the host element problem isn't unique to the host element. ::part() pseudos also mean both the outer and inner trees have direct styling access to an element, with the styles from both scopes intermixing, and thus both have claims to the registration of a particular property on the element.

So yeah, as far as I can tell this is just an unsolveable problem with the primitives we have available to us. Even if we track the tree scope that the rule came from (so we can parse it with the corresponding registration), that doesn't stop the confusion that would come from two different "meanings" of the same property name being used on a single element at the same time, and one winning while code expects the other to win.

Uniquifying names continues to be the only reasonable solution to this, and nothing we can do to help with uniquification seems like it would be significantly better than just hand-uniquifying, so we can't justify the added complexity.

I'm gonna just add some text to the spec talking about this issue and suggesting uniquification.

I elaborated a bit more on this in the spec, now. The big point is just that (a) if the custom property is part of the component's public API, then the name is already page-global in practice (it can be set on any shadow-inclusive ancestor to pass to the component, and might be visible to any shadow-inclusive descendant), and (b) if the custom property is purely internal to the component, then you already want to uniquify the name to prevent it being accidentally set to nonsense values by the outer page using it for some other purpose.

Registration just follows these principles - you either want the registration global as well, or you want to uniquify the name anyway so the registration being global doesn't matter.

Uniquifying names continues to be the only reasonable solution to this, and nothing we can do to help with uniquification seems like it would be significantly better than just hand-uniquifying

I still think lexical names would help, like prepending the component name definitely won't help in the presence of scoped element registries, in such places the only reliable way to uniqify is to either include some global url (i.e. --myorg.tld/some-component--property) or use some uuid --401db1cf-some-property, both of which are incredibly unweildly.

Lexical names as I've suggested above, would simplify things considerably by just allowing people to use $whateverNameTheyWant and have it scoped to the appropriate file.

Luckily we can add such lexical scoping later as it proves necessary. It'll slot in cleanly without the current spec needing to leave space for it.