Skip to content

Commit

Permalink
update css nesting stuff to match the latest spec
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Feb 20, 2023
1 parent 47e54fe commit 16e0988
Show file tree
Hide file tree
Showing 9 changed files with 359 additions and 181 deletions.
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,34 @@

## Unreleased

* Update esbuild's handling of CSS nesting to match the latest specification changes ([#1945](https://github.com/evanw/esbuild/issues/1945))

The syntax for the upcoming CSS nesting feature has [recently changed](https://webkit.org/blog/13813/try-css-nesting-today-in-safari-technology-preview/). The `@nest` prefix that was previously required in some cases is now gone, and nested rules no longer have to start with `&` (as long as they don't start with an identifier or function token).

This release updates esbuild's pass-through handling of CSS nesting syntax to match the latest specification changes. So you can now use esbuild to bundle CSS containing nested rules and try them out in a browser that supports CSS nesting (which includes nightly builds of both Chrome and Safari).

However, I'm not implementing lowering of nested CSS to non-nested CSS for older browsers yet. While the syntax has been decided, the semantics are still in flux. In particular, there is still some debate about changing the fundamental way that CSS nesting works. For example, you might think that the following CSS is equivalent to a `.outer .inner button { ... }` rule:

```css
.inner button {
.outer & {
color: red;
}
}
```

But instead it's actually equivalent to a `.outer :is(.inner button) { ... }` rule which unintuitively also matches the following DOM structure:

```html
<div class="inner">
<div class="outer">
<button></button>
</div>
</div>
```

The `:is()` behavior is preferred by browser implementers because it's more memory-efficient, but the straightforward translation into a `.outer .inner button { ... }` rule is preferred by developers used to the existing CSS preprocessing ecosystem (e.g. SASS). It seems premature to commit esbuild to specific semantics for this syntax at this time given the ongoing debate.

* Fix cross-file CSS rule deduplication involving `url()` tokens ([#2936](https://github.com/evanw/esbuild/issues/2936))

Previously cross-file CSS rule deduplication didn't handle `url()` tokens correctly. These tokens contain references to import paths which may be internal (i.e. in the bundle) or external (i.e. not in the bundle). When comparing two `url()` tokens for equality, the underlying import paths should be compared instead of their references. This release of esbuild fixes `url()` token comparisons. One side effect is that `@font-face` rules should now be deduplicated correctly across files:
Expand Down
66 changes: 59 additions & 7 deletions internal/bundler_tests/bundler_css_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -700,18 +700,71 @@ func TestCSSExternalQueryAndHashMatchIssue1822(t *testing.T) {
func TestCSSNestingOldBrowser(t *testing.T) {
css_suite.expectBundled(t, bundled{
files: map[string]string{
"/entry.css": `
a { &:hover { color: red; } }
`,
"/nested-@layer.css": `a { @layer base { color: red; } }`,
"/nested-@media.css": `a { @media screen { color: red; } }`,
"/nested-ampersand.css": `a { &, & { color: red; } }`,
"/nested-attribute.css": `a { [href] { color: red; } }`,
"/nested-colon.css": `a { :hover { color: red; } }`,
"/nested-dot.css": `a { .cls { color: red; } }`,
"/nested-greaterthan.css": `a { > b { color: red; } }`,
"/nested-hash.css": `a { #id { color: red; } }`,
"/nested-plus.css": `a { + b { color: red; } }`,
"/nested-tilde.css": `a { ~ b { color: red; } }`,

"/toplevel-ampersand.css": `a { &, & { color: red; } }`,
"/toplevel-attribute.css": `a { [href] { color: red; } }`,
"/toplevel-colon.css": `a { :hover { color: red; } }`,
"/toplevel-dot.css": `a { .cls { color: red; } }`,
"/toplevel-greaterthan.css": `a { > b { color: red; } }`,
"/toplevel-hash.css": `a { #id { color: red; } }`,
"/toplevel-plus.css": `a { + b { color: red; } }`,
"/toplevel-tilde.css": `a { ~ b { color: red; } }`,
},
entryPaths: []string{
"/nested-@layer.css",
"/nested-@media.css",
"/nested-ampersand.css",
"/nested-attribute.css",
"/nested-colon.css",
"/nested-dot.css",
"/nested-greaterthan.css",
"/nested-hash.css",
"/nested-plus.css",
"/nested-tilde.css",

"/toplevel-ampersand.css",
"/toplevel-attribute.css",
"/toplevel-colon.css",
"/toplevel-dot.css",
"/toplevel-greaterthan.css",
"/toplevel-hash.css",
"/toplevel-plus.css",
"/toplevel-tilde.css",
},
entryPaths: []string{"/entry.css"},
options: config.Options{
Mode: config.ModeBundle,
AbsOutputFile: "/out.css",
AbsOutputDir: "/out",
UnsupportedCSSFeatures: compat.Nesting,
OriginalTargetEnv: "chrome10",
},
expectedScanLog: `entry.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
expectedScanLog: `nested-@layer.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
nested-@media.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
nested-ampersand.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
nested-attribute.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
nested-colon.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
nested-dot.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
nested-greaterthan.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
nested-hash.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
nested-plus.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
nested-tilde.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
toplevel-ampersand.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
toplevel-attribute.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
toplevel-colon.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
toplevel-dot.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
toplevel-greaterthan.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
toplevel-hash.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
toplevel-plus.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
toplevel-tilde.css: WARNING: CSS nesting syntax is not supported in the configured target environment (chrome10)
`,
})
}
Expand Down Expand Up @@ -802,6 +855,5 @@ func TestDeduplicateRules(t *testing.T) {
AbsOutputDir: "/out",
MinifySyntax: true,
},
expectedScanLog: "no0.css: WARNING: CSS nesting syntax cannot be used outside of a style rule\n",
})
}
144 changes: 141 additions & 3 deletions internal/bundler_tests/snapshots/snapshots_css.txt
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,148 @@ console.log(void 0);

================================================================================
TestCSSNestingOldBrowser
---------- /out.css ----------
/* entry.css */
---------- /out/nested-@layer.css ----------
/* nested-@layer.css */
a {
@layer base {
color: red;
}
}

---------- /out/nested-@media.css ----------
/* nested-@media.css */
a {
@media screen {
color: red;
}
}

---------- /out/nested-ampersand.css ----------
/* nested-ampersand.css */
a {
&,
& {
color: red;
}
}

---------- /out/nested-attribute.css ----------
/* nested-attribute.css */
a {
[href] {
color: red;
}
}

---------- /out/nested-colon.css ----------
/* nested-colon.css */
a {
:hover {
color: red;
}
}

---------- /out/nested-dot.css ----------
/* nested-dot.css */
a {
.cls {
color: red;
}
}

---------- /out/nested-greaterthan.css ----------
/* nested-greaterthan.css */
a {
> b {
color: red;
}
}

---------- /out/nested-hash.css ----------
/* nested-hash.css */
a {
#id {
color: red;
}
}

---------- /out/nested-plus.css ----------
/* nested-plus.css */
a {
+ b {
color: red;
}
}

---------- /out/nested-tilde.css ----------
/* nested-tilde.css */
a {
~ b {
color: red;
}
}

---------- /out/toplevel-ampersand.css ----------
/* toplevel-ampersand.css */
a {
&,
& {
color: red;
}
}

---------- /out/toplevel-attribute.css ----------
/* toplevel-attribute.css */
a {
[href] {
color: red;
}
}

---------- /out/toplevel-colon.css ----------
/* toplevel-colon.css */
a {
:hover {
color: red;
}
}

---------- /out/toplevel-dot.css ----------
/* toplevel-dot.css */
a {
.cls {
color: red;
}
}

---------- /out/toplevel-greaterthan.css ----------
/* toplevel-greaterthan.css */
a {
> b {
color: red;
}
}

---------- /out/toplevel-hash.css ----------
/* toplevel-hash.css */
a {
#id {
color: red;
}
}

---------- /out/toplevel-plus.css ----------
/* toplevel-plus.css */
a {
+ b {
color: red;
}
}

---------- /out/toplevel-tilde.css ----------
/* toplevel-tilde.css */
a {
&:hover {
~ b {
color: red;
}
}
Expand Down
21 changes: 6 additions & 15 deletions internal/css_ast/css_ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,12 +437,11 @@ func (r *RUnknownAt) Hash() (uint32, bool) {
type RSelector struct {
Selectors []ComplexSelector
Rules []Rule
HasAtNest bool
}

func (a *RSelector) Equal(rule R, check *CrossFileEqualityCheck) bool {
b, ok := rule.(*RSelector)
if ok && len(a.Selectors) == len(b.Selectors) && a.HasAtNest == b.HasAtNest {
if ok && len(a.Selectors) == len(b.Selectors) {
for i, ai := range a.Selectors {
if !ai.Equal(b.Selectors[i], check) {
return false
Expand Down Expand Up @@ -606,7 +605,7 @@ func (a ComplexSelector) Equal(b ComplexSelector, check *CrossFileEqualityCheck)

for i, ai := range a.Selectors {
bi := b.Selectors[i]
if ai.NestingSelector != bi.NestingSelector || ai.Combinator != bi.Combinator {
if ai.HasNestingSelector != bi.HasNestingSelector || ai.Combinator != bi.Combinator {
return false
}

Expand All @@ -629,19 +628,11 @@ func (a ComplexSelector) Equal(b ComplexSelector, check *CrossFileEqualityCheck)
return true
}

type NestingSelector uint8

const (
NestingSelectorNone NestingSelector = iota
NestingSelectorPrefix // "&a {}"
NestingSelectorPresentButNotPrefix // "a& {}"
)

type CompoundSelector struct {
Combinator string // Optional, may be ""
TypeSelector *NamespacedName
SubclassSelectors []SS
NestingSelector NestingSelector // "&"
Combinator string // Optional, may be ""
TypeSelector *NamespacedName
SubclassSelectors []SS
HasNestingSelector bool // "&"
}

type NameToken struct {
Expand Down
Loading

0 comments on commit 16e0988

Please sign in to comment.