From 864457df8eeac12edf3b0a583fc7d4c79490dd7a Mon Sep 17 00:00:00 2001 From: Brandon Cheng Date: Mon, 11 Dec 2023 16:44:12 -0500 Subject: [PATCH 1/8] Dynamically generate OverlayToaster.create test This allows a future commit to add a new spec for OverlayToaster.createAsync and reuse the tests written in this file. --- .../core/test/toast/overlayToasterTests.tsx | 343 ++++++++++-------- 1 file changed, 185 insertions(+), 158 deletions(-) diff --git a/packages/core/test/toast/overlayToasterTests.tsx b/packages/core/test/toast/overlayToasterTests.tsx index 116dacc7f8..ca15a1a1d8 100644 --- a/packages/core/test/toast/overlayToasterTests.tsx +++ b/packages/core/test/toast/overlayToasterTests.tsx @@ -22,9 +22,30 @@ import sinon, { spy } from "sinon"; import { expectPropValidationError } from "@blueprintjs/test-commons"; -import { Classes, OverlayToaster, type Toaster } from "../../src"; +import { Classes, OverlayToaster, type OverlayToasterProps, type Toaster } from "../../src"; import { TOASTER_CREATE_NULL, TOASTER_MAX_TOASTS_INVALID } from "../../src/common/errors"; +const SPECS = [ + { + cleanup: unmountReact16Toaster, + create: (props: OverlayToasterProps | undefined, containerElement: HTMLElement) => + OverlayToaster.create(props, containerElement), + name: "create", + }, +]; + +/** + * Dynamically run describe blocks. The helper function here reduces indentation + * width compared to inlining a for loop. + * + * https://mochajs.org/#dynamically-generating-tests + */ +function describeEach(specs: readonly T[], runner: (spec: T) => void) { + for (const spec of specs) { + describe(spec.name, () => runner(spec)); + } +} + /** * @param containerElement The container argument passed to OverlayToaster.create/OverlayToaster.createAsync */ @@ -40,191 +61,197 @@ describe("OverlayToaster", () => { let testsContainerElement: HTMLElement; let toaster: Toaster; - describe("with default props", () => { - before(() => { - testsContainerElement = document.createElement("div"); - document.documentElement.appendChild(testsContainerElement); - toaster = OverlayToaster.create({}, testsContainerElement); - }); + describeEach(SPECS, spec => { + describe("with default props", () => { + before(() => { + testsContainerElement = document.createElement("div"); + document.documentElement.appendChild(testsContainerElement); + toaster = spec.create({}, testsContainerElement); + }); - afterEach(() => { - toaster.clear(); - }); + afterEach(() => { + toaster.clear(); + }); - after(() => { - unmountReact16Toaster(testsContainerElement); - document.documentElement.removeChild(testsContainerElement); - }); + after(() => { + spec.cleanup(testsContainerElement); + document.documentElement.removeChild(testsContainerElement); + }); - it("does not attach toast container to body on script load", () => { - assert.lengthOf(document.getElementsByClassName(Classes.TOAST_CONTAINER), 0, "unexpected toast container"); - }); + it("does not attach toast container to body on script load", () => { + assert.lengthOf( + document.getElementsByClassName(Classes.TOAST_CONTAINER), + 0, + "unexpected toast container", + ); + }); - it("show() renders toast immediately", () => { - toaster.show({ - message: "Hello world", + it("show() renders toast immediately", () => { + toaster.show({ + message: "Hello world", + }); + assert.lengthOf(toaster.getToasts(), 1, "expected 1 toast"); + assert.isNotNull(document.querySelector(`.${Classes.TOAST_CONTAINER}.${Classes.OVERLAY_OPEN}`)); }); - assert.lengthOf(toaster.getToasts(), 1, "expected 1 toast"); - assert.isNotNull(document.querySelector(`.${Classes.TOAST_CONTAINER}.${Classes.OVERLAY_OPEN}`)); - }); - it("multiple show()s renders them all", () => { - toaster.show({ message: "one" }); - toaster.show({ message: "two" }); - toaster.show({ message: "six" }); - assert.lengthOf(toaster.getToasts(), 3, "expected 3 toasts"); - }); + it("multiple show()s renders them all", () => { + toaster.show({ message: "one" }); + toaster.show({ message: "two" }); + toaster.show({ message: "six" }); + assert.lengthOf(toaster.getToasts(), 3, "expected 3 toasts"); + }); - it("show() updates existing toast", () => { - const key = toaster.show({ message: "one" }); - assert.deepEqual(toaster.getToasts()[0].message, "one"); - toaster.show({ message: "two" }, key); - assert.lengthOf(toaster.getToasts(), 1, "expected 1 toast"); - assert.deepEqual(toaster.getToasts()[0].message, "two"); - }); + it("show() updates existing toast", () => { + const key = toaster.show({ message: "one" }); + assert.deepEqual(toaster.getToasts()[0].message, "one"); + toaster.show({ message: "two" }, key); + assert.lengthOf(toaster.getToasts(), 1, "expected 1 toast"); + assert.deepEqual(toaster.getToasts()[0].message, "two"); + }); - it("dismiss() removes just the toast in question", () => { - toaster.show({ message: "one" }); - const key = toaster.show({ message: "two" }); - toaster.show({ message: "six" }); - toaster.dismiss(key); - assert.deepEqual( - toaster.getToasts().map(t => t.message), - ["six", "one"], - ); - }); + it("dismiss() removes just the toast in question", () => { + toaster.show({ message: "one" }); + const key = toaster.show({ message: "two" }); + toaster.show({ message: "six" }); + toaster.dismiss(key); + assert.deepEqual( + toaster.getToasts().map(t => t.message), + ["six", "one"], + ); + }); - it("clear() removes all toasts", () => { - toaster.show({ message: "one" }); - toaster.show({ message: "two" }); - toaster.show({ message: "six" }); - assert.lengthOf(toaster.getToasts(), 3, "expected 3 toasts"); - toaster.clear(); - assert.lengthOf(toaster.getToasts(), 0, "expected 0 toasts"); - }); + it("clear() removes all toasts", () => { + toaster.show({ message: "one" }); + toaster.show({ message: "two" }); + toaster.show({ message: "six" }); + assert.lengthOf(toaster.getToasts(), 3, "expected 3 toasts"); + toaster.clear(); + assert.lengthOf(toaster.getToasts(), 0, "expected 0 toasts"); + }); - it("action onClick callback invoked when action clicked", () => { - const onClick = spy(); - toaster.show({ - action: { onClick, text: "action" }, - message: "message", - timeout: 0, - }); - // action is first descendant button - const action = document.querySelector(`.${Classes.TOAST} .${Classes.BUTTON}`); - action?.click(); - assert.isTrue(onClick.calledOnce, "expected onClick to be called once"); - }); + it("action onClick callback invoked when action clicked", () => { + const onClick = spy(); + toaster.show({ + action: { onClick, text: "action" }, + message: "message", + timeout: 0, + }); + // action is first descendant button + const action = document.querySelector(`.${Classes.TOAST} .${Classes.BUTTON}`); + action?.click(); + assert.isTrue(onClick.calledOnce, "expected onClick to be called once"); + }); - it("onDismiss callback invoked when close button clicked", () => { - const handleDismiss = spy(); - toaster.show({ - message: "dismiss", - onDismiss: handleDismiss, - timeout: 0, - }); - // without action, dismiss is first descendant button - const dismiss = document.querySelector(`.${Classes.TOAST} .${Classes.BUTTON}`); - dismiss?.click(); - assert.isTrue(handleDismiss.calledOnce); - }); + it("onDismiss callback invoked when close button clicked", () => { + const handleDismiss = spy(); + toaster.show({ + message: "dismiss", + onDismiss: handleDismiss, + timeout: 0, + }); + // without action, dismiss is first descendant button + const dismiss = document.querySelector(`.${Classes.TOAST} .${Classes.BUTTON}`); + dismiss?.click(); + assert.isTrue(handleDismiss.calledOnce); + }); - it("onDismiss callback invoked on toaster.dismiss()", () => { - const onDismiss = spy(); - const key = toaster.show({ message: "dismiss me", onDismiss }); - toaster.dismiss(key); - assert.isTrue(onDismiss.calledOnce, "onDismiss not called"); - }); + it("onDismiss callback invoked on toaster.dismiss()", () => { + const onDismiss = spy(); + const key = toaster.show({ message: "dismiss me", onDismiss }); + toaster.dismiss(key); + assert.isTrue(onDismiss.calledOnce, "onDismiss not called"); + }); - it("onDismiss callback invoked on toaster.clear()", () => { - const onDismiss = spy(); - toaster.show({ message: "dismiss me", onDismiss }); - toaster.clear(); - assert.isTrue(onDismiss.calledOnce, "onDismiss not called"); - }); + it("onDismiss callback invoked on toaster.clear()", () => { + const onDismiss = spy(); + toaster.show({ message: "dismiss me", onDismiss }); + toaster.clear(); + assert.isTrue(onDismiss.calledOnce, "onDismiss not called"); + }); - it("reusing props object does not produce React errors", () => { - const errorSpy = spy(console, "error"); - try { - // if Toaster doesn't clone the props object before injecting key then there will be a - // React error that both toasts have the same key, because both instances refer to the - // same object. - const toast = { message: "repeat" }; - toaster.show(toast); - toaster.show(toast); - assert.isFalse(errorSpy.calledWithMatch("two children with the same key"), "mutation side effect!"); - } finally { - // Restore console.error. Otherwise other tests will fail - // with "TypeError: Attempted to wrap error which is already - // wrapped" when attempting to spy on console.error again. - sinon.restore(); - } + it("reusing props object does not produce React errors", () => { + const errorSpy = spy(console, "error"); + try { + // if Toaster doesn't clone the props object before injecting key then there will be a + // React error that both toasts have the same key, because both instances refer to the + // same object. + const toast = { message: "repeat" }; + toaster.show(toast); + toaster.show(toast); + assert.isFalse(errorSpy.calledWithMatch("two children with the same key"), "mutation side effect!"); + } finally { + // Restore console.error. Otherwise other tests will fail + // with "TypeError: Attempted to wrap error which is already + // wrapped" when attempting to spy on console.error again. + sinon.restore(); + } + }); }); - }); - describe("with maxToasts set to finite value", () => { - before(() => { - testsContainerElement = document.createElement("div"); - document.documentElement.appendChild(testsContainerElement); - toaster = OverlayToaster.create({ maxToasts: 3 }, testsContainerElement); - }); + describe("with maxToasts set to finite value", () => { + before(() => { + testsContainerElement = document.createElement("div"); + document.documentElement.appendChild(testsContainerElement); + toaster = spec.create({ maxToasts: 3 }, testsContainerElement); + }); - after(() => { - unmountReact16Toaster(testsContainerElement); - document.documentElement.removeChild(testsContainerElement); - }); + after(() => { + unmountReact16Toaster(testsContainerElement); + document.documentElement.removeChild(testsContainerElement); + }); - it("does not exceed the maximum toast limit set", () => { - toaster.show({ message: "one" }); - toaster.show({ message: "two" }); - toaster.show({ message: "three" }); - toaster.show({ message: "oh no" }); - assert.lengthOf(toaster.getToasts(), 3, "expected 3 toasts"); + it("does not exceed the maximum toast limit set", () => { + toaster.show({ message: "one" }); + toaster.show({ message: "two" }); + toaster.show({ message: "three" }); + toaster.show({ message: "oh no" }); + assert.lengthOf(toaster.getToasts(), 3, "expected 3 toasts"); + }); }); - }); - describe("with autoFocus set to true", () => { - before(() => { - testsContainerElement = document.createElement("div"); - document.documentElement.appendChild(testsContainerElement); - toaster = OverlayToaster.create({ autoFocus: true }, testsContainerElement); - }); + describe("with autoFocus set to true", () => { + before(() => { + testsContainerElement = document.createElement("div"); + document.documentElement.appendChild(testsContainerElement); + toaster = spec.create({ autoFocus: true }, testsContainerElement); + }); - after(() => { - unmountReact16Toaster(testsContainerElement); - document.documentElement.removeChild(testsContainerElement); - }); + after(() => { + spec.cleanup(testsContainerElement); + document.documentElement.removeChild(testsContainerElement); + }); - it("focuses inside toast container", done => { - toaster.show({ message: "focus near me" }); - // small explicit timeout reduces flakiness of these tests - setTimeout(() => { - const toastElement = testsContainerElement.querySelector(`.${Classes.TOAST_CONTAINER}`); - assert.isTrue(toastElement?.contains(document.activeElement)); - done(); - }, 100); + it("focuses inside toast container", done => { + toaster.show({ message: "focus near me" }); + // small explicit timeout reduces flakiness of these tests + setTimeout(() => { + const toastElement = testsContainerElement.querySelector(`.${Classes.TOAST_CONTAINER}`); + assert.isTrue(toastElement?.contains(document.activeElement)); + done(); + }, 100); + }); }); - }); - it("throws an error if used within a React lifecycle method", () => { - testsContainerElement = document.createElement("div"); + it("throws an error if used within a React lifecycle method", () => { + testsContainerElement = document.createElement("div"); - class LifecycleToaster extends React.Component { - public render() { - return React.createElement("div"); - } + class LifecycleToaster extends React.Component { + public render() { + return React.createElement("div"); + } - public componentDidMount() { - try { - OverlayToaster.create({}, testsContainerElement); - } catch (err: any) { - assert.equal(err.message, TOASTER_CREATE_NULL); - } finally { - unmountReact16Toaster(testsContainerElement); + public componentDidMount() { + try { + spec.create({}, testsContainerElement); + } catch (err: any) { + assert.equal(err.message, TOASTER_CREATE_NULL); + } finally { + spec.cleanup(testsContainerElement); + } } } - } - mount(React.createElement(LifecycleToaster)); + mount(React.createElement(LifecycleToaster)); + }); }); describe("validation", () => { From d59db13289041316f32f5d31a81568db916983d0 Mon Sep 17 00:00:00 2001 From: Brandon Cheng Date: Mon, 11 Dec 2023 12:47:22 -0500 Subject: [PATCH 2/8] Add OverlayToaster.createAsync method to support React 18 --- packages/core/src/common/errors.ts | 4 + packages/core/src/components/index.ts | 2 +- .../src/components/toast/overlayToaster.tsx | 76 ++++++++++++++++++- packages/core/src/components/toast/toast.md | 9 ++- 4 files changed, 88 insertions(+), 3 deletions(-) diff --git a/packages/core/src/common/errors.ts b/packages/core/src/common/errors.ts index d4811381c3..3afdf489a8 100644 --- a/packages/core/src/common/errors.ts +++ b/packages/core/src/common/errors.ts @@ -93,6 +93,10 @@ 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.`; +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.`; export const TOASTER_MAX_TOASTS_INVALID = ns + ` maxToasts is set to an invalid number, must be greater than 0`; export const TOASTER_WARN_INLINE = diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 0407981ce1..6227de2394 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -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"; diff --git a/packages/core/src/components/toast/overlayToaster.tsx b/packages/core/src/components/toast/overlayToaster.tsx index 69ddeae3d5..fa87512303 100644 --- a/packages/core/src/components/toast/overlayToaster.tsx +++ b/packages/core/src/components/toast/overlayToaster.tsx @@ -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"; @@ -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, containerElement: HTMLElement) => void; +} + /** * OverlayToaster component. * @@ -66,6 +91,55 @@ export class OverlayToaster extends AbstractPureComponent { + 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((resolve, reject) => { + try { + domRenderer(, toasterComponentRoot); + } catch (error) { + // Note that we're catching errors from the domRenderer function + // call, but not errors when rendering , 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: [], }; diff --git a/packages/core/src/components/toast/toast.md b/packages/core/src/components/toast/toast.md index 1cff053630..816bd2168c 100644 --- a/packages/core/src/components/toast/toast.md +++ b/packages/core/src/components/toast/toast.md @@ -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: + ```ts + const myToaster: Toaster = await OverlayToaster.createAsync({ position: "bottom" }); + myToaster.show({ ...toastOptions }); + ``` + + 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 }); From 220096e56b3888f676f69c1fd2873e2af31d88cc Mon Sep 17 00:00:00 2001 From: Brandon Cheng Date: Mon, 11 Dec 2023 17:07:23 -0500 Subject: [PATCH 3/8] Add OverlayToaster.createAsync test --- .../core/test/toast/overlayToasterTests.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/core/test/toast/overlayToasterTests.tsx b/packages/core/test/toast/overlayToasterTests.tsx index ca15a1a1d8..f8b8f1e7e9 100644 --- a/packages/core/test/toast/overlayToasterTests.tsx +++ b/packages/core/test/toast/overlayToasterTests.tsx @@ -32,6 +32,12 @@ const SPECS = [ OverlayToaster.create(props, containerElement), name: "create", }, + { + cleanup: unmountReact16Toaster, + create: (props: OverlayToasterProps | undefined, containerElement: HTMLElement) => + OverlayToaster.createAsync(props, { container: containerElement }), + name: "createAsync", + }, ]; /** @@ -63,10 +69,10 @@ describe("OverlayToaster", () => { describeEach(SPECS, spec => { describe("with default props", () => { - before(() => { + before(async () => { testsContainerElement = document.createElement("div"); document.documentElement.appendChild(testsContainerElement); - toaster = spec.create({}, testsContainerElement); + toaster = await spec.create({}, testsContainerElement); }); afterEach(() => { @@ -189,10 +195,10 @@ describe("OverlayToaster", () => { }); describe("with maxToasts set to finite value", () => { - before(() => { + before(async () => { testsContainerElement = document.createElement("div"); document.documentElement.appendChild(testsContainerElement); - toaster = spec.create({ maxToasts: 3 }, testsContainerElement); + toaster = await spec.create({ maxToasts: 3 }, testsContainerElement); }); after(() => { @@ -210,10 +216,10 @@ describe("OverlayToaster", () => { }); describe("with autoFocus set to true", () => { - before(() => { + before(async () => { testsContainerElement = document.createElement("div"); document.documentElement.appendChild(testsContainerElement); - toaster = spec.create({ autoFocus: true }, testsContainerElement); + toaster = await spec.create({ autoFocus: true }, testsContainerElement); }); after(() => { From 9bdf637917c0df5e9fbec5b00d8f2f4760d152ef Mon Sep 17 00:00:00 2001 From: Brandon Cheng Date: Fri, 15 Dec 2023 16:10:09 -0500 Subject: [PATCH 4/8] Add OverlayToaster.createAsync interactive example --- packages/core/src/components/toast/toast.md | 4 + .../src/examples/core-examples/index.ts | 1 + .../core-examples/toastCreateAsyncExample.tsx | 101 ++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 packages/docs-app/src/examples/core-examples/toastCreateAsyncExample.tsx diff --git a/packages/core/src/components/toast/toast.md b/packages/core/src/components/toast/toast.md index 816bd2168c..85fb54a95b 100644 --- a/packages/core/src/components/toast/toast.md +++ b/packages/core/src/components/toast/toast.md @@ -143,6 +143,10 @@ export class App extends React.PureComponent { } ``` +The example below uses the `OverlayToaster.createAsync()` static method. Clicking the button will create a new toaster mounted to ``, show a message, and unmount the toaster from the DOM once the message is dismissed. + +@reactExample ToastCreateAsyncExample + @## React component usage Render the `` component like any other element and supply `` elements as `children`. You can diff --git a/packages/docs-app/src/examples/core-examples/index.ts b/packages/docs-app/src/examples/core-examples/index.ts index 1aa2e9fa85..46a1b32ef4 100644 --- a/packages/docs-app/src/examples/core-examples/index.ts +++ b/packages/docs-app/src/examples/core-examples/index.ts @@ -80,6 +80,7 @@ export * from "./tabsExample"; export * from "./inputGroupExample"; export { SearchInputExample } from "./searchInputExample"; export * from "./tagExample"; +export * from "./toastCreateAsyncExample"; export * from "./toastExample"; export * from "./tooltipExample"; export * from "./treeExample"; diff --git a/packages/docs-app/src/examples/core-examples/toastCreateAsyncExample.tsx b/packages/docs-app/src/examples/core-examples/toastCreateAsyncExample.tsx new file mode 100644 index 0000000000..44ac3d6973 --- /dev/null +++ b/packages/docs-app/src/examples/core-examples/toastCreateAsyncExample.tsx @@ -0,0 +1,101 @@ +/* + * Copyright 2023 Palantir Technologies, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from "react"; +import ReactDOM from "react-dom"; + +import { Button, Intent, OverlayToaster } from "@blueprintjs/core"; +import { Example } from "@blueprintjs/docs-theme"; + +// This example adapts the docs example slightly: +// https://blueprintjs.com/docs/#core/components/toast.example +// +// Instead of a singleton toaster, the Toaster is only created when the user +// clicks the button. This avoids creating a singleton Toaster for the entire +// Blueprint docs app. +export function ToastCreateAsyncExample() { + const [isToastShown, setIsToastShown] = React.useState(false); + + const handleClick = React.useCallback(async () => { + setIsToastShown(true); + try { + await showMessageFromNewToaster(); + } finally { + setIsToastShown(false); + } + }, []); + + return ( + + + + ); +} + +/** + * Create a new OverlayToaster and show a message. The return promise will + * resolve when the message has been dismissed. + */ +async function showMessageFromNewToaster() { + const container = document.createElement("div"); + // Since this toaster isn't created in a portal, a fixed position container + // is required for it to show at the top of the viewport. Otherwise the + // toaster won't be visible until the user scrolls upward. + container.style.position = "fixed"; + container.style.top = "0"; + container.style.width = "100%"; + + document.body.appendChild(container); + + return new Promise(async resolve => { + async function onDismiss() { + resolve(); + + // Wait for the message to fade out before completely unmounting the OverlayToaster. + await sleep(1_000); + + unmountReact16Toaster(container); + document.body.removeChild(container); + } + + const toaster = await OverlayToaster.createAsync({}, { container }); + toaster.show({ message: "Toasted", intent: Intent.PRIMARY, onDismiss }); + }); +} + +/** + * @param containerElement The container argument passed to OverlayToaster.create/OverlayToaster.createAsync + */ +function unmountReact16Toaster(containerElement: HTMLElement) { + const toasterRenderRoot = containerElement.firstElementChild; + if (toasterRenderRoot == null) { + throw new Error("No elements were found under Toaster container."); + } + ReactDOM.unmountComponentAtNode(toasterRenderRoot); +} + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} From 4779ba20a8115ac9cea8cc19c49cba263e74b1ea Mon Sep 17 00:00:00 2001 From: Brandon Cheng Date: Fri, 15 Dec 2023 16:13:18 -0500 Subject: [PATCH 5/8] Add link to OverlayToaster examples --- packages/core/src/common/errors.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/common/errors.ts b/packages/core/src/common/errors.ts index 3afdf489a8..bcb88f16c7 100644 --- a/packages/core/src/common/errors.ts +++ b/packages/core/src/common/errors.ts @@ -92,11 +92,12 @@ export const SPINNER_WARN_CLASSES_SIZE = ns + ` 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.`; + `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 + ` maxToasts is set to an invalid number, must be greater than 0`; export const TOASTER_WARN_INLINE = From 9b1fb9334162d6726ed0403e676de0f14afd398a Mon Sep 17 00:00:00 2001 From: Brandon Cheng Date: Fri, 15 Dec 2023 17:22:44 -0500 Subject: [PATCH 6/8] Change doc mentions of OverlayToaster.create to createAsync --- packages/core/src/components/toast/toast.md | 31 +++++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/core/src/components/toast/toast.md b/packages/core/src/components/toast/toast.md index 85fb54a95b..e8a3c13b03 100644 --- a/packages/core/src/components/toast/toast.md +++ b/packages/core/src/components/toast/toast.md @@ -86,21 +86,34 @@ 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 ``. 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; ``` +@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 `` 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`, 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. + +```ts +function synchronousFn() { + const toasterPromise = OverlayToaster.createAsync({}); + toasterPromise.then(toaster => toaster.show({ message: "Toast!" })); +} +``` -Note that `OverlayToaster.create()` will throw an error if invoked inside a component lifecycle method, as +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. @interface Toaster @@ -117,7 +130,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, }); @@ -135,10 +148,10 @@ export class App extends React.PureComponent { return