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:
parent
06a403df28
commit
ae3bd2bda2
|
@ -163,6 +163,7 @@
|
|||
"matchMedia",
|
||||
"performance",
|
||||
"postMessage",
|
||||
"queueMicrotask",
|
||||
"requestAnimationFrame",
|
||||
"requestIdleCallback",
|
||||
"self",
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
24
src/routes/_utils/createPriorityQueue.js
Normal file
24
src/routes/_utils/createPriorityQueue.js
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
12
src/routes/_utils/queueMicrotask.js
Normal file
12
src/routes/_utils/queueMicrotask.js
Normal 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
|
||||
}
|
25
src/routes/_utils/tryToFocusElement.js
Normal file
25
src/routes/_utils/tryToFocusElement.js
Normal 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)
|
||||
}
|
Loading…
Reference in a new issue