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

Conversation

gluxon
Copy link
Contributor

@gluxon gluxon commented Dec 11, 2023

PR Stack

Checklist

  • Includes tests
  • Update documentation

Changes

Fixes #6239. A new OverlayToaster.createAsync() utility is added alongside OverlayToaster.create().

This asynchronous function allows a custom domRenderer utility to be passed to better support codebases on React 18.

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

const toaster = await OverlayToaster.createAsync(/* props */ {}, {
  domRenderer: (toaster, containerElement) => createRoot(containerElement).render(toaster),
});

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

The existing OverlayToaster.create() method uses ReactDOM.render without any ability to customize this call, which results in a console warning when upgrading to React 18.

Screenshot 2023-12-11 at 5 38 23 PM

Why is the new API asynchronous?

The change to Blueprint's API reflects a change in React's API. The new createRoot function from react-dom/client no longer renders components synchronously.

import * as React from "react";
import { createRoot } from "react-dom/client";

const toaster = React.createRef<OverlayToaster>();

createRoot(containerElement)
  .render(<OverlayToaster {...props} ref={toaster} usePortal={false} />)
  
// OverlayToaster render function is not yet executed.

// ‼️ This is null ‼️
ref.current

setTimeout(() => {
  // Okay, now the OverlayToaster render() function has ran.
  ref.current // now available
}, 0)

This is different than ReactDOM.render, which does synchronously populate the ref.

import * as React from "react";
import * as ReactDOM from "react-dom";

const toaster = React.createRef<OverlayToaster>();

ReactDOM.render(
  <OverlayToaster {...props} ref={toaster} usePortal={false} />,
  containerElement
);

// OverlayToaster render function has ran by this point.

ref.current // instance of <OverlayToaster />

This seems to be an intentional change in React 18. See the “What about the render callback?” section under Replacing render with createRoot.

Why do I need to pass in a custom domRenderer value for React 18?

The createRoot function is an import on react-dom/client. Blueprint is an NPM library that needs to support multiple bundlers (e.g. Webpack, Vite). Most bundlers will fail if it encounters a non-existent submodule import. Since the current major version of Blueprint needs to support React 16 to 18, imports into 18-specific code paths can't be made directly in Blueprint.

As a workaround, consumers of Blueprint can provide the createRoot function to Blueprint. This is an application of dependency injection.

When Blueprint drops support for React 16 in a future major version, the domRenderer option will change its current default from ReactDOM.render to a function using the new createRoot API. This breaking change will make OverlayToaster.createAsync easier to use in the future.

How do I migrate?

The most one-to-one conversion would be from:

OverlayToaster.create().show({ message: "Hello!" });

to:

OverlayToaster.createAsync().then(toaster => toaster.show({ message: "Hello!" }));

You may notice:

  1. We're swallowing errors that happen when rendering the component. There's no rejection handler on the promise object.
  2. The OverlayToaster component is never unmounted or removed from the DOM after the message is dismissed.

Both of these are true of the original synchronous OverlayToaster.create() API. The new API makes these existing problems more apparent. We recommend mounting the toaster directly to your React application tree if the first problem is a concern, and sharing the Toaster to avoid the second problem.

Reviewers should focus on:

  • Whether the new API makes sense.
  • Reviewing each commit individually should be easier than reviewing all changes at once. Hiding whitespace only changes should also make the diff easier to look at since a few tests were nested.
  • Several refactors had to be made to the OverlayToaster tests to make them reusable for both OverlayToaster.create and OverlayToaster.createAsync.
  • I briefly spent some time attempting to add a React 18 dependency for testing, but hit issues with NPM aliases discovering @types/react-dom@18. I can take another stab if we think a React 18 test is valuable to commit, but it's a bit hard to have multiple version of React in the same codebase, even for testing.

@gluxon gluxon changed the title Add OverlayToaster.createAsync method to support React 18 Add OverlayToaster.createAsync method to support React 18 Dec 11, 2023
@gluxon gluxon force-pushed the OverlayToaster-createAsync branch 3 times, most recently from fcf7105 to cbe7102 Compare December 11, 2023 23:20

afterEach(() => {
toaster.clear();
ReactDOM.unmountComponentAtNode(testsContainerElement);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We were previously calling ReactDOM.unmountComponentAtNode on the wrong DOM element. The OverlayToaster.create() method creates a new <div> within testsContainerElement to render on.

toaster = spec.create({ maxToasts: 3 }, testsContainerElement);
});

