MithrilJS / MithrilJS/mithril.js

Updates to keyed lists break FLIP animations when they occur mid-animation

Open
#2,612 6 comments 0 reactions 1 assignee View on GitHub

@dead-claudia is already working on this.

Since Jul 19, 2020.

Area: Core Type: Bug
Dominant language
JavaScript
Stars
14.5k
Forks
922
PR merge metrics
No merged PRs in 30d

Description

Mithril version: 2.0.4

Browser and OS: Chrome 83.0.4103.116 on Windows 64-bit, Safari 13.1 on iOS, Firefox 78.0.2 on Windows 64-bit

Project:

Code

Live Flems

<button id="toggle">Toggle</button>
<div style="display:flex">
  <ul id="prev"><li>0</li><li>1</li><li>2</li><li>3</li><li>4</li><li>5</li><li>6</li><li>7</li></ul>
  <ul id="animate"></ul>
  <ul id="next"><li>0</li><li>1</li><li>2</li><li>3</li><li>4</li><li>5</li><li>6</li><li>7</li></ul>
</div>
const animateRoot = document.getElementById("animate")
let values = Array.from({length: 8}, (_, i) => i)

function render() {
  m.render(animateRoot, values.map(i => m("li", {key: i}, i)))
}

let interval = setTimeout(updateBody, 500)

function updateBody() {
  interval = setTimeout(updateBody, 500)
  const prev = values
  // https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle
  const next = values = prev.slice()
  for (let i = next.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1))
    const temp = next[j]
    next[j] = next[i]
    next[i] = temp
  }

  requestAnimationFrame(() => {
    const prevList = document.getElementById("prev").childNodes
    const nextList = document.getElementById("next").childNodes

    // Reorder elements in DOM
    for (let i = 0; i < prev.length; i++) {
      prevList[i].textContent = prev[i]
    }
    for (let i = 0; i < next.length; i++) {
      nextList[i].textContent = next[i]
    }

    const prevElems = Array.from(animateRoot.childNodes)
    
    // First
    const firstParent = animateRoot.getBoundingClientRect()
    const first = prevElems.map(e => {
      const child = e.getBoundingClientRect()
      return {
        top: child.top - firstParent.top,
        left: child.left - firstParent.left,
      }
    })

    render()

    // Last + Invert
    const lastParent = animateRoot.getBoundingClientRect()
    const movedElems = prevElems.filter((e, i) => {
      const last = e.getBoundingClientRect()
      const dx = first[i].left - (last.left - lastParent.left)
      const dy = first[i].top - (last.top - lastParent.top)
      if (dx === 0 && dy === 0) return false
      e.style.transform = `translate(${dx}px,${dy}px)`
      e.style.transitionDuration = "0s"
      return true
    })

    // Force re-layout
    document.body.offsetHeight

    // Play
    movedElems.forEach((e, i) => {
      const callback = () => {
        e.removeEventListener("transitionend", callback, false)
        e.classList.remove("transition")
      }
      e.classList.add("transition")
      e.addEventListener("transitionend", callback, false)
      e.style.transform = e.style.transitionDuration = ''
    })
  })
}

document.getElementById('toggle').onclick = () => {
  if (interval) {
    clearTimeout(interval)
    interval = null
  } else {
    updateBody()
  }
  p('toggle', interval != null)
}

render()

Steps to Reproduce

  1. Open web page

You can enable and disable the animations at will using the "Toggle" button, but that's not part of the repro.

Expected Behavior

The animation to be smooth.

Current Behavior

The animation appears choppy whenever the subtree updates, and list items often unexpectedly jump.

Context

The following vanilla code works correctly. It's a near-exact clone of the above, but it simply uses appendChild instead of a full keyed diff.

Live Flems

<button id="toggle">Toggle</button>
<div style="display:flex">
  <ul id="prev"><li>0</li><li>1</li><li>2</li><li>3</li><li>4</li><li>5</li><li>6</li><li>7</li></ul>
  <ul id="animate"><li>0</li><li>1</li><li>2</li><li>3</li><li>4</li><li>5</li><li>6</li><li>7</li></ul>
  <ul id="next"><li>0</li><li>1</li><li>2</li><li>3</li><li>4</li><li>5</li><li>6</li><li>7</li></ul>
</div>
const animateRoot = document.getElementById("animate")
let cachedElems = Array.from(animateRoot.childNodes)
let interval = setTimeout(updateBody, 500)

function render() {
  for (const elem of cachedElems) animateRoot.appendChild(elem)
}

function updateBody() {
  interval = setTimeout(updateBody, 500)

  const prevElems = cachedElems
  const nextElems = cachedElems = prevElems.slice()
  // https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle
  for (let i = nextElems.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1))
    const temp = nextElems[j]
    nextElems[j] = nextElems[i]
    nextElems[i] = temp
  }

  requestAnimationFrame(() => {
    const prevList = document.getElementById("prev").childNodes
    const nextList = document.getElementById("next").childNodes

    // Reorder elements in DOM
    for (let i = 0; i < prevElems.length; i++) {
      prevList[i].textContent = prevElems[i].textContent
    }

    for (let i = 0; i < nextElems.length; i++) {
      nextList[i].textContent = nextElems[i].textContent
    }

    // First
    const firstParent = animateRoot.getBoundingClientRect()
    const first = prevElems.map(e => {
      const child = e.getBoundingClientRect()
      return {
        top: child.top - firstParent.top,
        left: child.left - firstParent.left,
      }
    })

    render()

    // Last + Invert
    const lastParent = animateRoot.getBoundingClientRect()
    const movedElems = prevElems.filter((e, i) => {
      const last = e.getBoundingClientRect()
      const dx = first[i].left - (last.left - lastParent.left)
      const dy = first[i].top - (last.top - lastParent.top)
      if (dx === 0 && dy === 0) return false
      e.style.transform = `translate(${dx}px,${dy}px)`
      e.style.transitionDuration = "0s"
      return true
    })

    // Force re-layout
    document.body.offsetHeight

    // Play
    movedElems.forEach((e, i) => {
      const callback = () => {
        e.removeEventListener("transitionend", callback, false)
        e.classList.remove("transition")
      }
      e.classList.add("transition")
      e.addEventListener("transitionend", callback, false)
      e.style.transform = e.style.transitionDuration = ''
    })
  })
}

// Utility bits
document.getElementById('toggle').onclick = () => {
  if (interval) {
    clearTimeout(interval)
    interval = null
  } else {
    updateBody()
  }
  p('toggle', interval != null)
}

render()

Edit:

Mithril's far from the only one with this issue: https://twitter.com/isiahmeadows1/status/1284726730574315522

Note: Vue is not affected by this bug and carries the same behavior as the vanilla version - go here and spam the "Shuffle" button to see.

Edit 2: Also relevant: https://github.com/whatwg/html/issues/5742

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.