Skip to content
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 packages/docs-v3/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export default defineConfig({
{ label: 'Multiple Dialogs', slug: 'concepts/multiple-dialogs' },
{ label: 'Animations', slug: 'concepts/animations' },
{ label: 'Performance', slug: 'concepts/performance' },
{ label: 'Suspense', slug: 'concepts/suspense' },
{ label: 'Static Dialogs', slug: 'concepts/static-dialogs' },
{ label: 'React Native', slug: 'concepts/react-native' },
{ label: 'Next.js / SSR', slug: 'concepts/next-js-ssr' },
Expand Down
76 changes: 76 additions & 0 deletions packages/docs-v3/src/content/docs/concepts/suspense.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
---
title: Suspense
---

import { Aside } from '@astrojs/starlight/components';

Dialogs are often the part of an app that needs to wait on something - a lazily loaded chunk, or data that is only fetched once the dialog is opened. React models this with [Suspense](https://react.dev/reference/react/Suspense), and React Dialog Async can render each of your dialogs inside its own boundary.

## The problem

By default, dialogs are rendered without a boundary of their own. If a dialog suspends, that suspension propagates up to the nearest `<Suspense/>` boundary above the `<DialogOutlet/>` - which is usually somewhere near the root of your app. The result is that opening a dialog replaces your whole page with the app-level fallback until the dialog is ready.

## Enabling suspense

Suspense is configured once, globally, on the `<DialogProvider/>`. Setting `suspenseFallback` enables it:

```tsx title="App.tsx"
<DialogProvider suspenseFallback={<LoadingSpinner />}>
<App />
<DialogOutlet />
</DialogProvider>
```

Every dialog is now rendered inside its own boundary, so a suspending dialog shows the fallback in place of itself, and the rest of your app is left alone.

If you'd rather render nothing at all while a dialog is loading, enable suspense without a fallback:

```tsx title="App.tsx"
<DialogProvider suspense>
```

<Aside>
The fallback is rendered inside the dialog's context, so it can call `useDialogContext()` - useful if you want the fallback to render inside your own dialog chrome.
</Aside>

## Suspending on data

With suspense enabled, a dialog can read a promise with `use()` and the fallback will be shown until it resolves:

```tsx title="UserDialog.tsx"
import { use } from 'react';
import type { AsyncDialogProps } from 'react-dialog-async';

const UserDialog = ({ data, handleClose }: AsyncDialogProps<Promise<User>>) => {
const user = use(data);

return (
<Dialog onClose={() => handleClose()}>
<h2>{user.name}</h2>
</Dialog>
);
};
```

```tsx title="UserList.tsx"
const userDialog = useDialog(UserDialog);

// The dialog opens immediately, showing the fallback until the user loads
const handleClick = (id: string) => userDialog.open(fetchUser(id));
```

## Suspense and `useDialogLazy`

Enabling suspense also changes how [`useDialogLazy`](/reference/hooks/use-dialog-lazy) loads its component.

Without suspense, `open()` waits for the component to be fetched before the dialog is shown - so there is a delay between the user clicking and anything appearing on screen. With suspense enabled, the component is loaded through `React.lazy`, so the dialog is shown straight away and the fallback covers the load.

```tsx title="Homepage.tsx"
const onboardingDialog = useDialogLazy(() => import('./OnboardingDialog'));
```

Calling `preload()` still warms the module cache ahead of time, so a preloaded dialog opens without the fallback ever being shown.

<Aside type="caution">
When suspense is enabled, a failed dynamic import throws while rendering rather than rejecting the promise returned by `open()`. Render an [error boundary](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary) above your `<DialogOutlet/>` if you need to handle that case.
</Aside>
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ function DialogProvider(props: DialogProviderProps): JSX.Element
|-|-|---------|-----------|
| `children` | `React.ReactNode` | - | Children |
| `defaultUnmountDelayInMs` | `number` | `300` | Default delay in milliseconds to wait before unmounting a dialog after it is closed |
| `suspense` | `boolean` | `true` if `suspenseFallback` is set | Renders every dialog inside its own `<Suspense/>` boundary. See [Suspense](/concepts/suspense) |
| `suspenseFallback` | `React.ReactNode` | - | Rendered in place of a dialog while that dialog is suspended. Setting this enables `suspense` |

## Source

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ Extends [`useDialogReturn`](/reference/hooks/use-dialog#usedialogreturn)
|-----------|-----------------------|---------------------------------------------------------------------------------------|
| `preload` | `() => Promise<void>` | Preloads the dialog, so that it will be immediately available when `open()` is called |

## Suspense

When suspense is enabled on the `<DialogProvider/>`, the component is loaded through `React.lazy` and the dialog is shown immediately, rather than `open()` waiting for the component to be fetched. See [Suspense](/concepts/suspense).

## Source

[View on GitHub](https://github.com/a16n-dev/react-dialog-async/blob/main/packages/react-dialog-async/src/useDialogLazy/useDialogLazy.tsx)
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ export const DialogOutlet = () => {
};
}, []);

const dialogComponents = useRenderDialogs(dialogState.dialogs);
const dialogComponents = useRenderDialogs(
dialogState.dialogs,
dialogState.suspense,
dialogState.suspenseFallback,
);

return <>{dialogComponents}</>;
};
26 changes: 22 additions & 4 deletions packages/react-dialog-async/src/DialogOutlet/useRenderDialogs.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { useMemo } from 'react';
import { Suspense, useMemo, type ReactNode } from 'react';
import type { dialogsStateData } from '../context/GlobalDialogStateContext.js';
import { IndividualDialogStateContext } from '../context/IndividualDialogStateContext.js';

/**
* Given the current dialog state, outputs an array of `Element`s to be rendered.
*
* @param state - the current dialog state
* @param suspense - whether each dialog is wrapped in a `<Suspense/>` boundary
* @param suspenseFallback - rendered in place of a dialog while it is suspended
*/
export const useRenderDialogs = (state: dialogsStateData) => {
export const useRenderDialogs = (
state: dialogsStateData,
suspense: boolean,
suspenseFallback?: ReactNode,
) => {
return useMemo(() => {
const entries = Object.entries(state);

Expand Down Expand Up @@ -34,12 +42,22 @@ export const useRenderDialogs = (state: dialogsStateData) => {
// This key will be unique for each open of the dialog, ensuring that the dialog always resets its internal state.
const key = id + hash;

// Without a boundary, suspending continues to propagate up to the
// nearest boundary above the outlet, as it did before suspense support.
const content = suspense ? (
<Suspense fallback={suspenseFallback ?? null}>
<Component {...dialogProps} />
</Suspense>
) : (
<Component {...dialogProps} />
);

return (
<IndividualDialogStateContext.Provider key={key} value={contextValue}>
<Component {...dialogProps} />
{content}
</IndividualDialogStateContext.Provider>
);
},
);
}, [state]);
}, [state, suspense, suspenseFallback]);
};
19 changes: 16 additions & 3 deletions packages/react-dialog-async/src/DialogProvider/DialogProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import type { DialogProviderProps } from './types.js';

