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",
|
"matchMedia",
|
||||||
"performance",
|
"performance",
|
||||||
"postMessage",
|
"postMessage",
|
||||||
|
"queueMicrotask",
|
||||||
"requestAnimationFrame",
|
"requestAnimationFrame",
|
||||||
"requestIdleCallback",
|
"requestIdleCallback",
|
||||||
"self",
|
"self",
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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: () => ({
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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 New Issue