diff --git a/packages/docs-v3/astro.config.mjs b/packages/docs-v3/astro.config.mjs
index 2365153..986cbd0 100644
--- a/packages/docs-v3/astro.config.mjs
+++ b/packages/docs-v3/astro.config.mjs
@@ -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' },
diff --git a/packages/docs-v3/src/content/docs/concepts/suspense.mdx b/packages/docs-v3/src/content/docs/concepts/suspense.mdx
new file mode 100644
index 0000000..ecadb21
--- /dev/null
+++ b/packages/docs-v3/src/content/docs/concepts/suspense.mdx
@@ -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 `` boundary above the `` - 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 ``. Setting `suspenseFallback` enables it:
+
+```tsx title="App.tsx"
+}>
+
+
+
+```
+
+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"
+
+```
+
+
+
+## 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>) => {
+ const user = use(data);
+
+ return (
+
+ );
+};
+```
+
+```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.
+
+
diff --git a/packages/docs-v3/src/content/docs/reference/components/dialog-provider.md b/packages/docs-v3/src/content/docs/reference/components/dialog-provider.md
index c9178e6..3b6bc44 100644
--- a/packages/docs-v3/src/content/docs/reference/components/dialog-provider.md
+++ b/packages/docs-v3/src/content/docs/reference/components/dialog-provider.md
@@ -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 `` boundary. See [Suspense](/concepts/suspense) |
+| `suspenseFallback` | `React.ReactNode` | - | Rendered in place of a dialog while that dialog is suspended. Setting this enables `suspense` |
## Source
diff --git a/packages/docs-v3/src/content/docs/reference/hooks/use-dialog-lazy.md b/packages/docs-v3/src/content/docs/reference/hooks/use-dialog-lazy.md
index 4708212..87bd94f 100644
--- a/packages/docs-v3/src/content/docs/reference/hooks/use-dialog-lazy.md
+++ b/packages/docs-v3/src/content/docs/reference/hooks/use-dialog-lazy.md
@@ -56,6 +56,10 @@ Extends [`useDialogReturn`](/reference/hooks/use-dialog#usedialogreturn)
|-----------|-----------------------|---------------------------------------------------------------------------------------|
| `preload` | `() => Promise` | Preloads the dialog, so that it will be immediately available when `open()` is called |
+## Suspense
+
+When suspense is enabled on the ``, 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)
diff --git a/packages/react-dialog-async/src/DialogOutlet/DialogOutlet.tsx b/packages/react-dialog-async/src/DialogOutlet/DialogOutlet.tsx
index 6a63e2e..c9bf557 100644
--- a/packages/react-dialog-async/src/DialogOutlet/DialogOutlet.tsx
+++ b/packages/react-dialog-async/src/DialogOutlet/DialogOutlet.tsx
@@ -22,7 +22,11 @@ export const DialogOutlet = () => {
};
}, []);
- const dialogComponents = useRenderDialogs(dialogState.dialogs);
+ const dialogComponents = useRenderDialogs(
+ dialogState.dialogs,
+ dialogState.suspense,
+ dialogState.suspenseFallback,
+ );
return <>{dialogComponents}>;
};
diff --git a/packages/react-dialog-async/src/DialogOutlet/useRenderDialogs.tsx b/packages/react-dialog-async/src/DialogOutlet/useRenderDialogs.tsx
index a68bd12..fcbff8a 100644
--- a/packages/react-dialog-async/src/DialogOutlet/useRenderDialogs.tsx
+++ b/packages/react-dialog-async/src/DialogOutlet/useRenderDialogs.tsx
@@ -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 `` 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);
@@ -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 ? (
+
+
+
+ ) : (
+
+ );
+
return (
-
+ {content}
);
},
);
- }, [state]);
+ }, [state, suspense, suspenseFallback]);
};
diff --git a/packages/react-dialog-async/src/DialogProvider/DialogProvider.tsx b/packages/react-dialog-async/src/DialogProvider/DialogProvider.tsx
index 3f342f7..4699baf 100644
--- a/packages/react-dialog-async/src/DialogProvider/DialogProvider.tsx
+++ b/packages/react-dialog-async/src/DialogProvider/DialogProvider.tsx
@@ -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
@@ -158,13 +161,19 @@ export const DialogProvider = ({
show,
hide,
updateData,
+ suspense,
}),
- [show, hide, updateData],
+ [show, hide, updateData, suspense],
);
return (
{children}
@@ -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(
diff --git a/packages/react-dialog-async/src/DialogProvider/types.ts b/packages/react-dialog-async/src/DialogProvider/types.ts
index 7a794d2..cb5bb5f 100644
--- a/packages/react-dialog-async/src/DialogProvider/types.ts
+++ b/packages/react-dialog-async/src/DialogProvider/types.ts
@@ -1,4 +1,4 @@
-import type { PropsWithChildren } from 'react';
+import type { PropsWithChildren, ReactNode } from 'react';
export interface DialogProviderProps extends PropsWithChildren {
/**
@@ -6,4 +6,25 @@ export interface DialogProviderProps extends PropsWithChildren {
* @default 300
*/
defaultUnmountDelayInMs?: number;
+ /**
+ * Renders every dialog inside its own `` 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;
}
diff --git a/packages/react-dialog-async/src/__snapshots__/suspense.test.tsx.snap b/packages/react-dialog-async/src/__snapshots__/suspense.test.tsx.snap
new file mode 100644
index 0000000..801ca91
--- /dev/null
+++ b/packages/react-dialog-async/src/__snapshots__/suspense.test.tsx.snap
@@ -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`] = `
+
+
+ Hello World!
+
+
+`;
diff --git a/packages/react-dialog-async/src/context/DialogActionsContext.tsx b/packages/react-dialog-async/src/context/DialogActionsContext.tsx
index f689a24..a12e164 100644
--- a/packages/react-dialog-async/src/context/DialogActionsContext.tsx
+++ b/packages/react-dialog-async/src/context/DialogActionsContext.tsx
@@ -11,6 +11,11 @@ export interface DialogActionsContextValue {
) => Promise;
hide: (dialogId: string, data?: any) => void;
updateData: (dialogId: string, data: unknown) => void;
+ /**
+ * Whether dialogs are rendered inside a `` boundary, as configured
+ * on the ``.
+ */
+ suspense: boolean;
lazyLoaderFn?: (loaderFn: () => Promise) => Promise;
}
diff --git a/packages/react-dialog-async/src/context/GlobalDialogStateContext.tsx b/packages/react-dialog-async/src/context/GlobalDialogStateContext.tsx
index 46661cb..1814fbf 100644
--- a/packages/react-dialog-async/src/context/GlobalDialogStateContext.tsx
+++ b/packages/react-dialog-async/src/context/GlobalDialogStateContext.tsx
@@ -1,4 +1,4 @@
-import { createContext } from 'react';
+import { createContext, type ReactNode } from 'react';
import type { AsyncDialogComponent } from '../types.js';
export type dialogsStateData = Record<
@@ -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
+ * ``.
+ */
+ suspenseFallback?: ReactNode;
+ /**
+ * Whether dialogs are rendered inside a `` boundary, as configured
+ * on the ``.
+ */
+ suspense: boolean;
};
export const GlobalDialogStateContext =
diff --git a/packages/react-dialog-async/src/suspense.test.tsx b/packages/react-dialog-async/src/suspense.test.tsx
new file mode 100644
index 0000000..cb3b394
--- /dev/null
+++ b/packages/react-dialog-async/src/suspense.test.tsx
@@ -0,0 +1,194 @@
+import { type PropsWithChildren, Suspense, act, useEffect, use } from 'react';
+import { expect, test } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+
+// Required for React to flush the re-render that happens when a suspended
+// dialog's promise resolves.
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+import { DialogProvider } from './DialogProvider/DialogProvider.js';
+import { DialogOutlet } from './DialogOutlet/DialogOutlet.js';
+import { useDialog } from './useDialog/useDialog.js';
+import { useDialogLazy } from './useDialogLazy/useDialogLazy.js';
+
+const LoadedDialog = () =>
Hello World!
;
+
+/**
+ * A dialog that suspends until the given promise resolves.
+ */
+const SuspendingDialog = ({ data }: { data: Promise }) => (
+
{use(data)}
+);
+
+const deferred = () => {
+ let resolve!: (value: string) => void;
+ const promise = new Promise((r) => (resolve = r));
+ return { promise, resolve };
+};
+
+const openOnMount = (useDialogResult: { open: (data?: any) => unknown }) => {
+ useEffect(() => {
+ void useDialogResult.open();
+ }, []);
+ return null;
+};
+
+test('a suspending dialog renders the provider fallback, then the dialog', async () => {
+ const { promise, resolve } = deferred();
+
+ const TestComponent = () => {
+ const dialog = useDialog(SuspendingDialog, { defaultData: promise });
+ return openOnMount(dialog);
+ };
+
+ await act(async () => {
+ render(
+ Loading...}>
+
+
+ ,
+ );
+ });
+
+ expect(screen.getByText('Loading...')).toBeTruthy();
+
+ await act(async () => {
+ resolve('Hello World!');
+ await promise;
+ });
+
+ expect(screen.getByText('Hello World!')).toBeTruthy();
+});
+
+test('useDialogLazy shows the fallback while the component loads', async () => {
+ const { promise, resolve } = deferred();
+
+ const TestComponent = () => {
+ const dialog = useDialogLazy(async () => {
+ await promise;
+ return { default: LoadedDialog };
+ });
+ return openOnMount(dialog);
+ };
+
+ render(
+ Loading...}>
+
+
+ ,
+ );
+
+ // The dialog is shown immediately, rather than only once the chunk has loaded
+ await waitFor(() => expect(screen.getByText('Loading...')).toBeTruthy());
+
+ resolve('');
+
+ await waitFor(() => expect(screen.getByText('Hello World!')).toBeTruthy());
+});
+
+test('useDialogLazy still awaits the component when suspense is disabled', async () => {
+ const { promise, resolve } = deferred();
+
+ const TestComponent = () => {
+ const dialog = useDialogLazy(async () => {
+ await promise;
+ return { default: LoadedDialog };
+ });
+ return openOnMount(dialog);
+ };
+
+ render(
+
+
+
+ ,
+ );
+
+ expect(screen.queryByText('Hello World!')).toBeNull();
+
+ resolve('');
+
+ await waitFor(() => expect(screen.getByText('Hello World!')).toBeTruthy());
+});
+
+const NoSuspenseWrapper = ({ children }: PropsWithChildren) => (
+
+ {children}
+
+
+);
+
+test('dialogs are not wrapped in a boundary unless suspense is configured', async () => {
+ const TestComponent = () => {
+ const dialog = useDialog(LoadedDialog);
+ return openOnMount(dialog);
+ };
+
+ const { asFragment } = render(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(screen.getByText('Hello World!')).toBeTruthy());
+ expect(asFragment()).toMatchSnapshot();
+});
+
+test('without suspense, a suspending dialog propagates to the boundary above the outlet', async () => {
+ const { promise, resolve } = deferred();
+
+ const TestComponent = () => {
+ const dialog = useDialog(SuspendingDialog, { defaultData: promise });
+ return openOnMount(dialog);
+ };
+
+ await act(async () => {
+ render(
+ Outer fallback}>
+
+
+
+ ,
+ );
+ });
+
+ expect(screen.getByText('Outer fallback')).toBeTruthy();
+
+ await act(async () => {
+ resolve('Hello World!');
+ await promise;
+ });
+
+ expect(screen.getByText('Hello World!')).toBeTruthy();
+});
+
+test('suspense can be enabled without configuring a fallback', async () => {
+ const { promise, resolve } = deferred();
+
+ const TestComponent = () => {
+ const dialog = useDialog(SuspendingDialog, { defaultData: promise });
+ return openOnMount(dialog);
+ };
+
+ await act(async () => {
+ render(
+ Outer fallback}>
+
+
+
+
+ ,
+ );
+ });
+
+ // The dialog renders nothing while suspended, rather than suspending the
+ // boundary above the outlet
+ expect(screen.queryByText('Outer fallback')).toBeNull();
+
+ await act(async () => {
+ resolve('Hello World!');
+ await promise;
+ });
+
+ expect(screen.getByText('Hello World!')).toBeTruthy();
+});
diff --git a/packages/react-dialog-async/src/useDialogLazy/types.ts b/packages/react-dialog-async/src/useDialogLazy/types.ts
index 5e67b2c..b9f6754 100644
--- a/packages/react-dialog-async/src/useDialogLazy/types.ts
+++ b/packages/react-dialog-async/src/useDialogLazy/types.ts
@@ -1,5 +1,14 @@
+import type { AsyncDialogComponent } from '../types.js';
import type { useDialogReturn } from '../useDialog/types.js';
+/**
+ * Loads a dialog component, either directly or as the default export of a
+ * module (i.e. the result of a dynamic `import()`).
+ */
+export type DialogComponentLoader = () => Promise<
+ AsyncDialogComponent | { default: AsyncDialogComponent }
+>;
+
export type useDialogLazyReturn<
D,
R,
@@ -9,6 +18,9 @@ export type useDialogLazyReturn<
* Call this method to preload the dialog ahead of time. If you don't call this method,
* the dialog component will be loaded the first time dialog.open() is called.
*
+ * When suspense is enabled, preloading warms the module cache so that the
+ * dialog resolves without ever showing the suspense fallback.
+ *
* Example usage:
* ```tsx
* const myDialog = useDialogLazy(() => import('./MyDialog'));
diff --git a/packages/react-dialog-async/src/useDialogLazy/useDialogLazy.tsx b/packages/react-dialog-async/src/useDialogLazy/useDialogLazy.tsx
index b511bf8..ec93dc5 100644
--- a/packages/react-dialog-async/src/useDialogLazy/useDialogLazy.tsx
+++ b/packages/react-dialog-async/src/useDialogLazy/useDialogLazy.tsx
@@ -1,13 +1,20 @@
-import { useCallback, useContext, useEffect, useId, useRef } from 'react';
+import { lazy, useCallback, useContext, useEffect, useId, useRef } from 'react';
import type { AsyncDialogComponent } from '../types.js';
import type { useDialogOptions } from '../useDialog/types.js';
import { DialogActionsContext } from '../context/DialogActionsContext.js';
-import type { useDialogLazyReturn } from './types.js';
+import type { DialogComponentLoader, useDialogLazyReturn } from './types.js';
+
+/**
+ * Normalises the result of a loader function, which may either return the
+ * component directly, or a module with the component as its default export.
+ */
+const unwrapLoaderResult = (
+ loaderResult: Awaited>>,
+): AsyncDialogComponent =>
+ 'default' in loaderResult ? loaderResult.default : loaderResult;
export function useDialogLazy(
- componentLoader: () => Promise<
- AsyncDialogComponent | { default: AsyncDialogComponent }
- >,
+ componentLoader: DialogComponentLoader,
options?: useDialogOptions,
): useDialogLazyReturn {
const id = useId();
@@ -22,15 +29,28 @@ export function useDialogLazy(
);
}
+ const { suspense } = ctx;
+
+ /**
+ * In suspense mode the component is loaded by React while rendering, rather
+ * than being awaited before the dialog is shown. Created lazily so that the
+ * loader is never invoked for dialogs that are never opened.
+ */
+ const lazyComponentRef = useRef | null>(null);
+ const getLazyComponent = () => {
+ lazyComponentRef.current ??= lazy(async () => ({
+ default: unwrapLoaderResult(await componentLoader()),
+ })) as unknown as AsyncDialogComponent;
+
+ return lazyComponentRef.current;
+ };
+
useEffect(() => {
if (ctx.lazyLoaderFn) {
// Call the lazy loader function with a callback to load the component
void ctx.lazyLoaderFn(async () => {
if (!componentRef.current) {
- const loaderResult = await componentLoader();
-
- componentRef.current =
- 'default' in loaderResult ? loaderResult.default : loaderResult;
+ componentRef.current = unwrapLoaderResult(await componentLoader());
}
});
}
@@ -46,22 +66,21 @@ export function useDialogLazy(
const open = useCallback(
async (data?: D): Promise => {
- if (!componentRef.current) {
- const loaderResult = await componentLoader();
-
- componentRef.current =
- 'default' in loaderResult ? loaderResult.default : loaderResult;
+ // In suspense mode the dialog is shown immediately, and React renders the
+ // configured fallback until the component has finished loading.
+ if (!suspense && !componentRef.current) {
+ componentRef.current = unwrapLoaderResult(await componentLoader());
}
return ctx.show(
id,
idCount.current++,
- componentRef.current,
+ suspense ? getLazyComponent() : componentRef.current!,
data ?? options?.defaultData,
options?.unmountDelayInMs,
);
},
- [id, options?.defaultData, options?.unmountDelayInMs],
+ [id, suspense, options?.defaultData, options?.unmountDelayInMs],
);
const close = () => {
@@ -74,10 +93,7 @@ export function useDialogLazy(
const preload = async () => {
if (!componentRef.current) {
- const loaderResult = await componentLoader();
-
- componentRef.current =
- 'default' in loaderResult ? loaderResult.default : loaderResult;
+ componentRef.current = unwrapLoaderResult(await componentLoader());
}
};