Skip to content

Commit e81d778

Browse files
authored
Merge pull request #268 from github/lazy-define
`lazyDefine` components
2 parents 6dd8ff9 + f0e572e commit e81d778

5 files changed

Lines changed: 211 additions & 0 deletions

File tree

docs/_guide/devtools-coverage.png

406 KB
Loading

docs/_guide/lazy-elements.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
chapter: 17
3+
subtitle: Dynamically load elements just in time
4+
---
5+
6+
A common practice in modern web development is to combine all JavaScript code into JS "bundles". By bundling the code together we avoid the network overhead of fetching each file. However the trade-off of bundling is that we might deliver JS code that will never run in the browser.
7+
8+
![](/guide/devtools-coverage.png)
9+
10+
An alternative solution to bundling is to load JavaScript just in time. Downloding the JavaScript for Catalyst controllers when the browser first encounters them can be done with the `lazyDefine` function.
11+
12+
```typescript
13+
import {lazyDefine} from '@github/catalyst'
14+
15+
// Dynamically import the Catalyst controller when the `<user-avatar>` tag is seen.
16+
lazyDefine('user-avatar', () => import('./components/user-avatar'))
17+
```
18+
19+
Serving this file allows us to defer loading of the component code until it's actually needed by the web page. The tradeoff of deferring loading is that the elements will be inert until the dynamic import of the component code resolves. Consider what your UI might look like while these components are resolving. Consider providing a loading indicator and disabling controls as the default state. The smaller the component, the faster it will resolve which means that your users might not notice a inert state. A good rule of thumb is that a component should load within 100ms on a "Fast 3G" connection.
20+
21+
Generally we think it's a good idea to `lazyDefine` all elements and then prioritize eager loading of ciritical elements as needed. You might consider using code-generation to generate a file lazy defining all your components.
22+
23+
By default the component will be loaded when the element is present in the document and the document has finished loading. This can happen before sub-resources such as scripts, images, stylesheets and frames have finished loading. It is possible to defer loading even later by adding a `data-load-on` attribute on your element. The value of which must be one of the following prefefined values:
24+
25+
- `<user-avatar data-load-on="ready"></user-avatar>` (default)
26+
- The element is loaded when the document has finished loading. This listens for changes to `document.readyState` and triggers when it's no longer loading.
27+
- `<user-avatar data-load-on="firstInteraction"></user-avatar>`
28+
- This element is loaded on the first user interaction with the page. This listens for `mousedown`, `touchstart`, `pointerdown` and `keydown` events on `document`.
29+
- `<user-avatar data-load-on="visible"></user-avatar>`
30+
- This element is loaded when it's close to being visible. Similar to `<img loading="lazy" [..] />` . The functionality is driven by an `IntersectionObserver`.
31+
32+
This functionality is similar to the ["Lazy Custom Element Definitions" spec proposal](https://github.com/WICG/webcomponents/issues/782) and as this proposal matures we see Catalyst conforming to the spec and leveraging this new API to lazy load elements.

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export {target, targets} from './target.js'
55
export {controller} from './controller.js'
66
export {attr, initializeAttrs, defineObservedAttributes} from './attr.js'
77
export {autoShadowRoot} from './auto-shadow-root.js'
8+
export {lazyDefine} from './lazy-define.js'

src/lazy-define.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
type Strategy = (tagName: string) => Promise<void>
2+
3+
const dynamicElements = new Map<string, Set<() => void>>()
4+
5+
const ready = new Promise<void>(resolve => {
6+
if (document.readyState !== 'loading') {
7+
resolve()
8+
} else {
9+
document.addEventListener('readystatechange', () => resolve(), {once: true})
10+
}
11+
})
12+
13+
const firstInteraction = new Promise<void>(resolve => {
14+
const controller = new AbortController()
15+
controller.signal.addEventListener('abort', () => resolve())
16+
const listenerOptions = {once: true, passive: true, signal: controller.signal}
17+
const handler = () => controller.abort()
18+
19+
document.addEventListener('mousedown', handler, listenerOptions)
20+
// eslint-disable-next-line github/require-passive-events
21+
document.addEventListener('touchstart', handler, listenerOptions)
22+
document.addEventListener('keydown', handler, listenerOptions)
23+
document.addEventListener('pointerdown', handler, listenerOptions)
24+
})
25+
26+
const visible = (tagName: string): Promise<void> =>
27+
new Promise<void>(resolve => {
28+
const observer = new IntersectionObserver(
29+
entries => {
30+
for (const entry of entries) {
31+
if (entry.isIntersecting) {
32+
resolve()
33+
observer.disconnect()
34+
return
35+
}
36+
}
37+
},
38+
{
39+
// Currently the threshold is set to 256px from the bottom of the viewport
40+
// with a threshold of 0.1. This means the element will not load until about
41+
// 2 keyboard-down-arrow presses away from being visible in the viewport,
42+
// giving us some time to fetch it before the contents are made visible
43+
rootMargin: '0px 0px 256px 0px',
44+
threshold: 0.01
45+
}
46+
)
47+
for (const el of document.querySelectorAll(tagName)) {
48+
observer.observe(el)
49+
}
50+
})
51+
52+
const strategies: Record<string, Strategy> = {
53+
ready: () => ready,
54+
firstInteraction: () => firstInteraction,
55+
visible
56+
}
57+
58+
const timers = new WeakMap<Element, number>()
59+
function scan(node: Element) {
60+
cancelAnimationFrame(timers.get(node) || 0)
61+
timers.set(
62+
node,
63+
requestAnimationFrame(() => {
64+
for (const tagName of dynamicElements.keys()) {
65+
const child: Element | null = node.matches(tagName) ? node : node.querySelector(tagName)
66+
if (customElements.get(tagName) || child) {
67+
const strategyName = (child?.getAttribute('data-load-on') || 'ready') as keyof typeof strategies
68+
const strategy = strategyName in strategies ? strategies[strategyName] : strategies.ready
69+
// eslint-disable-next-line github/no-then
70+
for (const cb of dynamicElements.get(tagName) || []) strategy(tagName).then(cb)
71+
dynamicElements.delete(tagName)
72+
timers.delete(node)
73+
}
74+
}
75+
})
76+
)
77+
}
78+
79+
let elementLoader: MutationObserver
80+
81+
export function lazyDefine(tagName: string, callback: () => void) {
82+
if (!dynamicElements.has(tagName)) dynamicElements.set(tagName, new Set<() => void>())
83+
dynamicElements.get(tagName)!.add(callback)
84+
85+
scan(document.body)
86+
87+
if (!elementLoader) {
88+
elementLoader = new MutationObserver(mutations => {
89+
if (!dynamicElements.size) return
90+
for (const mutation of mutations) {
91+
for (const node of mutation.addedNodes) {
92+
if (node instanceof Element) scan(node)
93+
}
94+
}
95+
})
96+
elementLoader.observe(document, {subtree: true, childList: true})
97+
}
98+
}

test/lazy-define.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {expect, fixture, html} from '@open-wc/testing'
2+
import {spy} from 'sinon'
3+
import {lazyDefine} from '../src/lazy-define.js'
4+
5+
describe('lazyDefine', () => {
6+
describe('ready strategy', () => {
7+
it('calls define for a lazy component', async () => {
8+
const onDefine = spy()
9+
lazyDefine('scan-document-test', onDefine)
10+
await fixture(html`<scan-document-test></scan-document-test>`)
11+
12+
await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
13+
14+
expect(onDefine).to.be.callCount(1)
15+
})
16+
17+
it('initializes dynamic elements that are defined after the document is ready', async () => {
18+
const onDefine = spy()
19+
await fixture(html`<later-defined-element-test></later-defined-element-test>`)
20+
lazyDefine('later-defined-element-test', onDefine)
21+
22+
await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
23+
24+
expect(onDefine).to.be.callCount(1)
25+
})
26+
27+
it("doesn't call the same callback twice", async () => {
28+
const onDefine = spy()
29+
lazyDefine('twice-defined-element', onDefine)
30+
lazyDefine('once-defined-element', onDefine)
31+
lazyDefine('twice-defined-element', onDefine)
32+
await fixture(html`
33+
<once-defined-element></once-defined-element>
34+
<once-defined-element></once-defined-element>
35+
<once-defined-element></once-defined-element>
36+
<twice-defined-element></twice-defined-element>
37+
<twice-defined-element></twice-defined-element>
38+
<twice-defined-element></twice-defined-element>
39+
<twice-defined-element></twice-defined-element>
40+
`)
41+
42+
await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
43+
44+
expect(onDefine).to.be.callCount(2)
45+
})
46+
})
47+
48+
describe('firstInteraction strategy', () => {
49+
it('calls define for a lazy component', async () => {
50+
const onDefine = spy()
51+
lazyDefine('scan-document-test', onDefine)
52+
await fixture(html`<scan-document-test data-load-on="firstInteraction"></scan-document-test>`)
53+
54+
await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
55+
expect(onDefine).to.be.callCount(0)
56+
57+
document.dispatchEvent(new Event('mousedown'))
58+
59+
await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
60+
expect(onDefine).to.be.callCount(1)
61+
})
62+
})
63+
describe('visible strategy', () => {
64+
it('calls define for a lazy component', async () => {
65+
const onDefine = spy()
66+
lazyDefine('scan-document-test', onDefine)
67+
await fixture(
68+
html`<div style="height: calc(100vh + 256px)"></div>
69+
<scan-document-test data-load-on="visible"></scan-document-test>`
70+
)
71+
await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
72+
expect(onDefine).to.be.callCount(0)
73+
74+
document.documentElement.scrollTo({top: 10})
75+
76+
await new Promise<unknown>(resolve => requestAnimationFrame(resolve))
77+
expect(onDefine).to.be.callCount(1)
78+
})
79+
})
80+
})

0 commit comments

Comments
 (0)