diff --git a/src/index.ts b/src/index.ts index eb1c78d5e..7aa1fca5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ export { formatRelativeDate, formatDateTime, } from './util/date-and-time'; +export { lazy } from './util/lazy'; export { ListenerCollection } from './util/listener-collection'; export { confirm } from './util/prompts'; diff --git a/src/pattern-library/components/PlaygroundApp.tsx b/src/pattern-library/components/PlaygroundApp.tsx index d7a5a5510..a1e3b96e0 100644 --- a/src/pattern-library/components/PlaygroundApp.tsx +++ b/src/pattern-library/components/PlaygroundApp.tsx @@ -147,6 +147,7 @@ export default function PlaygroundApp({ const prototypeRoutes = getRoutes('prototype'); const hookRoutes = getRoutes('hooks'); + const utilityRoutes = getRoutes('utilities'); const groupKeys = Object.keys(componentGroups) as Array< keyof typeof componentGroups @@ -204,6 +205,13 @@ export default function PlaygroundApp({ ))} + Utilities + + {utilityRoutes.map(route => ( + + ))} + + {prototypeRoutes.length > 0 && ( <> Prototypes diff --git a/src/pattern-library/components/patterns/utilities/LazyPage.tsx b/src/pattern-library/components/patterns/utilities/LazyPage.tsx new file mode 100644 index 000000000..206414696 --- /dev/null +++ b/src/pattern-library/components/patterns/utilities/LazyPage.tsx @@ -0,0 +1,114 @@ +import Library from '../../Library'; + +export default function LazyPage() { + return ( + + The lazy utility creates a component which is loaded + asynchronously, displaying fallback content while loading and showing + an error fallback if the component fails to load. +

+ } + > + + + + + + + + + Display name for the lazy wrapper component. + + + string + + + + + + + + Callback invoked on first render to load the component. This + returns a promise resolving to the loaded component. A common + use case is to use an import(...) expression to + load the component. + + + {'() => Promise>'} + + + + + + + + Configuration for the lazy component + + + LazyOptions<P> + + + + + + + + + + Function that returns content to display while loading + + + {'(props: P) => ComponentChildren'} + + + + + + + + Function that returns content to display if loading fails. If + not provided a default error fallback is shown. + + + + { + '((props: P, error: Error) => ComponentChildren) | undefined' + } + + + + undefined + + + + + + + + +

+ Lazy components start loading on first render, and show a fallback + while loading. +

+ +
+ + +

If a component fails to load, the error fallback is displayed:

+ +
+
+
+ ); +} diff --git a/src/pattern-library/examples/lazy-basic.tsx b/src/pattern-library/examples/lazy-basic.tsx new file mode 100644 index 000000000..8827e7800 --- /dev/null +++ b/src/pattern-library/examples/lazy-basic.tsx @@ -0,0 +1,43 @@ +import { Button, lazy } from '../../'; + +type ProfileProps = { + name: string; + description: string; +}; + +function Profile({ name, description }: ProfileProps) { + return ( +
+

{name}

+

{description}

+
+ ); +} + +let resolve: (x: typeof Profile) => void; +const promise = new Promise(resolve_ => (resolve = resolve_)); + +const LazyProfile = lazy('Profile', () => promise, { + fallback: ({ name }) => ( +
+

+ Loading {name} + {"'"}s profile... +

+
+ ), +}); + +export default function App() { + return ( +
+ + +
+ ); +} diff --git a/src/pattern-library/examples/lazy-error-handling.tsx b/src/pattern-library/examples/lazy-error-handling.tsx new file mode 100644 index 000000000..0ae1f58f6 --- /dev/null +++ b/src/pattern-library/examples/lazy-error-handling.tsx @@ -0,0 +1,58 @@ +import { Button, lazy } from '../../'; + +type ProfileProps = { + name: string; + description: string; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function Profile({ name, description }: ProfileProps) { + return ( +
+

{name}

+

{description}

+
+ ); +} + +let reject: (err: Error) => void; +const promise = new Promise( + (_resolve, reject_) => (reject = reject_), +); + +const LazyProfile = lazy('Profile', () => promise, { + fallback: ({ name }) => ( +
+

+ Loading {name} + {"'"}s profile... +

+
+ ), + errorFallback: ({ name }, error) => ( +
+

+ Unable to load {name} + {"'"}s profile: {error.message} +

+
+ ), +}); + +export default function App() { + return ( +
+ + +
+ ); +} diff --git a/src/pattern-library/routes.ts b/src/pattern-library/routes.ts index ca1637efe..69ef7636c 100644 --- a/src/pattern-library/routes.ts +++ b/src/pattern-library/routes.ts @@ -35,6 +35,7 @@ import PaginationPage from './components/patterns/navigation/PaginationPage'; import PointerButtonPage from './components/patterns/navigation/PointerButtonPage'; import TabPage from './components/patterns/navigation/TabPage'; import SliderPage from './components/patterns/transition/SliderPage'; +import LazyPage from './components/patterns/utilities/LazyPage'; export const componentGroups = { data: 'Data Display', @@ -53,6 +54,7 @@ export type PlaygroundRouteGroup = | 'foundations' | 'components' | 'hooks' + | 'utilities' | 'prototype' | 'custom'; @@ -280,6 +282,12 @@ const routes: PlaygroundRoute[] = [ component: UseClickAwayPage, route: '/hooks-use-click-away', }, + { + title: 'lazy', + group: 'utilities', + component: LazyPage, + route: '/utilities-lazy', + }, ]; /** diff --git a/src/util/lazy.tsx b/src/util/lazy.tsx new file mode 100644 index 000000000..9a0fb7a52 --- /dev/null +++ b/src/util/lazy.tsx @@ -0,0 +1,69 @@ +import type { ComponentChildren, FunctionalComponent, JSX } from 'preact'; +import { useState } from 'preact/hooks'; + +export type LazyOptions

= { + /** Returns the content to render if the component is not yet loaded. */ + fallback: (props: P) => ComponentChildren; + /** Returns the content to render if the component failed to load. */ + errorFallback?: (props: P, err: Error) => ComponentChildren; +}; + +/** + * Create a lazy-loading version of a component. + * + * This utility allows deferring loading the code for a component until it is + * rendered. The returned component loads in two phases. In the first phase a + * placeholder is rendered and the {@link load} callback is invoked to load + * the component. Then when the returned promise resolves, the placeholder is + * replaced with the real compoonent. + * + * @param displayName - Display name for the lazy wrapper component + * @param load - A function which loads the JS component. This will usually + * be an async function which does `import('path/to/module')` and then returns + * one of the loaded module's exports. + * @param options - Options that specify what to render while the component is + * loading or if it fails to load. + */ +export function lazy

( + displayName: string, + load: () => Promise>, + { errorFallback, fallback }: LazyOptions

, +) { + function Lazy(props: P & JSX.IntrinsicAttributes) { + const [component, setComponent] = useState>(); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + if (error) { + return errorFallback ? ( + errorFallback(props, error) + ) : ( +

+

+ There was a problem loading this content. Try refreshing the page. +

+ Details: {error.message} +
+ ); + } + if (!component && !loading) { + setLoading(true); + load() + .then(component => { + setComponent(() => component); + }) + .catch(setError) + .finally(() => { + setLoading(false); + }); + } + if (component) { + const Component = component; + return ; + } + return fallback(props); + } + + Lazy.displayName = `Lazy(${displayName})`; + return Lazy; +} diff --git a/src/util/test/lazy-test.js b/src/util/test/lazy-test.js new file mode 100644 index 000000000..579508e71 --- /dev/null +++ b/src/util/test/lazy-test.js @@ -0,0 +1,129 @@ +import { delay, mount } from '@hypothesis/frontend-testing'; + +import { lazy } from '../lazy'; + +describe('lazy', () => { + let fakeComponent; + let fakeLoader; + let LazyComponent; + + beforeEach(() => { + fakeComponent = ({ text }) => ( +
{text}
+ ); + fakeLoader = sinon.stub(); + LazyComponent = lazy('TestComponent', fakeLoader, { + fallback: ({ text }) =>
{text}
, + errorFallback: ({ text }, error) => ( +
+ {text} - Error: {error.message} +
+ ), + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('renders fallback initially', () => { + fakeLoader.returns(Promise.resolve(fakeComponent)); + const wrapper = mount(); + + assert.isTrue(wrapper.exists('[data-testid="fallback"]')); + assert.equal(wrapper.find('[data-testid="fallback"]').text(), 'test'); + }); + + it('renders loaded component after loading completes', async () => { + fakeLoader.returns(Promise.resolve(fakeComponent)); + const wrapper = mount(); + + // Initially shows fallback + assert.isTrue(wrapper.exists('[data-testid="fallback"]')); + assert.isFalse(wrapper.exists('[data-testid="loaded-component"]')); + + // Wait for component to load + await delay(0); + wrapper.update(); + + // Now shows loaded component + assert.isFalse(wrapper.exists('[data-testid="fallback"]')); + assert.isTrue(wrapper.exists('[data-testid="loaded-component"]')); + assert.equal( + wrapper.find('[data-testid="loaded-component"]').text(), + 'test', + ); + }); + + it('passes props to loaded component', async () => { + fakeLoader.returns(Promise.resolve(fakeComponent)); + const wrapper = mount(); + + await delay(0); + wrapper.update(); + + const loadedComponent = wrapper.find('[data-testid="loaded-component"]'); + assert.equal(loadedComponent.text(), 'test'); + // The component should receive all props passed to the lazy wrapper + assert.equal(loadedComponent.parent().prop('customProp'), 'value'); + }); + + it('renders error fallback when loading fails', async () => { + const error = new Error('Loading failed'); + fakeLoader.returns(Promise.reject(error)); + const wrapper = mount(); + + // Initially shows fallback + assert.isTrue(wrapper.exists('[data-testid="fallback"]')); + + // Wait for loading to fail + await delay(0); + wrapper.update(); + + // Now shows error fallback + assert.isFalse(wrapper.exists('[data-testid="fallback"]')); + assert.isTrue(wrapper.exists('[data-testid="error-fallback"]')); + assert.equal( + wrapper.find('[data-testid="error-fallback"]').text(), + 'test - Error: Loading failed', + ); + }); + + it('renders default error fallback when `errorFallback` is not provided', async () => { + const error = new Error('Loading failed'); + fakeLoader.returns(Promise.reject(error)); + + const LazyComponentWithoutErrorFallback = lazy( + 'TestComponent', + fakeLoader, + { + fallback: ({ text }) =>
{text}
, + }, + ); + + const wrapper = mount(); + + // Wait for loading to fail + await delay(0); + wrapper.update(); + + assert.isTrue( + wrapper.text().includes('There was a problem loading this content'), + ); + assert.isTrue(wrapper.text().includes('Loading failed')); + }); + + it('does not call loader again if re-rendered while loading', async () => { + fakeLoader.returns(Promise.resolve(fakeComponent)); + const wrapper = mount(); + + // Re-render component before loader result has been processed. + wrapper.setProps({ text: 'updated' }); + + assert.calledOnce(fakeLoader); + }); + + it('sets displayName on the returned component', () => { + assert.equal(LazyComponent.displayName, 'Lazy(TestComponent)'); + }); +});