Skip to content
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

Add OverlayToaster.createAsync method to support React 18 #6599

Merged
merged 8 commits into from
Dec 20, 2023
Merged
7 changes: 6 additions & 1 deletion packages/core/src/common/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,12 @@ export const SPINNER_WARN_CLASSES_SIZE = ns + ` <Spinner> Classes.SMALL/LARGE ar
export const TOASTER_CREATE_NULL =
ns +
` OverlayToaster.create() is not supported inside React lifecycle methods in React 16.` +
` See usage example on the docs site.`;
` See usage example on the docs site. https://blueprintjs.com/docs/#core/components/toast.example`;
export const TOASTER_CREATE_ASYNC_NULL =
ns +
` OverlayToaster.createAsync() received a null component ref. This can happen if called inside React lifecycle ` +
`methods in React 16. See usage example on the docs site. ` +
`https://blueprintjs.com/docs/#core/components/toast.example`;
export const TOASTER_MAX_TOASTS_INVALID =
ns + ` <OverlayToaster> maxToasts is set to an invalid number, must be greater than 0`;
export const TOASTER_WARN_INLINE =
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export { Tab, type TabId, type TabProps } from "./tabs/tab";
export { Tabs, type TabsProps, TabsExpander, Expander } from "./tabs/tabs";
export { Tag, type TagProps } from "./tag/tag";
export { TagInput, type TagInputProps, type TagInputAddMethod } from "./tag-input/tagInput";
export { OverlayToaster } from "./toast/overlayToaster";
export { OverlayToaster, type OverlayToasterCreateOptions } from "./toast/overlayToaster";
export type { OverlayToasterProps, ToasterPosition } from "./toast/overlayToasterProps";
export { Toast, type ToastProps } from "./toast/toast";
export { Toaster, type ToastOptions } from "./toast/toaster";
Expand Down
76 changes: 75 additions & 1 deletion packages/core/src/components/toast/overlayToaster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ import * as React from "react";
import * as ReactDOM from "react-dom";

import { AbstractPureComponent, Classes, Position } from "../../common";
import { TOASTER_CREATE_NULL, TOASTER_MAX_TOASTS_INVALID, TOASTER_WARN_INLINE } from "../../common/errors";
import {
TOASTER_CREATE_ASYNC_NULL,
TOASTER_CREATE_NULL,
TOASTER_MAX_TOASTS_INVALID,
TOASTER_WARN_INLINE,
} from "../../common/errors";
import { DISPLAYNAME_PREFIX } from "../../common/props";
import { isNodeEnv } from "../../common/utils";
import { Overlay } from "../overlay/overlay";
Expand All @@ -31,6 +36,26 @@ export interface OverlayToasterState {
toasts: ToastOptions[];
}

export interface OverlayToasterCreateOptions {
/**
* A new DOM element will be created to render the OverlayToaster component
* and appended to this container.
*
* @default document.body
*/
container?: HTMLElement;

/**
* A function to render the OverlayToaster React component onto a newly
* created DOM element.
*
* Defaults to `ReactDOM.render`. A future version of Blueprint will default
* to using React 18's createRoot API, but it's possible to configure this
* function to use createRoot on earlier Blueprint versions.
*/
domRenderer?: (toaster: React.ReactElement<OverlayToasterProps>, containerElement: HTMLElement) => void;
}

/**
* OverlayToaster component.
*
Expand Down Expand Up @@ -66,6 +91,55 @@ export class OverlayToaster extends AbstractPureComponent<OverlayToasterProps, O
return toaster;
}

/**
* Similar to {@link OverlayToaster.create}, but returns a Promise to a
* Toaster instance after it's rendered and mounted to the DOM.
*
* This API will replace the synchronous {@link OverlayToaster.create} in a
* future major version of Blueprint to reflect React 18+'s new asynchronous
* rendering API.
*/
public static createAsync(props?: OverlayToasterProps, options?: OverlayToasterCreateOptions): Promise<Toaster> {
if (props != null && props.usePortal != null && !isNodeEnv("production")) {
console.warn(TOASTER_WARN_INLINE);
}

const container = options?.container ?? document.body;
const domRenderer = options?.domRenderer ?? ReactDOM.render;

const toasterComponentRoot = document.createElement("div");
container.appendChild(toasterComponentRoot);

return new Promise<Toaster>((resolve, reject) => {
try {
domRenderer(<OverlayToaster {...props} ref={handleRef} usePortal={false} />, toasterComponentRoot);
} catch (error) {
// Note that we're catching errors from the domRenderer function
// call, but not errors when rendering <OverlayToaster>, which
// happens in a separate scheduled tick. Wrapping the
// OverlayToaster in an error boundary would be necessary to
// capture rendering errors, but that's still a bit unreliable
// and would only catch errors rendering the initial mount.
reject(error);
}

// We can get a rough guarantee that the OverlayToaster has been
// mounted to the DOM by waiting until the ref callback here has
// been fired.
//
// This is the approach suggested under "What about the render
// callback?" at https://github.com/reactwg/react-18/discussions/5.
function handleRef(ref: OverlayToaster | null) {
if (ref == null) {
reject(new Error(TOASTER_CREATE_ASYNC_NULL));
return;
}

resolve(ref);
}
});
}