after(() => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Without the additional cleanup in a few tests that call OverlayToaster.create() directly (instead of using the existing Toaster instance), I was seeing the "does not attach toast container to body on script load" test fail.

@@ -30,7 +30,14 @@ 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

@gluxon gluxon marked this pull request as ready for review December 11, 2023 23:22
@adidahiya
Copy link
Contributor

Add OverlayToaster.createAsync test

Build artifact links for this commit: documentation | landing | table | demo

This is an automated comment from the deploy-preview CircleCI job.

@gluxon gluxon changed the base branch from develop to OverlayToaster-test-cleanup December 12, 2023 17:04
@adidahiya
Copy link
Contributor

Add OverlayToaster.createAsync test

Build artifact links for this commit: documentation | landing | table | demo

This is an automated comment from the deploy-preview CircleCI job.

Base automatically changed from OverlayToaster-test-cleanup to develop December 12, 2023 18:45
@adidahiya adidahiya self-requested a review December 12, 2023 18:46
@adidahiya
Copy link
Contributor

Add OverlayToaster.createAsync test

Build artifact links for this commit: documentation | landing | table | demo

This is an automated comment from the deploy-preview CircleCI job.

Copy link
Contributor

@adidahiya adidahiya left a comment

Choose a reason for hiding this comment

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

thanks for improving the test suite, nice to see the amount of code re-use there 👍🏽

code changes & behavior look good, I'm requesting some docs changes in this review

would you mind adding a new (small) docs example using OverlayToaster.createAsync() which gets shown in this part of the docs? it doesn't need to have options:

image

this will help us to have a quick & easy way to test the imperative API interactively in the docs preview. Currently, toastExample.tsx only uses the declarative API.

@@ -30,7 +30,14 @@ 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

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?

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.`;
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: which documentation site? Blueprint or React? Might be useful to just add a link

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agree with it being useful to just add a link. This message was copied from the TOASTER_CREATE_NULL. I'll update the original message as well. 4779ba2

@gluxon
Copy link
Contributor Author

gluxon commented Dec 15, 2023

Thanks for all the feedback! Agree that the docs need to have some improvements. I'm still working through a few more clarifications and will move the PR to draft mode until I wrap those up.

@gluxon gluxon marked this pull request as draft December 15, 2023 22:54
@adidahiya
Copy link
Contributor

Move "React component usage example" further up

Build artifact links for this commit: documentation | landing | table | demo

This is an automated comment from the deploy-preview CircleCI job.

Copy link
Contributor Author

@gluxon gluxon left a comment

Choose a reason for hiding this comment

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

would you mind adding a new (small) docs example using OverlayToaster.createAsync() which gets shown in this part of the docs? it doesn't need to have options

Can do! Done in commit 9bdf637.

I ended up moving this to the Example section since the code snippets were very similar to what the interactive example implements. If that's not a good place, I can move it back to where you suggested.

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.`;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agree with it being useful to just add a link. This message was copied from the TOASTER_CREATE_NULL. I'll update the original message as well. 4779ba2

@@ -30,7 +30,14 @@ 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

@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

@gluxon
Copy link
Contributor Author

gluxon commented Dec 18, 2023

This should be ready for another review after the holidays. I wanted to finish my in-progress changes before I lost context over the next few weeks. Please feel free to ignore this until next year. Thanks!

@gluxon gluxon marked this pull request as ready for review December 18, 2023 22:13
@adidahiya
Copy link
Contributor

Add React 18 domRenderer example

Build artifact links for this commit: documentation | landing | table | demo

This is an automated comment from the deploy-preview CircleCI job.

Copy link
Contributor

@adidahiya adidahiya left a comment

Choose a reason for hiding this comment

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

new example works well in docs preview

2023-12-20 12 12 01

Comment on lines +121 to +132
<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>
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!

@adidahiya adidahiya merged commit 13a25f3 into develop Dec 20, 2023
12 checks passed
@adidahiya adidahiya deleted the OverlayToaster-createAsync branch December 20, 2023 17:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Update OverlayToaster component to use ReactDOM.createRoot() for React 18 compatibility
2 participants