Skip to content

Add lazy utility to create lazy-loading UI components #2035

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
8 changes: 8 additions & 0 deletions src/pattern-library/components/PlaygroundApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -204,6 +205,13 @@ export default function PlaygroundApp({
))}
</NavList>

<NavHeader>Utilities</NavHeader>
<NavList>
{utilityRoutes.map(route => (
<NavLink key={route.title} route={route} />
))}
</NavList>

{prototypeRoutes.length > 0 && (
<>
<NavHeader>Prototypes</NavHeader>
Expand Down
114 changes: 114 additions & 0 deletions src/pattern-library/components/patterns/utilities/LazyPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import Library from '../../Library';

export default function LazyPage() {
return (
<Library.Page
title="lazy"
intro={
<p>
The <code>lazy</code> utility creates a component which is loaded
asynchronously, displaying fallback content while loading and showing
an error fallback if the component fails to load.
</p>
}
>
<Library.Section title="API">
<Library.SectionL2>
<Library.Usage symbolName="lazy" />
</Library.SectionL2>
<Library.SectionL2 title="Parameters">
<Library.SectionL3 title="displayName">
<Library.Info>
<Library.InfoItem label="description">
Display name for the lazy wrapper component.
</Library.InfoItem>
<Library.InfoItem label="type">
<code>string</code>
</Library.InfoItem>
</Library.Info>
</Library.SectionL3>

<Library.SectionL3 title="load">
<Library.Info>
<Library.InfoItem label="description">
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 <code>import(...)</code> expression to
load the component.
</Library.InfoItem>
<Library.InfoItem label="type">
<code>{'() => Promise<FunctionalComponent<P>>'}</code>
</Library.InfoItem>
</Library.Info>
</Library.SectionL3>

<Library.SectionL3 title="options">
<Library.Info>
<Library.InfoItem label="description">
Configuration for the lazy component
</Library.InfoItem>
<Library.InfoItem label="type">
<code>LazyOptions&lt;P&gt;</code>
</Library.InfoItem>
</Library.Info>
</Library.SectionL3>
</Library.SectionL2>

<Library.SectionL2 title="LazyOptions">
<Library.SectionL3 title="fallback">
<Library.Info>
<Library.InfoItem label="description">
Function that returns content to display while loading
</Library.InfoItem>
<Library.InfoItem label="type">
<code>{'(props: P) => ComponentChildren'}</code>
</Library.InfoItem>
</Library.Info>
</Library.SectionL3>

<Library.SectionL3 title="errorFallback">
<Library.Info>
<Library.InfoItem label="description">
Function that returns content to display if loading fails. If
not provided a default error fallback is shown.
</Library.InfoItem>
<Library.InfoItem label="type">
<code>
{
'((props: P, error: Error) => ComponentChildren) | undefined'
}
</code>
</Library.InfoItem>
<Library.InfoItem label="default">
<code>undefined</code>
</Library.InfoItem>
</Library.Info>
</Library.SectionL3>
</Library.SectionL2>
</Library.Section>

<Library.Section title="Examples">
<Library.SectionL2 title="Basic usage">
<p>
Lazy components start loading on first render, and show a fallback
while loading.
</p>
<Library.Demo
title="Basic lazy component"
exampleFile="lazy-basic"
withSource
/>
</Library.SectionL2>

<Library.SectionL2 title="Error handling">
<p>If a component fails to load, the error fallback is displayed:</p>
<Library.Demo
title="Component with error state"
exampleFile="lazy-error-handling"
withSource
/>
</Library.SectionL2>
</Library.Section>
</Library.Page>
);
}
43 changes: 43 additions & 0 deletions src/pattern-library/examples/lazy-basic.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Button, lazy } from '../../';

type ProfileProps = {
name: string;
description: string;
};

function Profile({ name, description }: ProfileProps) {
return (
<div className="p-4 border border-green-500 rounded">
<h3 className="text-green-700 font-bold">{name}</h3>
<p>{description}</p>
</div>
);
}

let resolve: (x: typeof Profile) => void;
const promise = new Promise<typeof Profile>(resolve_ => (resolve = resolve_));

const LazyProfile = lazy('Profile', () => promise, {
fallback: ({ name }) => (
<div className="p-4 border border-gray-300 rounded">
<p className="text-gray-500 mt-2">
Loading {name}
{"'"}s profile...
</p>
</div>
),
});

export default function App() {
return (
<div>
<Button onClick={() => resolve(Profile)} variant="primary" classes="my-2">
Finish loading
</Button>
<LazyProfile
name="Bob Parr"
description="Robert Parr, also known as Mr. Incredible, is the husband of Helen Parr, and the father of Violet Parr, Dash Parr, and Jack-Jack Parr."
/>
</div>
);
}
58 changes: 58 additions & 0 deletions src/pattern-library/examples/lazy-error-handling.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="p-4 border border-green-500 rounded">
<h3 className="text-green-700 font-bold">{name}</h3>
<p>{description}</p>
</div>
);
}

let reject: (err: Error) => void;
const promise = new Promise<typeof Profile>(
(_resolve, reject_) => (reject = reject_),
);

const LazyProfile = lazy('Profile', () => promise, {
fallback: ({ name }) => (
<div className="p-4 border border-gray-300 rounded">
<p className="text-gray-500 mt-2">
Loading {name}
{"'"}s profile...
</p>
</div>
),
errorFallback: ({ name }, error) => (
<div className="p-4 border border-red-300 rounded">
<p className="text-gray-500 mt-2">
Unable to load {name}
{"'"}s profile: {error.message}
</p>
</div>
),
});

export default function App() {
return (
<div>
<Button
onClick={() => reject(new Error('Something went wrong'))}
variant="primary"
classes="my-2"
>
Trigger loading error
</Button>
<LazyProfile
name="Bob Parr"
description="Robert Parr, also known as Mr. Incredible, is the husband of Helen Parr, and the father of Violet Parr, Dash Parr, and Jack-Jack Parr."
/>
</div>
);
}
8 changes: 8 additions & 0 deletions src/pattern-library/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -53,6 +54,7 @@ export type PlaygroundRouteGroup =
| 'foundations'
| 'components'
| 'hooks'
| 'utilities'
| 'prototype'
| 'custom';

Expand Down Expand Up @@ -280,6 +282,12 @@ const routes: PlaygroundRoute[] = [
component: UseClickAwayPage,
route: '/hooks-use-click-away',
},
{
title: 'lazy',
group: 'utilities',
component: LazyPage,
route: '/utilities-lazy',
},
];

/**
Expand Down
69 changes: 69 additions & 0 deletions src/util/lazy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { ComponentChildren, FunctionalComponent, JSX } from 'preact';
import { useState } from 'preact/hooks';

export type LazyOptions<P> = {
/** 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<P>(
displayName: string,
load: () => Promise<FunctionalComponent<P>>,
{ errorFallback, fallback }: LazyOptions<P>,
) {
function Lazy(props: P & JSX.IntrinsicAttributes) {
const [component, setComponent] = useState<FunctionalComponent<P>>();
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(false);

if (error) {
return errorFallback ? (
errorFallback(props, error)
) : (
<div>
<p>
There was a problem loading this content. Try refreshing the page.
</p>
<b>Details:</b> {error.message}
</div>
);
}
if (!component && !loading) {
setLoading(true);
load()
.then(component => {
setComponent(() => component);
})
.catch(setError)
.finally(() => {
setLoading(false);
});
}
if (component) {
const Component = component;
return <Component {...props} />;
}
return fallback(props);
}

Lazy.displayName = `Lazy(${displayName})`;
return Lazy;
}
Loading