public state: OverlayToasterState = {
toasts: [],
};
Expand Down
79 changes: 69 additions & 10 deletions packages/core/src/components/toast/toast.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,16 @@ horizontally aligned along the left edge, center, or right edge of its container

There are three ways to use __OverlayToaster__:

1. __Recommended__: use the `OverlayToaster.create()` static method to create a new `Toaster` instance:
1. __Recommended__: use the `OverlayToaster.createAsync()` static method to create a new `Toaster` instance:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would actually swap the recommended approach here since OverlayToaster.create() and OverlayToaster.createAsync() both leak memory and add a DOM node to the page forever. Thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, sure, I'm down with that, we can change the recommended approach. So we'd reverse the order of approaches: 3 becomes 1 (recommended), and 1 becomes 3. Since it seems like most people prefer having an imperative API in their application to trigger toasts rather than storing a list of Toasts in app state and rendering <Toast> themselves.

It's worth adding more docs under the "Static usage" section around L105 with your note about memory leaks and leaving a DOM node on the page forever. Also perhaps update the code snippets there to use createAsync instead of create?

Copy link
Contributor Author

@gluxon gluxon Dec 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, sure, I'm down with that, we can change the recommended approach. So we'd reverse the order of approaches: 3 becomes 1 (recommended), and 1 becomes 3. Since it seems like most people prefer having an imperative API in their application to trigger toasts rather than storing a list of Toasts in app state and rendering <Toast> themselves.

I pushed up a few commits that switched 1 and 3, but decided to walk this back. The primary factor that changed my mind was that it becomes harder to pass the OverlayToaster ref down the React component tree. I think that will be a bigger point of confusion than I originally thought when I suggested switching the recommendations. Thanks for being open to my suggestion.

The latest push keeps the current recommendation since it may be the best of all options, but adds more notes around only creating one Toaster instance.

It's worth adding more docs under the "Static usage" section around L105 with your note about memory leaks and leaving a DOM node on the page forever.

Can do! Added in d375696.

Also perhaps update the code snippets there to use createAsync instead of create?

Good suggestion. Pushed up a commit that switches the docs and code snippets to createAsync here: 9b1fb93

```ts
const myToaster: Toaster = await OverlayToaster.createAsync({ position: "bottom" });
myToaster.show({ ...toastOptions });
```

