Safari에서 Virtual DOM :last-child 스타일 버그

Vue.js로 만들어진 페이지에서 아이폰에서만 테두리가 잘못 나온다고 전달을 받았다.
확인해보니 맥 Safari에서도 재현이 되는데 li:last-child에 지정해놓은 스타일이 마지막이 아닌 마지막 바로 위에 있는 li에도 적용되고 있었다. 조금 더 관찰해보니 다음과 같은 특징이 있었다.

재현이미지

  • 여러번 테스트해본 결과 해당 영역에 새로운 DOM이 추가되는 변화가 있을 때 발생했다.
  • 기존 DOM과 새로 추가된 DOM에 각각 :last-child가 이중으로 적용되는 현상이다.
  • 개발자 도구로 CSS에 엔터만 넣는 식의 스타일 변화없이 리로드만 시키는 수정만 해도 정상적으로 노출된다.
  • Safari에서만 발생한다. (테스트 버전 12버전대)

서비스 오픈하자마자 발견된 사항이라 빠른 해결이 필요했고 해결 가능한 방법은 우연히 알아냈다.

javascript – iOS8 Safari pushState 후 : nth-child () 셀렉터가 작동하지 않습니다.

힌트가 된 글인데 현재 글 쓰는 시점에서 iOS가 12버전인 시대에서 iOS8 버전의 문제는 큰 도움이 되진 않지만 :nth-child가 문제를 일으킨 전적이 있다는 점에 주목하여 마찬가지로 :last-of-type으로 지정했더니 해당 문제는 더이상 발생하지 않았고 이 변화로 다른 문제도 발생하지 않았다.

그리고 팀 보고 후 팀장님이 하드웨어 가속과 관련하여 해결할 수 있는 사항이 아닌지 언급하셔서 가볍게 테스트해봤더니 이렇게도 해결이 가능하여 하드웨어 가속과 관련한 사항이라고 정리를 하려고 했었다.

확실한 정리를 위해 해당 프로젝트가 아닌 별도의 테스트용 뷰 프로젝트를 만들어 테스트를 시작했고, 최대한 가볍게 만들어보니 재현이 쉽지 않았다. 그도 그럴 것이 이렇게 크리티컬한 문제라면 개선됐어야 했고 개선이 안됐다 하더라도 구글링해서 확인할 길이 있었어야 하는데 아무도 이 문제에 대해 이야기하고 있지 않았다. 그리고 한참 삽질한 끝에 정확하게 이 상황을 재현할 수 있는 방법을 알아냈다.

:last-child에 해당하는 DOM이 아닌 이 엘리먼트의 자식 엘리먼트에만 스타일이 지정되어 있을 경우 재현이 된다.

즉, 추가 조건으로 li:last-child에 스타일이 걸려있을 때 문제가 아니라 li:last-child a와 같이 자식에게 스타일이 지정되어 있을때 재현이 됐다. 그리고 한번 이상 가상돔 변화가 있는 영역에서만 발생했다.

다시 정리해보면,

  • :last-child에 해당하는 엘리먼트 자체가 아닌 이 엘리먼트의 자식 엘리먼트에만 스타일이 지정되어 있을 경우 재현이 된다.
  • 1회 이상 가상돔 변화가 이미 있었던 영역에서 발생한다.
  • 운영에 적용한 해결방법대로 last-of-type으로 해결 가능하다.
  • transform: translateZ(0) 같이 하드웨어 가속을 임의로 하게 하는 핵을 써도 해결이 가능하다.

그리고 글을 쓰기 위해 정리하다가 하드웨어 가속 핵을 쓰지 않아도 해결이 가능하다는 것을 알았다. li:last-child 자체에 의미 없는 스타일을 지정해도 해결이 가능하다.

여러가지 재현이미지

최종 정리하면,

  • 정확한 재현 방법은 새로운 DOM이 추가되는 이벤트가 있어야하고 스타일은 :last-child a처럼 마지막 엘리먼트의 자식에만 스타일을 지정해야한다.
  • 해결방법으로는 :last-child대신 :last-of-type으로 바꾸는 것으로 해결이 가능하다.
  • :last-child를 유지하며 해결하는 방법은 :last-child자체에도 (의미가 없더라도) 최소한 1개의 스타일을 넣어준다.
  • 굳이 하드웨어 가속 핵을 사용하지 않아도 된다.

간단히 vue-cli로 프로젝트를 생성하여 재현한 코드는 다음이다.

<template>
  <div id="app">
    <button type="button" @click="listToggle">Toggle Button</button>
    <section>
      <div :class="'sandbox type' + i" v-for="(p, i) in type" :key="i">
        <h1>{{ p }}</h1>
        <ul>
          <li v-for="(t, j) in items" :key="j">
            <span>{{ t }}</span>
          </li>
        </ul>
      </div>
    </section>
  </div>
</template>

<script>
export default {
  name: 'app',
  data() {
    return {
      type: [
        ':last-child',
        ':last-of-type',
        ':last-child + translate3d',
        ':last-child + ANYONE'
      ],
      items: [
        '1st', '2nd'
      ]
    }
  },
  methods: {
    listToggle: function() {
      let arr = this._data.items;
      if (arr.length > 2) {
        arr.pop();
      } else {
        arr.push('3rd', '4th', '5th');
      }
    }
  }
}
</script>

<style lang="scss">
body {
  background: #eee;
}

button {
  appearance: none;
  display: block;
  border: 0;
  width: 100%;
  height: 100px;
  font-size: 100%;
}

section {
  display: flex;
  margin-top: 20px;

  .sandbox {
    flex: 1;
    padding: 0 .5vw;

    h1 {
      height: 60px;
      padding: 10px 0;
      margin: 0;
      border-bottom: 5px double #000;
      text-align: center;
    }
  }
}

ul {
  margin: 0;
  padding: 0;

  li {
    list-style: none;
    height: 100px;
    line-height: 100px;
    text-align: center;
    border-bottom: 5px double #aaa;

    span {
      display: block;
      background: #fff;
    }

    .type0 &:last-child span {
      background: #faa;
    }

    .type1 &:last-of-type span {
      background: #afa;
    }

    .type2 &:last-child {
      transform: translateZ(0);

      span {
        background: #aaf;
      }
    }

    .type3 &:last-child {
      pointer-events: none;

      span {
        background: #faf;
      }
    }
  }
}
</style>