L'imbrication CSS est améliorée avec CSSNestedDeclarations

Publié le 8 octobre 2024

Pour corriger certaines bizarreries liées à l'imbrication CSS, le groupe de travail CSS a décidé d'ajouter l'interface CSSNestedDeclarations à la spécification d'imbrication CSS. Avec cette addition, les déclarations qui suivent les règles de style ne sont plus déplacées vers le haut, entre autres améliorations.

Ces modifications sont disponibles dans Chrome à partir de la version 130 et peuvent être testées dans Firefox Nightly 132 et Safari Technology Preview 204.

Navigateurs pris en charge

  • Chrome: 130
  • Edge: 130
  • Firefox : 132.
  • Safari : non compatible.

Problème d'imbrication CSS sans CSSNestedDeclarations

L'un des pièges de l'imbrication CSS est que, à l'origine, l'extrait de code suivant ne fonctionne pas comme vous pourriez le penser:

.foo {
    width: fit-content;

    @media screen {
        background-color: red;
    }
    
    background-color: green;
}

En examinant le code, vous pourriez supposer que l'élément <div class=foo> possède un background-color green, car la déclaration background-color: green; vient en dernier. Ce n'est toutefois pas le cas dans Chrome avant la version 130. Dans ces versions, qui ne sont pas compatibles avec CSSNestedDeclarations, le background-color de l'élément est red.

Après avoir analysé la règle réelle, Chrome avant la version 130 utilise la règle suivante:

.foo {
    width: fit-content;
    background-color: green;

    @media screen {
        & {
            background-color: red;
        }
    }
}

Le CSS après l'analyse a subi deux modifications:

  • background-color: green; a été déplacé vers le haut pour rejoindre les deux autres déclarations.
  • Le CSSMediaRule imbriqué a été réécrit pour encapsuler ses déclarations dans un CSSStyleRule supplémentaire à l'aide du sélecteur &.

Un autre changement typique que vous verriez ici est les propriétés de suppression de l'analyseur qu'il n'est pas compatible.

Vous pouvez inspecter le "CSS après l'analyse" par vous-même en lisant à nouveau le cssText à partir du CSSStyleRule.

Essayez-le vous-même dans ce playground interactif:

Pourquoi ce CSS est-il réécrit ?

Pour comprendre pourquoi cette réécriture interne s'est produite, vous devez comprendre comment cet élément CSSStyleRule est représenté dans le modèle d'objet CSS (CSSOM).

Dans Chrome version antérieure à 130, l'extrait CSS partagé précédemment se sérialise comme suit:

↳ CSSStyleRule
  .type = STYLE_RULE
  .selectorText = ".foo"
  .resolvedSelectorText = ".foo"
  .specificity = "(0,1,0)"
  .style (CSSStyleDeclaration, 2) =
    - width: fit-content
    - background-color: green
  .cssRules (CSSRuleList, 1) =
    ↳ CSSMediaRule
    .type = MEDIA_RULE
    .cssRules (CSSRuleList, 1) =
      ↳ CSSStyleRule
        .type = STYLE_RULE
        .selectorText = "&"
        .resolvedSelectorText = ":is(.foo)"
        .specificity = "(0,1,0)"
        .style (CSSStyleDeclaration, 1) =
          - background-color: red

Parmi toutes les propriétés d'un CSSStyleRule, les deux suivantes sont pertinentes dans ce cas:

  • La propriété style, qui est une instance CSSStyleDeclaration représentant les déclarations.
  • La propriété cssRules, qui est un CSSRuleList contenant tous les objets CSSRule imbriqués.

Étant donné que toutes les déclarations de l'extrait CSS se retrouvent dans la propriété style de CSStyleRule, des informations sont perdues. En examinant la propriété style, il n'est pas clair que background-color: green a été déclarée après CSSMediaRule imbriquée.

↳ CSSStyleRule
  .type = STYLE_RULE
  .selectorText = ".foo"
  .style (CSSStyleDeclaration, 2) =
    - width: fit-content
    - background-color: green
  .cssRules (CSSRuleList, 1) =
    ↳ …