We recommend calling `OverlayToaster.createAsync` once in your application and [sharing the created instance](#core/components/toast.example) throughout your application.

A synchronous `OverlayToaster.create()` static method is also available, but will be phased out since React 18+ no longer synchronously renders components to the DOM.

```ts
const myToaster: Toaster = OverlayToaster.create({ position: "bottom" });
myToaster.show({ ...toastOptions });
Expand Down Expand Up @@ -79,23 +88,49 @@ enable `autoFocus` for an individual `OverlayToaster` via a prop, if desired.

@## Static usage

__OverlayToaster__ provides the static `create` method that returns a new `Toaster`, rendered into an
__OverlayToaster__ provides the static `createAsync` method that returns a new `Toaster`, rendered into an
element attached to `<body>`. A toaster instance has a collection of methods to show and hide toasts in its given container.

```ts
OverlayToaster.create(props?: ToasterProps, container = document.body): Toaster
OverlayToaster.createAsync(props?: OverlayToasterProps, options?: OverlayToasterCreateOptions): Promise<Toaster>;
```

@interface OverlayToasterCreateOptions

The toaster will be rendered into a new element appended to the given `container`.
The `container` determines which element toasts are positioned relative to; the default value of `<body>` allows them to use the entire viewport.

Note that the return type is `Toaster`, which is a minimal interface that exposes only the instance
methods detailed below. It can be thought of as `OverlayToaster` minus the `React.Component` methods,
because the `OverlayToaster` should not be treated as a normal React component.
The return type is `Promise<Toaster>`, which is a minimal interface that exposes only the instance methods detailed
below. It can be thought of as `OverlayToaster` minus the `React.Component` methods, because the `OverlayToaster` should
not be treated as a normal React component.

A promise is returned as React components cannot be rendered synchronously after React version 18. If this makes
`Toaster` usage difficult outside of a function that's not `async`, it's still possible to attach `.then()` handlers to
the returned toaster.

Note that `OverlayToaster.create()` will throw an error if invoked inside a component lifecycle method, as
```ts
function synchronousFn() {
const toasterPromise = OverlayToaster.createAsync({});
toasterPromise.then(toaster => toaster.show({ message: "Toast!" }));
}
```

Note that `OverlayToaster.createAsync()` will throw an error if invoked inside a component lifecycle method, as
`ReactDOM.render()` will return `null` resulting in an inaccessible toaster instance.

<div class="@ns-callout @ns-intent-primary @ns-icon-info-sign @ns-callout-has-body-content">
<h5 class="@ns-heading">Beware of memory leaks</h5>

The static `createAsync` and `create` methods create a new `OverlayToaster` instance for the full lifetime of your
application. Since there's no React parent component, these methods create a new DOM node as a container for the
rendered `<OverlayToaster>` component. Every `createAsync` call will add a new DOM node. We do not recommend creating a
new `Toaster` every time a toast needs to be shown. To minimize leaking:

1. Call `OverlayToaster.createAsync` once in an application and [share the instance](#core/components/toast.example).
2. Consider one of the alternative APIs that mount the `<OverlayToaster>` somewhere in the application's React component tree. This provides component lifecycle management out of the box. See [_React component usage_](#core/components/toast.react-component-usage) for an example.

</div>
Comment on lines +121 to +132
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these docs look good 👍🏽

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!


@interface Toaster

@### Example
Expand All @@ -110,7 +145,7 @@ The following code samples demonstrate our preferred pattern for intergrating a
import { OverlayToaster, Position } from "@blueprintjs/core";

/** Singleton toaster instance. Create separate instances for different options. */
export const AppToaster = OverlayToaster.create({
export const AppToaster = OverlayToaster.createAsync({
className: "recipe-toaster",
position: Position.TOP,
});
Expand All @@ -128,14 +163,38 @@ export class App extends React.PureComponent {
return <Button onClick={this.showToast} text="Toast please" />;
}

showToast = () => {
showToast = async () => {
// create toasts in response to interactions.
// in most cases, it's enough to simply create and forget (thanks to timeout).
AppToaster.show({ message: "Toasted." });
(await AppToaster).show({ message: "Toasted." });
}
}
```

The example below uses the `OverlayToaster.createAsync()` static method. Clicking the button will create a new toaster mounted to `<body>`, show a message, and unmount the toaster from the DOM once the message is dismissed.

@reactExample ToastCreateAsyncExample

#### React 18

To maintain backwards compatibility with React 16 and 17, `OverlayToaster.createAsync` uses `ReactDOM.render` out of the box. This triggers a [console warning on React 18](https://react.dev/blog/2022/03/08/react-18-upgrade-guide#updates-to-client-rendering-apis). A future major version of Blueprint will drop support for React versions before 18 and switch the default rendering function from `ReactDOM.render` to `createRoot`.

If you're using React 18, we recommend passing in a custom `domRenderer` function.

```tsx
import { OverlayToaster } from "@blueprintjs/core";
import { createRoot } from "react-dom/client";

const toaster = await OverlayToaster.createAsync(toasterProps, {
// Use createRoot() instead of ReactDOM.render(). This can be deleted after
// a future Blueprint version uses createRoot() for Toasters by default.
domRenderer: (toaster, containerElement) => createRoot(containerElement).render(toaster),
});

toaster.show({ message: "Hello React 18!" })
```


@## React component usage

Render the `<OverlayToaster>` component like any other element and supply `<Toast>` elements as `children`. You can
Expand Down
Loading