export const DialogProvider = ({
defaultUnmountDelayInMs = 300,
suspenseFallback,
// Providing a fallback is taken as opting in to suspense
suspense = suspenseFallback !== undefined,
children,
}: DialogProviderProps) => {
// This ref tracks timers for unmount dialogs after they're closed
Expand Down Expand Up @@ -158,13 +161,19 @@ export const DialogProvider = ({
show,
hide,
updateData,
suspense,
}),
[show, hide, updateData],
[show, hide, updateData, suspense],
);

return (
<GlobalDialogStateContext.Provider
value={{ dialogs: dialogState, setIsUsingOutlet: setUsingOutlet }}
value={{
dialogs: dialogState,
setIsUsingOutlet: setUsingOutlet,
suspenseFallback,
suspense,
}}
>
<DialogActionsContext.Provider value={ctx}>
{children}
Expand All @@ -191,7 +200,11 @@ const InternalDialogOutlet = () => {
);
}

const dialogComponents = useRenderDialogs(dialogState.dialogs);
const dialogComponents = useRenderDialogs(
dialogState.dialogs,
dialogState.suspense,
dialogState.suspenseFallback,
);

if (process.env.NODE_ENV !== 'production' && dialogComponents.length > 0) {
console.warn(
Expand Down
23 changes: 22 additions & 1 deletion packages/react-dialog-async/src/DialogProvider/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,30 @@
import type { PropsWithChildren } from 'react';
import type { PropsWithChildren, ReactNode } from 'react';

export interface DialogProviderProps extends PropsWithChildren {
/**
* The default delay in milliseconds to wait before unmounting a dialog after it's closed.
* @default 300
*/
defaultUnmountDelayInMs?: number;
/**
* Renders every dialog inside its own `<Suspense/>` boundary, so a dialog
* that suspends - because it lazily loads its component, or reads data with
* `use()` - shows `suspenseFallback` in place of itself, rather than the
* fallback of a boundary further up the tree.
*
* This also changes `useDialogLazy` to load its component through
* `React.lazy`, meaning `open()` no longer waits for the component to be
* fetched before the dialog is shown.
*
* @default true if `suspenseFallback` is set, otherwise false
*/
suspense?: boolean;
/**
* Rendered in place of a dialog while that dialog is suspended. Setting this
* enables `suspense`.
*
* The fallback renders inside the dialog's context, so it can call
* `useDialogContext()`.
*/
suspenseFallback?: ReactNode;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`dialogs are not wrapped in a boundary unless suspense is configured 1`] = `
<DocumentFragment>
<div>
Hello World!
</div>
</DocumentFragment>
`;
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ export interface DialogActionsContextValue {
) => Promise<any>;
hide: (dialogId: string, data?: any) => void;
updateData: (dialogId: string, data: unknown) => void;
/**
* Whether dialogs are rendered inside a `<Suspense/>` boundary, as configured
* on the `<DialogProvider/>`.
*/
suspense: boolean;
lazyLoaderFn?: (loaderFn: () => Promise<void>) => Promise<void>;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createContext } from 'react';
import { createContext, type ReactNode } from 'react';
import type { AsyncDialogComponent } from '../types.js';

export type dialogsStateData = Record<
Expand All @@ -16,6 +16,16 @@ export type dialogsStateData = Record<
export type GlobalDialogStateContextValue = {
setIsUsingOutlet: (value: boolean) => void;
dialogs: dialogsStateData;
/**
* Rendered in place of a dialog while it is suspended, as configured on the
* `<DialogProvider/>`.
*/
suspenseFallback?: ReactNode;
/**
* Whether dialogs are rendered inside a `<Suspense/>` boundary, as configured
* on the `<DialogProvider/>`.
*/
suspense: boolean;
};

export const GlobalDialogStateContext =
Expand Down
Loading
Loading