Skip to content

Commit aae0a49

Browse files
committed
Add lazy utility to create lazy-loading UI components
Add a utility for creating components which are loaded only when rendered. When first rendered, a lazy component displays a fallback while the real implementation is fetched. It then renders the real component after the fetch completes. If the component fails to load an an error fallback is rendered in its place.
1 parent 9672ede commit aae0a49

File tree

3 files changed

+199
-0
lines changed

3 files changed

+199
-0
lines changed

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export {
2121
formatRelativeDate,
2222
formatDateTime,
2323
} from './util/date-and-time';
24+
export { lazy } from './util/lazy';
2425
export { ListenerCollection } from './util/listener-collection';
2526
export { confirm } from './util/prompts';
2627

src/util/lazy.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { ComponentChildren, FunctionalComponent, JSX } from 'preact';
2+
import { useState } from 'preact/hooks';
3+
4+
export type LazyOptions<P> = {
5+
/** Returns the content to render if the component is not yet loaded. */
6+
fallback: (props: P) => ComponentChildren;
7+
/** Returns the content to render if the component failed to load. */
8+
errorFallback?: (props: P, err: Error) => ComponentChildren;
9+
};
10+
11+
/**
12+
* Create a lazy-loading version of a component.
13+
*
14+
* This utility allows deferring loading the code for a component until it is
15+
* rendered. The returned component loads in two phases. In the first phase a
16+
* placeholder is rendered and the {@link onLoad} callback is invoked to load
17+
* the component. Then when the returned promise resolves, the placeholder is
18+
* replaced with the real compoonent.
19+
*
20+
* @param displayName - Display name for the lazy wrapper component
21+
* @param onLoad - A function which loads the JS component. This will usually
22+
* be an async function which does `import('path/to/module')` and then returns
23+
* one of the loaded module's exports.
24+
* @param options - Options that specify what to render while the component is
25+
* loading or if it fails to load.
26+
*/
27+
export function lazy<P>(
28+
displayName: string,
29+
onLoad: () => Promise<FunctionalComponent<P>>,
30+
{ errorFallback, fallback }: LazyOptions<P>,
31+
) {
32+
function Lazy(props: P & JSX.IntrinsicAttributes) {
33+
const [component, setComponent] = useState<FunctionalComponent<P>>();
34+
const [error, setError] = useState<Error | null>(null);
35+
const [loading, setLoading] = useState(false);
36+
37+
if (error) {
38+
return errorFallback ? (
39+
errorFallback(props, error)
40+
) : (
41+
<div>
42+
<p>
43+
There was a problem loading this content. Try refreshing the page.
44+
</p>
45+
<b>Details:</b> {error.message}
46+
</div>
47+
);
48+
}
49+
if (!component && !loading) {
50+
setLoading(true);
51+
onLoad()
52+
.then(component => {
53+
setComponent(() => component);
54+
})
55+
.catch(setError)
56+
.finally(() => {
57+
setLoading(false);
58+
});
59+
}
60+
if (component) {
61+
const Component = component;
62+
return <Component {...props} />;
63+
}
64+
return fallback(props);
65+
}
66+
67+
Lazy.displayName = `Lazy(${displayName})`;
68+
return Lazy;
69+
}