Cela pose problème, car pour qu'un moteur CSS fonctionne correctement, il doit pouvoir distinguer les propriétés qui apparaissent au début du contenu d'une règle de style de celles qui apparaissent entremêlées avec d'autres règles.

Quant aux déclarations dans CSSMediaRule, elles sont soudainement encapsulées dans un CSSStyleRule, car CSSMediaRule n'a pas été conçu pour contenir des déclarations.

Étant donné que CSSMediaRule peut contenir des règles imbriquées (accessibles via sa propriété cssRules), les déclarations sont automatiquement encapsulées dans un CSSStyleRule.

↳ CSSMediaRule
  .type = MEDIA_RULE
  .cssRules (CSSRuleList, 1) =
    ↳ CSSStyleRule
      .type = STYLE_RULE
      .selectorText = "&"
      .resolvedSelectorText = ":is(.foo)"
      .specificity = "(0,1,0)"
      .style (CSSStyleDeclaration, 1) =
        - background-color: red

Comment résoudre ce problème ?

Le groupe de travail CSS a examiné plusieurs options pour résoudre ce problème.

L'une des solutions suggérées consistait à encapsuler toutes les déclarations nues dans un CSSStyleRule imbriqué avec le sélecteur d'imbrication (&). Cette idée a été abandonnée pour diverses raisons, y compris les effets secondaires indésirables suivants du désucrage de & en :is(…):

  • Cela a un effet sur la spécificité. En effet, :is() reprend la spécificité de son argument le plus spécifique.
  • Il ne fonctionne pas bien avec les pseudo-éléments du sélecteur externe d'origine. En effet, :is() n'accepte pas les pseudo-éléments dans son argument de liste de sélecteur.

Prenons l'exemple suivant :

#foo, .foo, .foo::before {
  width: fit-content;
  background-color: red;

  @media screen {
    background-color: green;
  }
}

Après l'analyse, cet extrait devient le suivant dans Chrome 130 et versions antérieures:

#foo,
.foo,
.foo::before {
  width: fit-content;
  background-color: red;

  @media screen {
    & {
      background-color: green;
    }
  }
}

Cela pose problème, car l'CSSRule imbriquée avec le sélecteur &:

  • Aplatit jusqu'à :is(#foo, .foo), en supprimant .foo::before de la liste du sélecteur.
  • La valeur de spécificité est (1,0,0), ce qui rend plus difficile l'écrasement ultérieur.

Pour vérifier cela, inspectez ce que la règle sérialise:

↳ CSSStyleRule
  .type = STYLE_RULE
  .selectorText = "#foo, .foo, .foo::before"
  .resolvedSelectorText = "#foo, .foo, .foo::before"
  .specificity = (1,0,0),(0,1,0),(0,1,1)
  .style (CSSStyleDeclaration, 2) =
    - width: fit-content
    - background-color: red
  .cssRules (CSSRuleList, 1) =
    ↳ CSSMediaRule
      .type = MEDIA_RULE
      .cssRules (CSSRuleList, 1) =
        ↳ CSSStyleRule
          .type = STYLE_RULE
          .selectorText = "&"
          .resolvedSelectorText = ":is(#foo, .foo, .foo::before)"
          .specificity = (1,0,0)
          .style (CSSStyleDeclaration, 1) =
            - background-color: green

Visuellement, cela signifie également que le background-color de .foo::before est red au lieu de green.

Le groupe de travail CSS a également envisagé de vous demander d'encapsuler toutes les déclarations imbriquées dans une règle @nest. Cette option a été ignorée en raison de la régression de l'expérience développeur que cela entraînerait.

Présentation de l'interface CSSNestedDeclarations

La solution adoptée par le groupe de travail CSS est l'introduction de la règle des déclarations imbriquées.

Cette règle de déclaration imbriquée est implémentée dans Chrome à partir de la version 130.

Navigateurs pris en charge

  • Chrome: 130
  • Edge : 130.
  • Firefox : 132.
  • Safari: non compatible.

L'introduction de la règle des déclarations imbriquées modifie l'analyseur CSS pour qu'il encapsule automatiquement les déclarations imbriquées directement consécutives dans une instance CSSNestedDeclarations. Lors de la sérialisation, cette instance CSSNestedDeclarations se retrouve dans la propriété cssRules de CSSStyleRule.

