[go: nahoru, domu]

Skip to content
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

[cascade-6] Unclear proximity for scoped descendant combinator #8380

Open
andruud opened this issue Jan 31, 2023 · 24 comments
Open

[cascade-6] Unclear proximity for scoped descendant combinator #8380

andruud opened this issue Jan 31, 2023 · 24 comments

Comments

@andruud
Copy link
Member
andruud commented Jan 31, 2023

This combinator [>>] differs from the descendant combinator in that it applies weak scoping proximity to the relationship between A and B.

In simple cases (e.g. A >> B), it's clear what this means, but what about more complex cases? A >> B >> ... >> Z, A:has(B >> C), :is, :not?

It doesn't seem easy to spec an easily understandable behavior for >> given the amount of flexibility we have in selectors. Perhaps we should revisit whether >> really is needed at all.

If we do keep it, we should avoid introducing complexity that would be detrimental to performance:

  • Avoid a variable number of cascade criteria. Proximity should be a single number for the whole declaration.
  • Avoid a proximity value which depends on multiple successful matches of the same selector. (E.g. imagine an :is(X,Y) which matches for both X and Y but with different proximities). Once we find a match, we can not continue looking for "better" matches.

Note: The proposed selector scoping notation does not have any of these issues, so perhaps we should continue to explore that direction instead, if we really want "inline" scoping.

@Loirooriol
Copy link
Contributor

Yeah, it kinda sounds like the specificity problem from #1027. Example:

.a ~~ .b >> .c {}
<div class="a" id="a1"></div>
<div class="b" id="b1">
  <div class="a" id="a2"></div>
  <div></div><div></div><div></div><div></div><div></div><div></div>
  <div class="b" id="b2">
    <div class="c"></div>
  </div>
</div>

If we match .b as #b2, then .a must match #a2, which means ~~ has 7 hops, and >> has 1 hop.
If we match .b as #b1, then .a must match #a1, which means ~~ has 1 hops, and >> has 2 hops.

The 2nd option seems better, but this means that, in order to know the precedence of a selector, it can't just be matched greedily, all possible matchings need to be considered.

@mirisuzanne
Copy link
Contributor

@andruud Can you clarify why the other scope notations don't have this issue? Is it just a clearer order of operations - matching scope root/boundary elements, and then resolving each selector in relation to those defined scopes?

I think it's likely we'd want to go with the more clear syntax if they have the same meaning, but just as a way of understanding the issue – does it help to parse a b >> c >> d as sugar for @scope (a b) { @scope (c) { d { ... }}}, and what all would that impact?

@tabatkins
Copy link
Member
tabatkins commented Jan 31, 2023

Right, my understanding of the intention of the scoping combinators is that they're functionally sugar for a set of (possibly nested) @scope elements, just letting you express certain relationships more compactly when you're not using the scope root multiple times. @scope should be strictly more powerful, so any question one can have about scoping combinators should be answerable by just asking it about @scope instead.

@andruud
Copy link
Member Author
andruud commented Feb 1, 2023

The 2nd option seems better, but this means that, in order to know the precedence of a selector, it can't just be matched greedily, all possible matchings need to be considered.

Thanks for that context and info @Loirooriol. Not being able to finish selector matching at the first match is pretty much unacceptable. (Though maybe this problem is only possible to create if we add ~~ at the moment)?

Can you clarify why the other scope notations don't have this issue?

Nested @scope always follows the pattern A >> B >> C >> ... etc, but with selectors you can get much more creative (e.g. use different combinators between some compounds), and that is where I don't understand how proximity is supposed to be calculated.

@scope should be strictly more powerful, so any question one can have about scoping combinators should be answerable by just asking it about @scope instead.

