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", "matchMedia",
"performance", "performance",
"postMessage", "postMessage",
"queueMicrotask",
"requestAnimationFrame", "requestAnimationFrame",
"requestIdleCallback", "requestIdleCallback",
"self", "self",

View File

@ -43,29 +43,13 @@ async function decodeAllBlurhashes (statusOrNotification) {
})) }))
stop(`decodeBlurhash-${status.id}`) stop(`decodeBlurhash-${status.id}`)
} }
return statusOrNotification
} }
export function createMakeProps (instanceName, timelineType, timelineValue) { export function createMakeProps (instanceName, timelineType, timelineValue) {
let taskCount = 0 let promiseChain = Promise.resolve()
let pending = []
tryInitBlurhash() // start the blurhash worker a bit early to save time 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) { async function fetchFromIndexedDB (itemId) {
mark(`fetchFromIndexedDB-${itemId}`) mark(`fetchFromIndexedDB-${itemId}`)
try { try {
@ -78,13 +62,20 @@ export function createMakeProps (instanceName, timelineType, timelineValue) {
} }
} }
return (itemId) => { async function getStatusOrNotification (itemId) {
taskCount++ const statusOrNotification = await fetchFromIndexedDB(itemId)
await decodeAllBlurhashes(statusOrNotification)
return statusOrNotification
}
return fetchFromIndexedDB(itemId) // The results from IndexedDB or the worker thread can return in random order,
.then(decodeAllBlurhashes) // so we ensure consistent ordering based on the order this function is called in.
.then(statusOrNotification => { return itemId => {
return awaitAllTasksComplete().then(() => statusOrNotification) 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 { showMoreItemsForCurrentTimeline } from './timeline'
import { scrollToTop } from '../_utils/scrollToTop' import { scrollToTop } from '../_utils/scrollToTop'
import { scheduleIdleTask } from '../_utils/scheduleIdleTask'
import { createStatusOrNotificationUuid } from '../_utils/createStatusOrNotificationUuid' import { createStatusOrNotificationUuid } from '../_utils/createStatusOrNotificationUuid'
import { store } from '../_store/store' import { store } from '../_store/store'
import { tryToFocusElement } from '../_utils/tryToFocusElement'
const RETRIES = 5
const TIMEOUT = 50
export function showMoreAndScrollToTop () { export function showMoreAndScrollToTop () {
// Similar to Twitter, pressing "." will click the "show more" button and select // 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 notificationId = currentTimelineType === 'notifications' && firstItemSummary.id
const statusId = currentTimelineType !== 'notifications' && firstItemSummary.id const statusId = currentTimelineType !== 'notifications' && firstItemSummary.id
scrollToTop(/* smooth */ false) scrollToTop(/* smooth */ false)
// try 5 times to wait for the element to be rendered and then focus it const id = createStatusOrNotificationUuid(
let count = 0 currentInstance, currentTimelineType,
const tryToFocusElement = () => { currentTimelineValue, notificationId, statusId
const uuid = createStatusOrNotificationUuid( )
currentInstance, currentTimelineType, tryToFocusElement(id)
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)
} }

View File

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

View File

@ -9,15 +9,42 @@
<script> <script>
import VirtualListItem from './VirtualListItem' import VirtualListItem from './VirtualListItem'
import { mark, stop } from '../../_utils/marks' 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 { export default {
async oncreate () { async oncreate () {
const { makeProps, key } = this.get() const { makeProps, key, index } = this.get()
if (makeProps) { if (makeProps) {
await priorityQueue(index)
const props = await makeProps(key) const props = await makeProps(key)
mark('VirtualListLazyItem set props') const setProps = () => {
this.set({ props: props }) mark('VirtualListLazyItem set props')
stop('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: () => ({ data: () => ({

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)
}