Prenons à nouveau l'exemple de fichier CSSStyleRule suivant:

.foo {
  width: fit-content;

  @media screen {
    background-color: red;
  }
    
  background-color: green;
}

Lorsqu'il est sérialisé dans Chrome 130 ou version ultérieure, il se présente comme suit:

↳ CSSStyleRule
  .type = STYLE_RULE
  .selectorText = ".foo"
  .resolvedSelectorText = ".foo"
  .specificity = (0,1,0)
  .style (CSSStyleDeclaration, 1) =
    - width: fit-content
  .cssRules (CSSRuleList, 2) =
    ↳ CSSMediaRule
      .type = MEDIA_RULE
      .cssRules (CSSRuleList, 1) =
        ↳ CSSNestedDeclarations
          .style (CSSStyleDeclaration, 1) =
            - background-color: red
    ↳ CSSNestedDeclarations
      .style (CSSStyleDeclaration, 1) =
        - background-color: green

Étant donné que la règle CSSNestedDeclarations se termine dans CSSRuleList, l'analyseur peut conserver la position de la déclaration background-color: green: après la déclaration background-color: red (qui fait partie de CSSMediaRule).

De plus, la présence d'une instance CSSNestedDeclarations n'entraîne aucun des effets secondaires indésirables que les autres solutions potentielles, désormais abandonnées, ont causés: la règle de déclaration imbriquée correspond exactement aux mêmes éléments et pseudo-éléments que sa règle de style parente, avec le même comportement de spécificité.

Pour le prouver, lisez à nouveau le cssText de l'CSSStyleRule. Grâce à la règle des déclarations imbriquées, il est identique au CSS d'entrée:

.foo {
  width: fit-content;

  @media screen {
    background-color: red;
  }
    
  background-color: green;
}

Ce que cela implique pour vous

Cela signifie que l'imbrication CSS a été considérablement améliorée à partir de Chrome 130. Cela signifie également que vous devrez peut-être revoir certains de vos codes si vous intercalez des déclarations brutes avec des règles imbriquées.

Prenons l'exemple suivant qui utilise la merveilleuse @starting-style.

/* This does not work in Chrome 130 */
#mypopover:popover-open {
  @starting-style {
    opacity: 0;
    scale: 0.5;
  }

  opacity: 1;
  scale: 1;
}

Avant Chrome 130, ces déclarations étaient hissées. Les déclarations opacity: 1; et scale: 1; seront alors insérées dans CSSStyleRule.style, suivies d'un CSSStartingStyleRule (représentant la règle @starting-style) dans CSSStyleRule.cssRules.

À partir de Chrome 130, les déclarations ne sont plus hissées, et vous obtenez deux objets CSSRule imbriqués dans CSSStyleRule.cssRules. Dans l'ordre: un élément CSSStartingStyleRule (représentant la règle @starting-style) et un élément CSSNestedDeclarations contenant les déclarations opacity: 1; scale: 1;.

En raison de ce changement de comportement, les déclarations @starting-style sont écrasées par celles contenues dans l'instance CSSNestedDeclarations, ce qui supprime l'animation d'entrée.

Pour corriger le code, assurez-vous que le bloc @starting-style vient après les déclarations régulières. Par exemple :

/* This works in Chrome 130 */
#mypopover:popover-open {
  opacity: 1;
  scale: 1;

  @starting-style {
    opacity: 0;
    scale: 0.5;
  }
}

Si vous conservez vos déclarations imbriquées au-dessus des règles imbriquées lorsque vous utilisez l'imbrication CSS, votre code fonctionne essentiellement avec toutes les versions de tous les navigateurs compatibles avec l'imbrication CSS.

Enfin, si vous souhaitez que les fonctionnalités détectent la disponibilité de CSSNestedDeclarations, vous pouvez utiliser l'extrait JavaScript suivant:

if (!("CSSNestedDeclarations" in self && "style" in CSSNestedDeclarations.prototype)) {
  // CSSNestedDeclarations is not available
}