OK, if >> is intended as sugar for (nested) @scope, that's fair enough, but then I still need to understand how to actually desugar, or at least how to calculate proximity. How do I desugar the following?

  1. A >> :not(B >> C) >> D
  2. A >> B C >> D
  3. A >> :is(B C) >> D (I know how desugar this, but adding it to highlight that it's not the same as (2)).

@mirisuzanne
Copy link
Contributor

If we do with plain 'syntax sugar', I think you would basically treat each mention of >> as a nesting of scopes. That probably answers the question for examples 2 and 3, but not for example 1 – because there is no logic for @scope to exist inside a pseudo-class, and so no clear way to desugar it. The others, I would expect to be:

  1. A >> B C >> D -> @scope (A) { @scope (B C) { D }
  2. A >> :is(B C) >> D -> @scope (A) { @scope (:is(B C)) { D } (I think in practice, the scope-start parameter already acts like :is(), so this might be redundant)

To highlight the two questions raised here:

Nesting Scopes, Generally:

To make sure I understand what's implemented: When @scope rules are nested, you're not currently calculating all the proximity relationships up the chain - only the final relationship. You get a single number by asking: 'how close is the final matched element to the nearest scope root?' Since scopes can only contract, never expand, we know that the nearest @scope rule will give us the nearest scope root as well.

I think that might be a reasonable solution, and certainly the easiest one to reason about. If we wanted, I think there might be ways to expand that and give some priority to things that are 'scoped more often' – but I'm not sure that's helpful, or actually captures a useful heuristic. But that probably needs more clarity in the specification.

Sugaring Limits:

It seems like the other issue here is that a scope-start clause in the @scope rule has some limitations that a normal combinator does not: you can't put @scope inside a pseudo-class, for example. In order for the syntax sugar to be a strict subset of the @scope feature, it would also have to be disallowed in those situations. I think we could make that limitation on either the old selector syntax or the combinators – but both have a chance of adding confusion (especially when combined with nesting).

(I'd be tempted to drop any scope selector sugar from v1 of the spec, and return later to see if it's necessary)

@Loirooriol
Copy link
Contributor

how close is the final matched element to the nearest scope root?

I still don't think that's clear.

@sibling-scope (.a) {
  @scope (.b) {
    .c { color: blue }
  }
}
<div class="a"></div>
<div class="b" id="b1">
  <div class="b" id="b2">
    <div class="c"></div>
  </div>
</div>

Does it mean that the rule will not apply, since the closest .b has no .a previous sibling?
Does it mean that the rule will apply, matching .b as #b1, but for the number of >> hops we use #b2? Do we also count the number of ~~ hops from #b2 (NaN?) ?

@andruud
Copy link
Member Author
andruud commented Feb 2, 2023

@mirisuzanne My point with (2) vs (3) was that they can not both expand to effectively the same @scope chain (as you have done), because they do not select the same elements.

A >> B C >> D -> @scope (A) { @scope (B C) { D }

For example, the @scope chain would match B -> A -> C -> D, but the selector would not.

The @scope chain would also match B -> ACD (where ACD is an element that can be matched by either A or C or D) since scoping is root-inclusive. (Although that's a separate point).

To make sure I understand what's implemented: When @scope rules are nested, you're not currently calculating all the proximity relationships up the chain - only the final relationship.

Yes, exactly. I did not see anything in https://drafts.csswg.org/css-cascade-6/#scope-atrule that would suggest anything else.

I think that might be a reasonable solution, and certainly the easiest one to reason about.

It should be completely uncontroversial in terms of performance, and if it's easy to reason about I guess that's something authors will appreciate as well. The question is if it's "too simple" from the author's perspective, i.e. would proximity still be useful enough in more complicated (nested) scoping scenarios? (I can't answer this question).

@andruud
Copy link
Member Author
andruud commented Feb 2, 2023

@Loirooriol Using the "nearest scope is what determines proximity" mode, I suppose your example would go like this:

  • A sibling scope is created at .a.
  • @scope (.b) only produces scopes for elements that are siblings of .a, so we get a scope at #b1 and no scope at #b2.
  • The proximity of .c is the distance to the nearest enclosing scope (#b1), i.e. 2.

(Unless I've misunderstood how @sibling-scope should work).

@mirisuzanne
Copy link
Contributor

That's how I would parse the example as well. Nested scope-start rules are (according to spec already) scoped by any parent scope rules. So #b2 is never matched by any scope rule in that example.

For example, the @scope chain would match B -> A -> C -> D, but the selector would not.

Right. Yeah, that's a more difficult logic to impose on a combinator.

The question is if it's "too simple" from the author's perspective, i.e. would proximity still be useful enough in more complicated (nested) scoping scenarios? (I can't answer this question).

In my mind proximity is a useful heuristic in the simple cases - and this logic continues to handle those cases well. Once things get more complicated, authors will likely need to think about other cascade controls: layers, specificity, etc. With a heuristic like this, I think it would be a mistake to get too clever about solving more complex scenarios in an abstract or magical way.

@tabatkins
Copy link
Member

I mean, the equivalent @scope was just written incorrectly. The actual desugaring of A >> B C >> D is:

@scope (A) {
  @scope (:scope B C) {
    :scope D {...}
  }
}

That does match the identical set of elements.

The only significant thing is the fact that you can write something like A :is(B >> C) D, which can't be desugared into a @scope. We can just disallow that and only allow >> at the top level of a selector.

@andruud
Copy link
Member Author
andruud commented Feb 2, 2023

@tabatkins 👍

Then how about the following:

  • Add Tab's desugaring above to the spec, as an example.
  • Specify that >> is only allowed top-level in the selector.
  • Remove selector scoping notation from the spec.
  • Specify that proximity is a single number derived from the innermost relationship only. (I read @mirisuzanne's comment above as favoring this approach at the moment).

@mirisuzanne
Copy link
Contributor

We've already resolved to remove the selector scoping notation - so don't need to do anything there, except apply the changes. I like this proposed resolution. Add to telecon or async agenda?

@tabatkins
Copy link
Member

Don't necessarily need a resolution at all - the spec is FPWD. As long as it's not expected to be controversial (and from this thread, it shouldn't be), you can just do it.

@Loirooriol
Copy link
Contributor

Specify that proximity is a single number derived from the innermost relationship only

If only one >> or ~~ works, should the selector become invalid if there are more of them?

@tabatkins
Copy link
Member

No need for it, I'd think? We allow nested @scopes even tho only the innermost one has an effect on matching.

Like, additional >> or ~~ don't do anything different from or ~, but they're not wrong.

@Loirooriol
Copy link
Contributor

Sure that's possible, but failing loud may be less confusing than failing silently. If they are invalid inside :is(), seems consistent to make them invalid in the top level when there are multiple of them.

@mirisuzanne
Copy link
Contributor

Inside :is(), these combinators are invalid because there is no way to de-sugar them in a meaningful way. But when there are multiple, they de-sugar directly to nested @scope rules, which have a well-defined behavior. That behavior only takes the final step into account when determining proximity – but the outer scopes are still valid.

@mirisuzanne mirisuzanne moved this from ToDo to Needs Edits in Cascade 6 (Scope) Feb 3, 2023
@mirisuzanne
Copy link
Contributor
mirisuzanne commented Feb 17, 2023

The current spec text says about the combinator that:

It does not change the [=:scope element=].

We defined it that way to avoid having :scope mean different things in different parts of the selector - but that would change how this de-sugaring happens. Given a combination of @scope and the combinator:

@scope (.a) { 
  .b >> .c :scope { ... }
}

We could de-sugar directly, leaving :scope intact:

@scope (.a) {
  @scope (.b) {
    .c :scope { ... }
  }
}

In that case :scope refers to the scoping element .b that is a descendant of .a, matching if it is also an arbitrary descendant of .c. That seems likely confusing for authors, though.

On the other hand, if we don't want the combinator to update the meaning of :scope, we would need to de-sugar such that :scope refers to the scoping element .a - and it's not clear to me how we would achieve that. (There is no possible match in this case, since .a cannot be a descendant of .b, since .b is scoped to .a)

(I'm somewhat drawn to the conclusion that these combinators introduce more authoring complexity than they solve, and we should at remove or defer to another level, so they don't block shipping a more clear at-rule syntax)

@mirisuzanne
Copy link
Contributor

The alternative to all of that is: we don't need strict de-sugaring - just clarification that proximity is determined:

  • as a single number determined by the final 'step' (scope or combinator)
  • for the purpose of that measurement, the 'proximity source' of a combinator (the element to measure against) has to be matched explicitly by the preceding selector-fragment

With normal @scope rules, we can expect the 'proximity source' to be the :scope, but in this case we would be saying that the scoping combinators update the proximity source, without updating the :scope. Beyond that, there's no reason to de-sugar fully.

@mirisuzanne
Copy link
Contributor

My hope is that we can resolve on that the proposal in my previous comment. Failing that, I might push to defer scoped combinators to a future level, so they don't hold up the main @scope rule.

@fantasai
Copy link
Collaborator
fantasai commented Mar 1, 2023

+1 to Tab's comment in #8380 (comment) and Miriam's clarification that it's not a strict de-sugaring (to handle cases where that doesn't quite work). But about matching only the last step:

To make sure I understand what's implemented: When @scope rules are nested, you're not currently calculating all the proximity relationships up the chain - only the final relationship.

I'm actually surprised by this. I think it's pretty confusing if you have

@scope (A) {
  @scope (B) {
    X { color: blue }
  }
  X { color: yellow }
}

and the color is yellow for an element that matches both.

@mirisuzanne
Copy link
Contributor

and the color is yellow for an element that matches both.

I don't think it would be, at least if we accept the implied descendant relationship proposed in #8377. If x is inside b, which is inside a - x will have a closer proximity to b, and the color will be blue.

@mirisuzanne mirisuzanne moved this from Needs Edits to In progress in Cascade 6 (Scope) Mar 1, 2023
@atanassov atanassov added this to Overflow in March 2023 VF2F Mar 7, 2023
@tabatkins
Copy link
Member
tabatkins commented Mar 7, 2023

Well, it's still possible for it to be yellow, if there's another instance of A between the B and the X.

But assuming there's only one A and B, then yes, it'll definitely be blue, since the B defaults to being inside the A (and thus is closer to the X).

@mirisuzanne
Copy link
Contributor

Right. The case where scope A wins over scope B is also the case where scope A is the closer scope root. Which I don't think is confusing. A 'more nested' mechanism would be akin to specificity. Which:

  • for @scope rules, authors can achieve by using the & or additional selectors
  • for >> combinators, the additional specificity is in the selector already

I don't see any reason to have a specificity-like cascade mechanic based on 'how many scopes were used to get here'. That would over-complicate what scope is about.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
March 2023 VF2F
Wednesday - Mar 22
Development

No branches or pull requests

6 participants