From 3b9459133872ac80ba647c1329ced928a9ea2c2f Mon Sep 17 00:00:00 2001 From: Robert Knight Date: Thu, 3 Jul 2025 14:02:10 +0100 Subject: [PATCH 1/2] 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. --- src/index.ts | 1 + src/util/lazy.tsx | 69 ++++++++++++++++++++ src/util/test/lazy-test.js | 129 +++++++++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 src/util/lazy.tsx create mode 100644 src/util/test/lazy-test.js 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/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)'); + }); +}); From e2a9243b60d91c50ec8f8af9f27028c5a0f250ef Mon Sep 17 00:00:00 2001 From: Robert Knight Date: Thu, 10 Jul 2025 11:21:12 +0100 Subject: [PATCH 2/2] Add documentation page for the `lazy` utility --- .../components/PlaygroundApp.tsx | 8 ++ .../patterns/utilities/LazyPage.tsx | 114 ++++++++++++++++++ src/pattern-library/examples/lazy-basic.tsx | 43 +++++++ .../examples/lazy-error-handling.tsx | 58 +++++++++ src/pattern-library/routes.ts | 8 ++ 5 files changed, 231 insertions(+) create mode 100644 src/pattern-library/components/patterns/utilities/LazyPage.tsx create mode 100644 src/pattern-library/examples/lazy-basic.tsx create mode 100644 src/pattern-library/examples/lazy-error-handling.tsx 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', + }, ]; /**