src/util/test/lazy-test.js

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { delay, mount } from '@hypothesis/frontend-testing';
2+
3+
import { lazy } from '../lazy';
4+
5+
describe('lazy', () => {
6+
let fakeComponent;
7+
let fakeLoader;
8+
let LazyComponent;
9+
10+
beforeEach(() => {
11+
fakeComponent = ({ text }) => (
12+
<div data-testid="loaded-component">{text}</div>
13+
);
14+
fakeLoader = sinon.stub();
15+
LazyComponent = lazy('TestComponent', fakeLoader, {
16+
fallback: ({ text }) => <div data-testid="fallback">{text}</div>,
17+
errorFallback: ({ text }, error) => (
18+
<div data-testid="error-fallback">
19+
{text} - Error: {error.message}
20+
</div>
21+
),
22+
});
23+
});
24+
25+
afterEach(() => {
26+
sinon.restore();
27+
});
28+
29+
it('renders fallback initially', () => {
30+
fakeLoader.returns(Promise.resolve(fakeComponent));
31+
const wrapper = mount(<LazyComponent text="test" />);
32+
33+
assert.isTrue(wrapper.exists('[data-testid="fallback"]'));
34+
assert.equal(wrapper.find('[data-testid="fallback"]').text(), 'test');
35+
});
36+
37+
it('renders loaded component after loading completes', async () => {
38+
fakeLoader.returns(Promise.resolve(fakeComponent));
39+
const wrapper = mount(<LazyComponent text="test" />);
40+
41+
// Initially shows fallback
42+
assert.isTrue(wrapper.exists('[data-testid="fallback"]'));
43+
assert.isFalse(wrapper.exists('[data-testid="loaded-component"]'));
44+
45+
// Wait for component to load
46+
await delay(0);
47+
wrapper.update();
48+
49+
// Now shows loaded component
50+
assert.isFalse(wrapper.exists('[data-testid="fallback"]'));
51+
assert.isTrue(wrapper.exists('[data-testid="loaded-component"]'));
52+
assert.equal(
53+
wrapper.find('[data-testid="loaded-component"]').text(),
54+
'test',
55+
);
56+
});
57+
58+
it('passes props to loaded component', async () => {
59+
fakeLoader.returns(Promise.resolve(fakeComponent));
60+
const wrapper = mount(<LazyComponent text="test" customProp="value" />);
61+
62+
await delay(0);
63+
wrapper.update();
64+
65+
const loadedComponent = wrapper.find('[data-testid="loaded-component"]');
66+
assert.equal(loadedComponent.text(), 'test');
67+
// The component should receive all props passed to the lazy wrapper
68+
assert.equal(loadedComponent.parent().prop('customProp'), 'value');
69+
});
70+
71+
it('renders error fallback when loading fails', async () => {
72+
const error = new Error('Loading failed');
73+
fakeLoader.returns(Promise.reject(error));
74+
const wrapper = mount(<LazyComponent text="test" />);
75+
76+
// Initially shows fallback
77+
assert.isTrue(wrapper.exists('[data-testid="fallback"]'));
78+
79+
// Wait for loading to fail
80+
await delay(0);
81+
wrapper.update();
82+
83+
// Now shows error fallback
84+
assert.isFalse(wrapper.exists('[data-testid="fallback"]'));
85+
assert.isTrue(wrapper.exists('[data-testid="error-fallback"]'));
86+
assert.equal(
87+
wrapper.find('[data-testid="error-fallback"]').text(),
88+
'test - Error: Loading failed',
89+
);
90+
});
91+
92+
it('renders default error fallback when `errorFallback` is not provided', async () => {
93+
const error = new Error('Loading failed');
94+
fakeLoader.returns(Promise.reject(error));
95+
96+
const LazyComponentWithoutErrorFallback = lazy(
97+
'TestComponent',
98+
fakeLoader,
99+
{
100+
fallback: ({ text }) => <div data-testid="fallback">{text}</div>,
101+
},
102+
);
103+
104+
const wrapper = mount(<LazyComponentWithoutErrorFallback text="test" />);
105+
106+
// Wait for loading to fail
107+
await delay(0);
108+
wrapper.update();
109+
110+
assert.isTrue(
111+
wrapper.text().includes('There was a problem loading this content'),
112+
);
113+
assert.isTrue(wrapper.text().includes('Loading failed'));
114+
});
115+
116+
it('does not call loader again if re-rendered while loading', async () => {
117+
fakeLoader.returns(Promise.resolve(fakeComponent));
118+
const wrapper = mount(<LazyComponent text="test" />);
119+
120+
// Re-render component before loader result has been processed.
121+
wrapper.setProps({ text: 'updated' });
122+
123+
assert.calledOnce(fakeLoader);
124+
});
125+
126+
it('sets displayName on the returned component', () => {
127+
assert.equal(LazyComponent.displayName, 'Lazy(TestComponent)');
128+
});
129+
});

0 commit comments

Comments
 (0)