perf: make timeline rendering less janky (#1747)

* perf: make timeline rendering less janky

1. Ensures all statuses are rendered from top to bottom (no more shuffling-card-effect rendering)
2. Wraps all individual status renders in their own requestIdleCallback to improve input responsiveness especially only slow devices like Nexus 5.

* fix focus restoration

* only do rIC on mobile
This commit is contained in:
Nolan Lawson 2020-04-26 16:54:00 -07:00 committed by GitHub
parent 06a403df28
commit ae3bd2bda2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 116 additions and 64 deletions

View File

@ -163,6 +163,7 @@
"matchMedia",
"performance",
"postMessage",
"queueMicrotask",
"requestAnimationFrame",
"requestIdleCallback",
"self",

View File

@ -43,29 +43,13 @@ async function decodeAllBlurhashes (statusOrNotification) {
}))
stop(`decodeBlurhash-${status.id}`)
}
return statusOrNotification
}
export function createMakeProps (instanceName, timelineType, timelineValue) {
let taskCount = 0
let pending = []
let promiseChain = Promise.resolve()
tryInitBlurhash() // start the blurhash worker a bit early to save time
// The worker-powered indexeddb promises can resolve in arbitrary order,
// causing the timeline to load in a jerky way. With this function, we
// wait for all promises to resolve before resolving them all in one go.
function awaitAllTasksComplete () {
return new Promise(resolve => {
taskCount--
pending.push(resolve)
if (taskCount === 0) {
pending.forEach(_ => _())
pending = []
}
})
}
async function fetchFromIndexedDB (itemId) {
mark(`fetchFromIndexedDB-${itemId}`)
try {
@ -78,13 +62,20 @@ export function createMakeProps (instanceName, timelineType, timelineValue) {
}
}
return (itemId) => {
taskCount++
async function getStatusOrNotification (itemId) {
const statusOrNotification = await fetchFromIndexedDB(itemId)
await decodeAllBlurhashes(statusOrNotification)
return statusOrNotification
}
return fetchFromIndexedDB(itemId)
.then(decodeAllBlurhashes)
.then(statusOrNotification => {
return awaitAllTasksComplete().then(() => statusOrNotification)
// The results from IndexedDB or the worker thread can return in random order,
// so we ensure consistent ordering based on the order this function is called in.
return itemId => {
const getStatusOrNotificationPromise = getStatusOrNotification(itemId) // start the promise ASAP
return new Promise((resolve, reject) => {
promiseChain = promiseChain
.then(() => getStatusOrNotificationPromise)
.then(resolve, reject)
})
}
}

View File

@ -1,11 +1,8 @@
import { showMoreItemsForCurrentTimeline } from './timeline'
import { scrollToTop } from '../_utils/scrollToTop'
import { scheduleIdleTask } from '../_utils/scheduleIdleTask'
import { createStatusOrNotificationUuid } from '../_utils/createStatusOrNotificationUuid'
import { store } from '../_store/store'
const RETRIES = 5
const TIMEOUT = 50
import { tryToFocusElement } from '../_utils/tryToFocusElement'
export function showMoreAndScrollToTop () {
// Similar to Twitter, pressing "." will click the "show more" button and select
@ -24,25 +21,9 @@ export function showMoreAndScrollToTop () {
const notificationId = currentTimelineType === 'notifications' && firstItemSummary.id
const statusId = currentTimelineType !== 'notifications' && firstItemSummary.id
scrollToTop(/* smooth */ false)
// try 5 times to wait for the element to be rendered and then focus it
let count = 0
const tryToFocusElement = () => {
const uuid = createStatusOrNotificationUuid(
const id = createStatusOrNotificationUuid(
currentInstance, currentTimelineType,
currentTimelineValue, notificationId, statusId
)
const element = document.getElementById(uuid)
if (element) {
try {
element.focus({ preventScroll: true })
} catch (e) {
console.error(e)
}
} else {
if (++count <= RETRIES) {
setTimeout(() => scheduleIdleTask(tryToFocusElement), TIMEOUT)
}
}
}
scheduleIdleTask(tryToFocusElement)
tryToFocusElement(id)
}

View File

@ -7,7 +7,7 @@
<script>
import { PAGE_HISTORY_SIZE } from '../_static/pages'
import { QuickLRU } from '../_thirdparty/quick-lru/quick-lru'
import { doubleRAF } from '../_utils/doubleRAF'
import { tryToFocusElement } from '../_utils/tryToFocusElement'
const cache = new QuickLRU({ maxSize: PAGE_HISTORY_SIZE })
@ -64,16 +64,7 @@
return
}
console.log('restoreFocus', realm, elementId)
doubleRAF(() => {
const element = document.getElementById(elementId)
if (element) {
try {
element.focus({ preventScroll: true })
} catch (err) {
console.warn('failed to focus', elementId, err)
}
}
})
tryToFocusElement(elementId)
},
clearFocus () {
const { realm } = this.get()

View File

@ -9,16 +9,43 @@
<script>
import VirtualListItem from './VirtualListItem'
import { mark, stop } from '../../_utils/marks'
import { scheduleIdleTask } from '../../_utils/scheduleIdleTask'
import { createPriorityQueue } from '../../_utils/createPriorityQueue'
import { isMobile } from '../../_utils/userAgent/isMobile'
// In Svelte's implementation of lists, these VirtualListLazyItems can be
// created in any order. By default in fact it seems to do it in reverse
// order, which we don't really want, because then items render in a janky
// way, with the last ones first and then replaced by the first ones,
// resulting in a UI that looks like a deck of cards being shuffled.
// This functions waits a microtask and then ensures that the callbacks
// are called by index, in ascending order.
const priorityQueue = createPriorityQueue()
export default {
async oncreate () {
const { makeProps, key } = this.get()
const { makeProps, key, index } = this.get()
if (makeProps) {
await priorityQueue(index)
const props = await makeProps(key)
const setProps = () => {
mark('VirtualListLazyItem set props')
this.set({ props: props })
stop('VirtualListLazyItem set props')
}
// On mobile, render in rIC for maximum input responsiveness. The reason we do
// different behavior for desktop versus mobile is:
// 1. On desktop, the scrollbar is really distracting as it changes size. It may
// even cause issues for people with vestibular disorders (see also prefers-reduced-motion).
// 2. On mobile, the CPU is generally slower, so we want better input responsiveness.
// TODO: someday we can use isInputPending as a better way to break up work
// https://www.chromestatus.com/feature/5719830432841728
if (isMobile()) {
scheduleIdleTask(setProps)
} else {
setProps()
}
}
},
data: () => ({
props: undefined

View File

@ -0,0 +1,24 @@
// Promise-based implementation that waits a microtask tick
// and executes the resolve() functions in priority order
import { queueMicrotask } from './queueMicrotask'
export function createPriorityQueue () {
const tasks = []
function flush () {
if (tasks.length) {
const sortedTasks = tasks.sort((a, b) => a.priority < b.priority ? -1 : 1)
for (const task of sortedTasks) {
task.resolve()
}
tasks.length = 0
}
}
return function next (priority) {
return new Promise(resolve => {
tasks.push({ priority, resolve })
queueMicrotask(flush)
})
}
}

View File

@ -0,0 +1,12 @@
// via https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/queueMicrotask
function queueMicrotaskPolyfill (callback) {
Promise.resolve()
.then(callback)
.catch(e => setTimeout(() => { throw e }))
}
const qM = typeof queueMicrotask === 'function' ? queueMicrotask : queueMicrotaskPolyfill
export {
qM as queueMicrotask
}

View File

@ -0,0 +1,25 @@
// try 5 times to wait for the element to be rendered and then focus it
import { scheduleIdleTask } from './scheduleIdleTask'
const RETRIES = 5
const TIMEOUT = 50
export async function tryToFocusElement (id) {
for (let i = 0; i < RETRIES; i++) {
if (i > 0) {
await new Promise(resolve => setTimeout(resolve, TIMEOUT))
}
await new Promise(resolve => scheduleIdleTask(resolve))
const element = document.getElementById(id)
if (element) {
try {
element.focus({ preventScroll: true })
console.log('focused element', id)
return
} catch (e) {
console.error(e)
}
}
}
console.log('failed to focus element', id)
}