El anidamiento de CSS mejora con CSSNestedDeclarations

Fecha de publicación: 8 de octubre de 2024

Para corregir algunas peculiaridades extrañas con el anidamiento de CSS, el grupo de trabajo de CSS resolvió agregar la interfaz CSSNestedDeclarations a la especificación de anidamiento de CSS. Con esta incorporación, las declaraciones que aparecen después de las reglas de estilo ya no se desplazan hacia arriba, entre otras mejoras.

Estos cambios están disponibles en Chrome a partir de la versión 130 y están listos para probarse en Firefox Nightly 132 y Safari Technology Preview 204.

Navegadores compatibles

  • Chrome: 130.
  • Edge: 130.
  • Firefox: 132.
  • Safari: No se admite.

El problema con el anidamiento de CSS sin CSSNestedDeclarations

Uno de los problemas con el anidamiento de CSS es que, originalmente, el siguiente fragmento no funciona como podrías esperar inicialmente:

.foo {
    width: fit-content;

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

Si observas el código, supongamos que el elemento <div class=foo> tiene una background-color green porque la declaración background-color: green; es el último lugar. Sin embargo, este no era el caso en las versiones anteriores a la 130 de Chrome. En esas versiones, que no admiten CSSNestedDeclarations, el background-color del elemento es red.

Después de analizar la regla real, Chrome anterior a 130 usos es el siguiente:

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

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

El CSS después del análisis sufrió dos cambios:

  • El background-color: green; se movió hacia arriba para unirse a las otras dos declaraciones.
  • El CSSMediaRule anidado se volvió a escribir para unir sus declaraciones en un CSSStyleRule adicional con el selector &.

Otro cambio típico que verías aquí es que el analizador descarta las propiedades que no admite.

Para inspeccionar el "CSS después del análisis", vuelve a leer el cssText del CSSStyleRule.

Pruébalas tú mismo en esta zona de pruebas interactiva:

¿Por qué se reescribe este CSS?

Para comprender por qué se produjo esta reescritura interna, debes comprender cómo se representa este CSSStyleRule en el modelo de objetos CSS (CSSOM).

En Chrome anterior a la versión 130, el fragmento de CSS que se compartió antes se serializa de la siguiente manera:

↳ 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

De todas las propiedades que tiene un CSSStyleRule, las siguientes dos son relevantes en este caso:

  • La propiedad style, que es una instancia de CSSStyleDeclaration que representa las declaraciones
  • La propiedad cssRules, que es un objeto CSSRuleList que contiene todos los objetos CSSRule anidados.

Como todas las declaraciones del fragmento de CSS terminan en la propiedad style de CSStyleRule, se produce una pérdida de información. Cuando se observa la propiedad style, no está claro que background-color: green se haya declarado después del CSSMediaRule anidado.

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

Esto es problemático, ya que, para que un motor de CSS funcione correctamente, debe poder distinguir las propiedades que aparecen al comienzo del contenido de una regla de estilo de aquellas que aparecen intercaladas con otras reglas.

En cuanto a las declaraciones dentro de CSSMediaRule que se unen de repente en un CSSStyleRule, esto se debe a que CSSMediaRule no se diseñó para contener declaraciones.

Debido a que CSSMediaRule puede contener reglas anidadas a las que se puede acceder a través de su propiedad cssRules, las declaraciones se unen automáticamente en una 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

¿Cómo se resuelve este problema?

El grupo de trabajo del CSS analizó varias opciones para resolver este problema.

Una de las soluciones sugeridas fue unir todas las declaraciones sin formato en un CSSStyleRule anidado con el selector de anidación (&). Esta idea se descartó por varios motivos, incluidos los siguientes efectos secundarios no deseados de la expansión de sintaxis de & a :is(…):

  • Tiene un efecto en la especificidad. Esto se debe a que :is() se apropia de la especificidad de su argumento más específico.
  • No funciona bien con pseudoelementos en el selector externo original. Esto se debe a que :is() no acepta pseudoelementos en su argumento de lista de selectores.

Considera el siguiente ejemplo:

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

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

Después de analizar ese fragmento, se convierte en lo siguiente en Chrome anterior a la versión 130:

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

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

Esto es un problema porque el CSSRule anidado con el selector & hace lo siguiente:

  • Se aplana hasta :is(#foo, .foo) y descarta el .foo::before de la lista del selector a lo largo del camino.
  • Tiene una especificidad de (1,0,0), lo que dificulta la posibilidad de reemplazarlo más adelante.

Para comprobarlo, inspecciona a qué serializa la regla:

↳ 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

Visualmente, también significa que el background-color de .foo::before es red en lugar de green.

Otro enfoque que analizó el grupo de trabajo del CSS fue que uniras todas las declaraciones anidadas en una regla @nest. Se descartó debido a la regresión en la experiencia del desarrollador que esto causaría.

Presentamos la interfaz CSSNestedDeclarations

La solución en la que se estableció el grupo de trabajo de CSS es la introducción de la regla de declaraciones anidadas.

Esta regla de declaraciones anidadas se implementa en Chrome a partir de la versión 130.

Navegadores compatibles

  • Chrome: 130.
  • Edge: 130.
  • Firefox: 132.
  • Safari: no es compatible.

La introducción de la regla de declaraciones anidadas cambia el analizador de CSS para unir automáticamente las declaraciones consecutivas anidadas directamente en una instancia de CSSNestedDeclarations. Cuando se serializa, esta instancia de CSSNestedDeclarations termina en la propiedad cssRules de CSSStyleRule.

Tomemos nuevamente el siguiente CSSStyleRule como ejemplo:

.foo {
  width: fit-content;

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

Cuando se serializa en Chrome 130 o versiones posteriores, se ve de la siguiente manera:

↳ 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

Debido a que la regla CSSNestedDeclarations termina en CSSRuleList, el analizador puede retener la posición de la declaración background-color: green: después de la declaración background-color: red (que forma parte de CSSMediaRule).

Además, tener una instancia de CSSNestedDeclarations no presenta ninguno de los efectos secundarios desagradables que causaron las otras posibles soluciones, ahora descartadas: la regla de declaraciones anidadas coincide con los mismos elementos y pseudoelementos que su regla de estilo superior, con el mismo comportamiento de especificidad.

La prueba de esto es volver a leer el cssText del CSSStyleRule. Gracias a la regla de declaraciones anidadas, es igual que el CSS de entrada:

.foo {
  width: fit-content;

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

Qué significa esto para ti

Esto significa que la anidación de CSS mejoró mucho a partir de Chrome 130. Sin embargo, también significa que tal vez debas revisar parte de tu código si intercalas declaraciones básicas con reglas anidadas.

Considera el siguiente ejemplo que usa el maravilloso @starting-style.

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

  opacity: 1;
  scale: 1;
}

Antes de Chrome 130, esas declaraciones se elevaban. Terminarás con las declaraciones opacity: 1; y scale: 1; que ingresan en CSSStyleRule.style, seguidas de un CSSStartingStyleRule (que representa la regla @starting-style) en CSSStyleRule.cssRules.

A partir de Chrome 130, las declaraciones ya no se elevan y terminas con dos objetos CSSRule anidados en CSSStyleRule.cssRules. En orden: un CSSStartingStyleRule (que representa la regla @starting-style) y un CSSNestedDeclarations que contiene las declaraciones opacity: 1; scale: 1;.

Debido a este comportamiento modificado, las declaraciones de @starting-style se reemplazan por las que se contienen en la instancia de CSSNestedDeclarations, lo que quita la animación de entrada.

Para corregir el código, asegúrate de que el bloque @starting-style aparezca después de las declaraciones normales. Sería algo como lo siguiente:

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

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

Si mantienes tus declaraciones anidadas por encima de las reglas anidadas cuando usas el anidamiento de CSS, tu código funciona en su mayoría bien con todas las versiones de todos los navegadores que admiten la anidación de CSS.

Por último, si quieres que detecte la función de CSSNestedDeclarations disponible, puedes usar el siguiente fragmento de JavaScript:

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