-
Notifications
You must be signed in to change notification settings - Fork 57
lazyDefine components
#268
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
061a038
Copy `lazy-define.ts` from `github/github`
koddsson 926d61b
Listen to `readystatechange` instead of `DOMContentLoaded`
koddsson 57148d0
Add more event listeners for interaction check
koddsson 49b4cc0
Use abort controller to remove event handlers
koddsson 612cc18
Observe elements when `whenSeen` is called
koddsson 065bde1
Convert callback Array to a Set
koddsson f6489c9
Pass in the element tag name to strategies
koddsson 2e858fb
Add a `visible` strategy
koddsson d790e4b
Rename `whenSeen` to `lazyDefine`
koddsson 943c929
Check actual object rather than string
koddsson ec912f2
Add tests for `addStrategy`
koddsson a0c45c4
Add some basic tests for `lazyDefine`
koddsson 1b509ca
Scan whole document on each `lazyDefine`
koddsson d540098
prettier
koddsson 8e9da7d
Add test to make sure callbacks are only called once
koddsson 702fe87
Add tests for the different strategies
koddsson df33984
Disable wrong eslint rule
koddsson d81a887
Remove addStrategy function
koddsson 9fc890b
Remove default `document.body` from function signature
koddsson 84621ef
Export `lazyDefine` in index
koddsson f0e572e
Add documentation for `lazyDefine`
koddsson File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| --- | ||
| chapter: 17 | ||
| subtitle: Dynamically load elements just in time | ||
| --- | ||
|
|
||
| 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. | ||
|
|
||
|  | ||
|
|
||
| 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. | ||
|
|
||
| ```typescript | ||
| import {lazyDefine} from '@github/catalyst' | ||
|
|
||
| // Dynamically import the Catalyst controller when the `<user-avatar>` tag is seen. | ||
| lazyDefine('user-avatar', () => import('./components/user-avatar')) | ||
| ``` | ||
|
|
||
| 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. | ||
|
|
||
| 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. | ||
|
|
||
| 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: | ||
|
|
||
| - `<user-avatar data-load-on="ready"></user-avatar>` (default) | ||
| - 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. | ||
| - `<user-avatar data-load-on="firstInteraction"></user-avatar>` | ||
| - This element is loaded on the first user interaction with the page. This listens for `mousedown`, `touchstart`, `pointerdown` and `keydown` events on `document`. | ||
| - `<user-avatar data-load-on="visible"></user-avatar>` | ||
| - This element is loaded when it's close to being visible. Similar to `<img loading="lazy" [..] />` . The functionality is driven by an `IntersectionObserver`. | ||
|
|
||
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| type Strategy = (tagName: string) => Promise<void> | ||
|
|
||
| const dynamicElements = new Map<string, Set<() => void>>() | ||
|
|
||
| const ready = new Promise<void>(resolve => { | ||
| if (document.readyState !== 'loading') { | ||
| resolve() | ||
| } else { | ||
| document.addEventListener('readystatechange', () => resolve(), {once: true}) | ||
| } | ||
| }) | ||
|
|
||
| const firstInteraction = new Promise<void>(resolve => { | ||
| const controller = new AbortController() | ||
| controller.signal.addEventListener('abort', () => resolve()) | ||
| const listenerOptions = {once: true, passive: true, signal: controller.signal} | ||
| const handler = () => controller.abort() | ||
|
|
||
| document.addEventListener('mousedown', handler, listenerOptions) | ||
| // eslint-disable-next-line github/require-passive-events | ||
| document.addEventListener('touchstart', handler, listenerOptions) | ||
| document.addEventListener('keydown', handler, listenerOptions) | ||
| document.addEventListener('pointerdown', handler, listenerOptions) | ||
| }) | ||
|
|
||
| const visible = (tagName: string): Promise<void> => | ||
| new Promise<void>(resolve => { | ||
| const observer = new IntersectionObserver( | ||
| entries => { | ||
| for (const entry of entries) { | ||
| if (entry.isIntersecting) { | ||
| resolve() | ||
| observer.disconnect() | ||
| return | ||
| } | ||
| } | ||
| }, | ||
| { | ||
| // Currently the threshold is set to 256px from the bottom of the viewport | ||
| // with a threshold of 0.1. This means the element will not load until about | ||
| // 2 keyboard-down-arrow presses away from being visible in the viewport, | ||
| // giving us some time to fetch it before the contents are made visible | ||
| rootMargin: '0px 0px 256px 0px', | ||
| threshold: 0.01 | ||
| } | ||
| ) | ||
| for (const el of document.querySelectorAll(tagName)) { | ||
| observer.observe(el) | ||
| } | ||
| }) | ||
|
|
||
| const strategies: Record<string, Strategy> = { | ||
| ready: () => ready, | ||
| firstInteraction: () => firstInteraction, | ||
| visible | ||
| } | ||
|
|
||
| const timers = new WeakMap<Element, number>() | ||
| function scan(node: Element) { | ||
| cancelAnimationFrame(timers.get(node) || 0) | ||
| timers.set( | ||
| node, | ||
| requestAnimationFrame(() => { | ||
| for (const tagName of dynamicElements.keys()) { | ||
| const child: Element | null = node.matches(tagName) ? node : node.querySelector(tagName) | ||
| if (customElements.get(tagName) || child) { | ||
| const strategyName = (child?.getAttribute('data-load-on') || 'ready') as keyof typeof strategies | ||
| const strategy = strategyName in strategies ? strategies[strategyName] : strategies.ready | ||
| // eslint-disable-next-line github/no-then | ||
| for (const cb of dynamicElements.get(tagName) || []) strategy(tagName).then(cb) | ||
| dynamicElements.delete(tagName) | ||
| timers.delete(node) | ||
| } | ||
| } | ||
| }) | ||
| ) | ||
| } | ||
|
|
||
| let elementLoader: MutationObserver | ||
|
|
||
| export function lazyDefine(tagName: string, callback: () => void) { | ||
| if (!dynamicElements.has(tagName)) dynamicElements.set(tagName, new Set<() => void>()) | ||
| dynamicElements.get(tagName)!.add(callback) | ||
|
|
||
| scan(document.body) | ||
|
|
||
| if (!elementLoader) { | ||
| elementLoader = new MutationObserver(mutations => { | ||
| if (!dynamicElements.size) return | ||
| for (const mutation of mutations) { | ||
| for (const node of mutation.addedNodes) { | ||
| if (node instanceof Element) scan(node) | ||
| } | ||
| } | ||
| }) | ||
| elementLoader.observe(document, {subtree: true, childList: true}) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| import {expect, fixture, html} from '@open-wc/testing' | ||
| import {spy} from 'sinon' | ||
| import {lazyDefine} from '../src/lazy-define.js' | ||
|
|
||
| describe('lazyDefine', () => { | ||
| describe('ready strategy', () => { | ||
| it('calls define for a lazy component', async () => { | ||
| const onDefine = spy() | ||
| lazyDefine('scan-document-test', onDefine) | ||
| await fixture(html`<scan-document-test></scan-document-test>`) | ||
|
|
||
| await new Promise<unknown>(resolve => requestAnimationFrame(resolve)) | ||
|
|
||
| expect(onDefine).to.be.callCount(1) | ||
| }) | ||
|
|
||
| it('initializes dynamic elements that are defined after the document is ready', async () => { | ||
| const onDefine = spy() | ||
| await fixture(html`<later-defined-element-test></later-defined-element-test>`) | ||
| lazyDefine('later-defined-element-test', onDefine) | ||
|
|
||
| await new Promise<unknown>(resolve => requestAnimationFrame(resolve)) | ||
|
|
||
| expect(onDefine).to.be.callCount(1) | ||
| }) | ||
|
|
||
| it("doesn't call the same callback twice", async () => { | ||
| const onDefine = spy() | ||
| lazyDefine('twice-defined-element', onDefine) | ||
| lazyDefine('once-defined-element', onDefine) | ||
| lazyDefine('twice-defined-element', onDefine) | ||
| await fixture(html` | ||
| <once-defined-element></once-defined-element> | ||
| <once-defined-element></once-defined-element> | ||
| <once-defined-element></once-defined-element> | ||
| <twice-defined-element></twice-defined-element> | ||
| <twice-defined-element></twice-defined-element> | ||
| <twice-defined-element></twice-defined-element> | ||
| <twice-defined-element></twice-defined-element> | ||
| `) | ||
|
|
||
| await new Promise<unknown>(resolve => requestAnimationFrame(resolve)) | ||
|
|
||
| expect(onDefine).to.be.callCount(2) | ||
| }) | ||
| }) | ||
|
|
||
| describe('firstInteraction strategy', () => { | ||
| it('calls define for a lazy component', async () => { | ||
| const onDefine = spy() | ||
| lazyDefine('scan-document-test', onDefine) | ||
| await fixture(html`<scan-document-test data-load-on="firstInteraction"></scan-document-test>`) | ||
|
|
||
| await new Promise<unknown>(resolve => requestAnimationFrame(resolve)) | ||
| expect(onDefine).to.be.callCount(0) | ||
|
|
||
| document.dispatchEvent(new Event('mousedown')) | ||
|
|
||
| await new Promise<unknown>(resolve => requestAnimationFrame(resolve)) | ||
| expect(onDefine).to.be.callCount(1) | ||
| }) | ||
| }) | ||
| describe('visible strategy', () => { | ||
| it('calls define for a lazy component', async () => { | ||
| const onDefine = spy() | ||
| lazyDefine('scan-document-test', onDefine) | ||
| await fixture( | ||
| html`<div style="height: calc(100vh + 256px)"></div> | ||
| <scan-document-test data-load-on="visible"></scan-document-test>` | ||
| ) | ||
| await new Promise<unknown>(resolve => requestAnimationFrame(resolve)) | ||
| expect(onDefine).to.be.callCount(0) | ||
|
|
||
| document.documentElement.scrollTo({top: 10}) | ||
|
|
||
| await new Promise<unknown>(resolve => requestAnimationFrame(resolve)) | ||
| expect(onDefine).to.be.callCount(1) | ||
| }) | ||
| }) | ||
| }) |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.