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 ( + handleClose()}> +

{user.name}

+
+ ); +}; +``` + +```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()); } };