Skip to content

Commit

Permalink
perf: use background-image instead of <img> (#450)
Browse files Browse the repository at this point in the history
  • Loading branch information
nolanlawson authored Sep 8, 2024
1 parent 9954aef commit a9351bc
Show file tree
Hide file tree
Showing 7 changed files with 130 additions and 33 deletions.
2 changes: 1 addition & 1 deletion bin/bundlesize.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { promisify } from 'node:util'
import prettyBytes from 'pretty-bytes'
import fs from 'node:fs/promises'

const MAX_SIZE_MIN = '37 kB'
const MAX_SIZE_MIN = '38 kB'
const MAX_SIZE_MINGZ = '13 kB'

const FILENAME = './bundle.js'
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,13 +141,15 @@
"btoa",
"crypto",
"customElements",
"CSS",
"CustomEvent",
"Event",
"fetch",
"getComputedStyle",
"Element",
"indexedDB",
"IDBKeyRange",
"IntersectionObserver",
"Headers",
"HTMLElement",
"matchMedia",
Expand Down
17 changes: 15 additions & 2 deletions src/picker/components/Picker/Picker.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { resetScrollTopIfPossible } from '../../utils/resetScrollTopIfPossible.j
import { render } from './PickerTemplate.js'
import { createState } from './reactivity.js'
import { arraysAreEqualByFunction } from '../../utils/arraysAreEqualByFunction.js'
import { intersectionObserverAction } from '../../utils/intersectionObserverAction.js'

// constants
const EMPTY_ARRAY = []
Expand All @@ -34,6 +35,7 @@ export function createRoot (shadowRoot, props) {
const abortController = new AbortController()
const abortSignal = abortController.signal
const { state, createEffect } = createState(abortSignal)
const actionContext = new Map()

// initial state
assign(state, {
Expand Down Expand Up @@ -180,12 +182,13 @@ export function createRoot (shadowRoot, props) {
onSearchInput
}
const actions = {
calculateEmojiGridStyle
calculateEmojiGridStyle,
updateOnIntersection
}

let firstRender = true
createEffect(() => {
render(shadowRoot, state, helpers, events, actions, refs, abortSignal, firstRender)
render(shadowRoot, state, helpers, events, actions, refs, abortSignal, actionContext, firstRender)
firstRender = false
})

Expand Down Expand Up @@ -377,6 +380,16 @@ export function createRoot (shadowRoot, props) {
})
}

// Re-run whenever the custom emoji in a category are shown/hidden. This is an optimization that simulates
// what we'd get from `<img loading=lazy>` but without rendering an `<img>`.
function updateOnIntersection (node) {
intersectionObserverAction(node, abortSignal, (entries) => {
for (const { target, isIntersecting } of entries) {
target.classList.toggle('onscreen', isIntersecting)
}
})
}

//
// Set or update the currentEmojis. Check for invalid ZWJ renderings
// (i.e. double emoji).
Expand Down
61 changes: 39 additions & 22 deletions src/picker/components/Picker/PickerTemplate.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createFramework } from './framework.js'

export function render (container, state, helpers, events, actions, refs, abortSignal, firstRender) {
export function render (container, state, helpers, events, actions, refs, abortSignal, actionContext, firstRender) {
const { labelWithSkin, titleForEmoji, unicodeWithSkin } = helpers
const { html, map } = createFramework(state)

Expand All @@ -11,12 +11,18 @@ export function render (container, state, helpers, events, actions, refs, abortS
aria-selected="${state.searchMode ? i === state.activeSearchItem : ''}"
aria-label="${labelWithSkin(emoji, state.currentSkinTone)}"
title="${titleForEmoji(emoji)}"
class="emoji ${searchMode && i === state.activeSearchItem ? 'active' : ''}"
id=${`${prefix}-${emoji.id}`}>
class="${
'emoji' +
(searchMode && i === state.activeSearchItem ? ' active' : '') +
(emoji.unicode ? '' : ' custom-emoji')
}"
id=${`${prefix}-${emoji.id}`}
style="${emoji.unicode ? '' : `--custom-emoji-background: url(${JSON.stringify(emoji.url)})`}"
>
${
emoji.unicode
? unicodeWithSkin(emoji, state.currentSkinTone)
: html`<img class="custom-emoji" src="${emoji.url}" alt="" loading="lazy"/>`
: ''
}
</button>
`
Expand Down Expand Up @@ -152,7 +158,8 @@ export function render (container, state, helpers, events, actions, refs, abortS
<!--The tabindex=0 is so people can scroll up and down with the keyboard. The element has a role and a label, so I
feel it's appropriate to have the tabindex.
This on:click is a delegated click listener -->
<div data-ref="tabpanelElement" class="tabpanel ${(!state.databaseLoaded || state.message) ? 'gone' : ''}"
<div data-ref="tabpanelElement"
class="tabpanel ${(!state.databaseLoaded || state.message) ? 'gone' : ''}"
role="${state.searchMode ? 'region' : 'tabpanel'}"
aria-label="${state.searchMode ? state.i18n.searchResultsLabel : state.i18n.categories[state.currentGroup.name]}"
id="${state.searchMode ? '' : `tab-${state.currentGroup.id}`}"
Expand Down Expand Up @@ -187,12 +194,13 @@ export function render (container, state, helpers, events, actions, refs, abortS
)
}
</div>
<!--
Improve performance in custom emoji by using \`content-visibility: auto\` on every category
The \`--num-rows\` is also used in these calculations to contain the intrinsic height
<!--
Improve performance in custom emoji by using \`content-visibility: auto\` on every category
The \`--num-rows\` is used in these calculations to contain the intrinsic height
-->
<div class="emoji-menu ${!state.searchMode && emojiWithCategory.category ? 'hide-offscreen' : ''}"
<div class="emoji-menu hide-offscreen"
style=${`--num-rows: ${Math.ceil(emojiWithCategory.emojis.length / state.numColumns)}`}
data-action="updateOnIntersection"
role="${state.searchMode ? 'listbox' : 'menu'}"
aria-labelledby="menu-label-${i}"
id=${state.searchMode ? 'search-results' : ''}
Expand Down Expand Up @@ -226,17 +234,17 @@ export function render (container, state, helpers, events, actions, refs, abortS

const rootDom = section()

// helper for traversing the dom, finding elements by an attribute, and getting the attribute value
const forElementWithAttribute = (attributeName, callback) => {
for (const element of container.querySelectorAll(`[${attributeName}]`)) {
callback(element, element.getAttribute(attributeName))
}
}

if (firstRender) { // not a re-render
container.appendChild(rootDom)

// we only bind events/refs/actions once - there is no need to find them again given this component structure

// helper for traversing the dom, finding elements by an attribute, and getting the attribute value
const forElementWithAttribute = (attributeName, callback) => {
for (const element of container.querySelectorAll(`[${attributeName}]`)) {
callback(element, element.getAttribute(attributeName))
}
}
// we only bind events/refs once - there is no need to find them again given this component structure

// bind events
for (const eventName of ['click', 'focusout', 'input', 'keydown', 'keyup']) {
Expand All @@ -250,14 +258,23 @@ export function render (container, state, helpers, events, actions, refs, abortS
refs[ref] = element
})

// set up actions
forElementWithAttribute('data-action', (element, action) => {
actions[action](element)
})

// destroy/abort logic
abortSignal.addEventListener('abort', () => {
container.removeChild(rootDom)
})
}

// set up actions - these are re-bound on every render
forElementWithAttribute('data-action', (element, action) => {
let boundActions = actionContext.get(action)
if (!boundActions) {
actionContext.set(action, (boundActions = new WeakSet()))
}

// avoid applying the same action to the same element multiple times
if (!boundActions.has(element)) {
boundActions.add(element)
actions[action](element)
}
})
}
26 changes: 18 additions & 8 deletions src/picker/styles/picker.scss
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,25 @@ button.emoji,
}
}

.custom-emoji {
height: var(--total-emoji-size);
width: var(--total-emoji-size);
padding: var(--emoji-padding);
object-fit: contain;
pointer-events: none;
.custom-emoji::after {
content: '';
width: var(--emoji-size);
height: var(--emoji-size);
background-repeat: no-repeat;
background-position: center center;
background-size: var(--emoji-size) var(--emoji-size);
background-size: contain;

// Don't eagerly download the images while the custom emoji is offscreen
.hide-offscreen & {
background-image: none;
}

// Note we have to handle the case of the favorites bar, which has no `.onscreen` or `.hide-offscreen` in its
// ancestor tree. The specificity/ordering here is deliberate.
// We could use `.hide-offscreen:not(.onscreen) &` above instead, but avoiding `:not()` here for selector perf.
&, .onscreen & {
background-image: var(--custom-emoji-background);
}
}

// nav
Expand Down Expand Up @@ -222,4 +232,4 @@ input.search {

.message {
padding: var(--emoji-padding);
}
}
37 changes: 37 additions & 0 deletions src/picker/utils/intersectionObserverAction.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const intersectionObserverCache = new WeakMap()

export function intersectionObserverAction (node, abortSignal, listener) {
/* istanbul ignore else */
if (import.meta.env.MODE === 'test') {
// jsdom doesn't support intersection observer; just fake it
listener([{ target: node, isIntersecting: true }])
} else {
// The scroll root is always `.tabpanel`
const root = node.closest('.tabpanel')

let observer = intersectionObserverCache.get(root)
if (!observer) {
// TODO: replace this with the contentvisibilityautostatechange event when all supported browsers support it.
// For now we use IntersectionObserver because it has better cross-browser support, and it would be bad for
// old Safari versions if they eagerly downloaded all custom emoji all at once.
observer = new IntersectionObserver(listener, {
root,
// trigger if we are 1/2 scroll container height away so that the images load a bit quicker while scrolling
rootMargin: '50% 0px 50% 0px',
// trigger if any part of the emoji grid is intersecting
threshold: 0
})

// avoid creating a new IntersectionObserver for every category; just use one for the whole root
intersectionObserverCache.set(root, observer)

// assume that the abortSignal is always the same for this root node; just add one event listener
abortSignal.addEventListener('abort', () => {
console.log('IntersectionObserver destroyed')
observer.disconnect()
})
}

observer.observe(node)
}
}
18 changes: 18 additions & 0 deletions test/adhoc/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,24 @@
worker.postMessage('init')
})
}

if (new URLSearchParams(location.search).has('custom')) {
// enable custom emoji
const categoriesToCustomEmoji = (await (await fetch('/docs/custom.json')).json())
const customEmoji = []
for (const [category, names] of Object.entries(categoriesToCustomEmoji)) {
for (const name of names) {
customEmoji.push({
category: category || undefined,
name,
shortcodes: [name],
url: `/docs/custom/${name}.svg`
})
}
}
opts.customEmoji = customEmoji
}

const picker = new Picker(opts)
picker.addEventListener('emoji-click', e => console.log(e))
document.body.appendChild(picker)
Expand Down

0 comments on commit a9351bc

Please sign in to comment.