Repository navigation
[css-nesting-1] Can we relax the syntax further? #7961
Description
In #7834 we resolved to relax the Nesting syntax to basically "& is only required to indicate a descendant selector only when the selector starts with an ident" (e.g. in & h1 the & is necessary, but & .foo can become just .foo and & > h1 can be just > h1).
I opened this issue to brainstorm about ways to relax the syntax even further and do away with the & for all descendants, without introducing infinite lookahead, so we can have our 🍰 and eat it too.
The problem
If we do not require a & before descendant element selectors, then when followed by a pseudo-class they can look like declarations (which are <ident> : and then anything goes, including another <ident>) to the parser. The parser cannot know if it's dealing with a declaration or a nested rule, until it sees a ; or {, which could come after an arbitrary number of tokens, hence unbounded lookahead.
Non-starters
- No, we cannot require whitespace after
:for declarations, minifiers currently remove that so there is a lot of code out there with declarations that do not include whitespace after:. - No, the parser cannot take the list of recognized properties or pseudo-classes into account when deciding whether it's dealing with a declaration or a rule. That would be a most unfortunate coupling, and wouldn't even completely solve the problem (e.g.
fontis both a valid property name and an HTML element — also with custom elements any vaid property name can also be an element selector).
Proposed algorithm
One way this problem is bounded is that there are only two distinct possibilities: either you have a declaration, or a selector.
In CSS, tokenization is context-less, i.e. parsing a declaration or rule creates the same tokens, it's only the higher-level structures that are different.
Assuming parsing a declaration takes O(M) time and parsing a rule takes O(N) time, it would theoretically solve the problem to naively parse every rule-or-declaration twice (one as declaration, one as rule), and then throw away the structure we don't need. Clearly, that's a silly idea, because that would take O(M+N) time for every rule-or-declaration.
One optimization would be to parse as a declaration (there are far more declarations than nested rules), and keep the list of raw tokens around until the ; or {. Then declarations continue to be parsed in O(M) time, and rules are parsed in O(M+N) time. The extra space needed is minimal, since we don't need to keep these tokens around after the current structure is parsed.
But also, as discussed in #7834, we can rule out the possibility of being a declaration very early for nearly every selector. The only exception is element selectors followed by a pseudo-class (e.g. strong:hover) which are fairly rare in nested stylesheets (you usually want to style the base selector as well, so it's usually & { /* ... */ &:hover {...} })
So in the end, declarations still take O(M) time, nearly all rules still take O(N) time, and some, but very few rules take O(M + N) time.
And there's probably more room for optimizations.
I'd love to hear from implementers whether this is feasible, and whether I'm missing something.
Edit: Another restriction of going that way is that we'd need to forbid {} from property values, including custom properties (having it in strings is fine). But usage of that in the wild seems very low (and a lot of that includes the braces in strings, which will always be fine). Note we can reserve property: { for future syntax, since pseudo-classes cannot be empty so there's no ambiguity there.
Activity
tabatkins commentedon Oct 26, 2022
The ambiguous case is a small subset of valid rules, but unfortunately it's all valid declarations, so any cost added to the parsing of them is going to balloon the cost of parsing virtually all stylesheets.
tabatkins commentedon Oct 26, 2022
(We have the ambiguity of invalid declarations with valid rules to worry about too, but that is a tiny percentage of declarations, so it's not nearly as bad. That is, for existing invalid decls like
*color: red;, we'll now pay the cost of creating a rule that we later throw away when we realize it was a decl all along, but since they're so rare it's not a big deal iiuc.)LeaVerou commentedon Oct 26, 2022
That is exactly why I'm proposing to parse all ambiguous structures as declarations, so there's no cost added to them.
But since these are invalid we throw them away anyway, no?
tabatkins commentedon Oct 26, 2022
I'm confused. If when the structure is ambiguous you definitely parse as a declaration, that means
fieldset & {...}will just be an invalid declaration, rather than a rule, which doesn't seem to be what you're suggesting. That's just the existing Option 3.If you mean "parse as a declaration but also do X to allow for reinterpreting as a rule later", then it is indeed a per-declaration cost to do X.
Yeah, but today we throw them away as an immediate error-handling consequence - we see the
*and just switch into "consume tokens until we see a semicolon" mode immediately without creating any additional data structures. With Nesting we'll start making a rule object on the assumption that it's gonna turn out to be a valid rule, and then later throw that out when it ends up being an invalid declaration; that's an extra cost. (Just a rare one.)LeaVerou commentedon Oct 26, 2022
I mean parse as a declaration, but keep its tokens around until the declaration finishes parsing (not forever). I suppose that's an extra cost per declaration, but it seems a very small one since, you have these tokens anyway.
tabatkins commentedon Oct 26, 2022
You have those tokens transiently today, yes. Keeping them around for the duration of the decl parse is precisely the extra cost that I've been told in the past is too much. It would be good to verify that this is indeed the case, but we can't just sweep it under the rug, as it is the precise reason we're not just doing Sass-style nesting today.
LeaVerou commentedon Oct 26, 2022
It would be good to get confirmation on that, but also, if keeping tokens around for the duration of parsing a declaration is such a big cost, another option could be to …not. Just keep the raw string around, or even just the index in the source. Of course in the rare cases where you have an element selector followed by a pseudo-class you have to re-tokenize, but it may be deemed a worthy tradeoff? I'm not trying to push a specific proposal, but we should all try to brainstorm about this before we rule the syntax we want out as impossible.
dbaron commentedon Oct 26, 2022
I think some of the things that at least some of today's parsers do today for error handling (e.g., have general rules for how to parse the high level style sheet constructs, and then specific parsers for values of specific properties, designed so that error handling bugs in the specific value parsers can't cause violations of the general rules) is sort of like keeping the tokens around. But I'm not sure if it's exactly like keeping the tokens around, and I think this may vary between implementations.
This is an area that I'm not super-familiar with because the CSS parser that I knew best (the old Gecko one) didn't do this sort of multi-layer parsing, but instead relied on having the error handling correct in all the specific value parsers.
I think you probably need to ask the folks who are closest to today's parsers, but perhaps with a slightly firmer write-up of what it is you'd propose to do.
emilio commentedon Oct 26, 2022
Gecko's CSS parser (https://github.com/servo/rust-cssparser) doesn't keep tokens around other than the very last token we tokenized (as a performance optimization):
https://github.com/servo/rust-cssparser/blob/d4784093d953c56efbb5aa79d94aed646e580e6b/src/parser.rs#L162
It has the ability to restart at arbitrary points. In fact we use them quite aggressively:
https://github.com/servo/rust-cssparser/blob/d4784093d953c56efbb5aa79d94aed646e580e6b/src/parser.rs#L514-L529
So my understanding is that implementing something like this should have no extra overhead for valid declarations, since we already have the parser state from before the start of the declaration even:
https://github.com/servo/rust-cssparser/blob/d4784093d953c56efbb5aa79d94aed646e580e6b/src/rules_and_declarations.rs#L243
It might have extra overhead for invalid declarations however, which are unsurprisingly rather common (due to vendor prefixes and other stuff...), so it'd need some measurement with different inputs to see how bad it is...
It should be feasible to measure this either inside or outside of Firefox btw, happy to help if someone wants to give that a shot.
sesse commentedon Oct 26, 2022
Blink's tokenizer would not be happy about that; we don't always keep the raw string around after tokenization, so it would not be easy for us to re-tokenize (and tokenization is a nontrivial part of the page load cost, so tokenizing things twice would also be bad). So that's both a confirmation that keeping the tokens around are bad, and a NAK on the workaround of re-tokenizing.
lilles commentedon Oct 27, 2022
Even with lookahead, this is still ambiguous per core syntax since blocks are valid component values in a declaration:
For instance inside a style rule, does the semi-colon make a difference between:
and:
?
tabatkins commentedon Oct 27, 2022
Right, a consequence of resolving this is that curly-brace blocks are no longer a valid (top-level) component of properties. (And conversely, top-level semicolons are not valid in selectors, but that's true of the existing Option 3 already, since we have to avoid confusing invalid declarations for blocks.)
We haven't yet tried to use {} for anything in properties, and I think the loss to future extensibility is minimal.
tabatkins commentedon Oct 27, 2022
Never mind, Lea reminded me that custom properties can have {} in them , so nope, this won't work, the ambiguity is indeed unresolvable (in some circumstances).
46 remaining items
Fix cssom subtest for nesting.
Bug 1839946 - Fix cssom subtest for nesting. r=boris
Bug 1839946 - Fix cssom subtest for nesting. r=boris
Bug 1839946 - Fix cssom subtest for nesting. r=boris
tabatkins commentedon Jul 14, 2023
Specs should be updated to the resolutions, so closing now.
yisibl commentedon Aug 31, 2023
WebKit implemented here: WebKit/WebKit#17189
AlbertMarashi commentedon Nov 27, 2023
By the way, if anyone implementing this was confused about how to know whether we're parsing a declaration or a rule this was my algorithm:
;{});or}which are both valid endings)tabatkins commentedon Nov 28, 2023
You might want to check the "Implementation Note" in "consume a block's contents" for even more efficiency wins in this regard.
Bug 1835066 - Fix a Gecko reftest that isn't valid when nesting is en…
Bug 1839946 - Fix cssom subtest for nesting. r=boris
yisibl commentedon Sep 4, 2026
@tabatkins Are there currently any test cases in WPT that use JSON-like values in custom properties?
I think this serves as a good reference for other CSS parsers. esbuild recently fixed this issue in evanw/esbuild@fc2ee9b