From 4f2c8af1c24e6586684b339739d906521604f038 Mon Sep 17 00:00:00 2001
From: Cheton Wu <447801+cheton@users.noreply.github.com>
Date: Sun, 15 Oct 2023 20:47:12 +0800
Subject: [PATCH] feat: update `ToastManager` and `PortalManager` to optimize
rendering and reduce unnecessary re-renders (#802)
* feat: memoize ToastManager context to avoid unnecessary re-renders
* feat: refine `usePortalManager` and `useToastManager` Hooks to avoid unnecessary re-renders
* docs: refine usePortalManager example
* docs: update portal component examples
* feat: update usePortalManager and useToastManager by eliminating the need for a return value in object assignment
* test: separate into Portal.test.js and PortalManager.test.js
* docs: update useToastManager example
---
.../portal-manager/usePortalManager.js | 60 ++++++++++
.../portal-manager/usePortalManager.page.mdx | 47 +-------
.../pages/components/portal.page.mdx | 105 ------------------
.../components/portal/custom-container.js | 31 ++++++
.../pages/components/portal/index.page.mdx | 39 +++++++
.../pages/components/portal/nested-portals.js | 33 ++++++
.../pages/components/portal/portal.js | 31 ++++++
.../toast-manager/useToastManager.js | 48 ++++++++
.../toast-manager/useToastManager.page.mdx | 40 +------
.../react/src/portal/__tests__/Portal.test.js | 97 +---------------
.../portal/__tests__/PortalManager.test.js | 99 +++++++++++++++++
packages/react/src/portal/usePortalManager.js | 20 ++--
packages/react/src/toast/ToastManager.js | 49 ++++----
packages/react/src/toast/useToastManager.js | 20 ++--
14 files changed, 395 insertions(+), 324 deletions(-)
create mode 100644 packages/react-docs/pages/components/portal-manager/usePortalManager.js
delete mode 100644 packages/react-docs/pages/components/portal.page.mdx
create mode 100644 packages/react-docs/pages/components/portal/custom-container.js
create mode 100644 packages/react-docs/pages/components/portal/index.page.mdx
create mode 100644 packages/react-docs/pages/components/portal/nested-portals.js
create mode 100644 packages/react-docs/pages/components/portal/portal.js
create mode 100644 packages/react-docs/pages/components/toast-manager/useToastManager.js
create mode 100644 packages/react/src/portal/__tests__/PortalManager.test.js
diff --git a/packages/react-docs/pages/components/portal-manager/usePortalManager.js b/packages/react-docs/pages/components/portal-manager/usePortalManager.js
new file mode 100644
index 0000000000..4d8c17fd7d
--- /dev/null
+++ b/packages/react-docs/pages/components/portal-manager/usePortalManager.js
@@ -0,0 +1,60 @@
+import {
+ Button,
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalOverlay,
+ usePortalManager,
+} from '@tonic-ui/react';
+import React, { forwardRef, useCallback } from 'react';
+
+const MyModal = forwardRef((
+ {
+ onClose,
+ ...rest
+ },
+ ref,
+) => (
+
+
+
+
+ Modal Header
+
+
+ Modal Body
+
+
+
+
+
+
+));
+
+MyModal.displayName = 'MyModal';
+
+const App = () => {
+ const portal = usePortalManager();
+ const openModal = useCallback(() => {
+ portal((close) => (
+
+ ));
+ }, [portal]);
+
+ return (
+
+ );
+};
+
+export default App;
diff --git a/packages/react-docs/pages/components/portal-manager/usePortalManager.page.mdx b/packages/react-docs/pages/components/portal-manager/usePortalManager.page.mdx
index 3d90232037..6cf054d13f 100644
--- a/packages/react-docs/pages/components/portal-manager/usePortalManager.page.mdx
+++ b/packages/react-docs/pages/components/portal-manager/usePortalManager.page.mdx
@@ -62,49 +62,4 @@ The `remove` method removes a portal by its id.
Here is an example of how to use `usePortalManager` to create and remove a modal:
-```jsx noInline
-render(() => {
- const portal = usePortalManager();
- const openModal = React.useCallback(() => {
- portal((close) => (
-
- ));
- }, [portal]);
-
- return (
-
- );
-});
-
-const MyModal = React.forwardRef((
- {
- onClose,
- ...rest
- },
- ref,
-) => (
-
-
-
-
- Modal Header
-
-
- Modal Body
-
-
-
-
-
-
-));
-```
+{render('./usePortalManager')}
diff --git a/packages/react-docs/pages/components/portal.page.mdx b/packages/react-docs/pages/components/portal.page.mdx
deleted file mode 100644
index cf74f62244..0000000000
--- a/packages/react-docs/pages/components/portal.page.mdx
+++ /dev/null
@@ -1,105 +0,0 @@
-# Portal
-
-A Portal is a declarative component that allows you to render children into a DOM node that exists outside the DOM hierarchy of the parent component. This is useful for rendering elements, such as drawers, modals, or popovers, above other elements in the document.
-
-## Import
-
-```js
-import {
- Portal,
-} from '@tonic-ui/react';
-```
-
-## Usage
-
-To use the `Portal` component, you can wrap any element or component within it, and it will be rendered at the end of the document body.
-
-```jsx
-function Example() {
- const [colorMode] = useColorMode();
- const [colorStyle] = useColorStyle({ colorMode });
-
- return (
- <>
-
-
- {/* Open developer tool to inspect elements inside the body tag */}
-
- Portal - This is transported to the end of the document body
-
-
-
-
- I'm the container
-
- >
- );
-}
-```
-
-### Using a custom container
-
-You can also specify a custom container for the portal to be rendered in by passing the `containerRef` prop and its value to the `ref` of the container element.
-
-```jsx
-function Example() {
- const ref = React.useRef();
- const [colorMode] = useColorMode();
- const [colorStyle] = useColorStyle({ colorMode });
-
- return (
- <>
-
-
- Portal - This is transported to the container
-
-
-
-
- I'm the container
-
-
- >
- );
-}
-```
-
-### Nested portals
-
-It is also possible to nest portals inside a parent portal. To do this, pass `appendToParentPortal={true}` to the nested portal. This will append the children of the nested portal to the container of the parent portal.
-
-```jsx
-function Example() {
- const ref = React.useRef();
- const [colorMode] = useColorMode();
- const [colorStyle] = useColorStyle({ colorMode });
-
- return (
- <>
-
-
- Parent portal - This is transported to the container
-
-
- Child portal - This is attached to its parent portal
-
-
-
-
-
- I'm the container
-
- >
- );
-}
-```
-
-## Props
-
-### Portal
-
-| Name | Type | Default | Description |
-| :--- | :--- | :------ | :---------- |
-| appendToParentPortal | boolean | false | If `true`, the portal will check if it is within a parent portal and append itself to the parent's portal node. |
-| children | ReactNode | | |
-| containerRef | RefObject | | The `ref` to the component where the portal will be rendered. |
diff --git a/packages/react-docs/pages/components/portal/custom-container.js b/packages/react-docs/pages/components/portal/custom-container.js
new file mode 100644
index 0000000000..0a7a581c73
--- /dev/null
+++ b/packages/react-docs/pages/components/portal/custom-container.js
@@ -0,0 +1,31 @@
+import {
+ Box,
+ Flex,
+ Portal,
+ useColorMode,
+ useColorStyle,
+} from '@tonic-ui/react';
+import React, { useRef } from 'react';
+
+const App = () => {
+ const ref = useRef();
+ const [colorMode] = useColorMode();
+ const [colorStyle] = useColorStyle({ colorMode });
+
+ return (
+ <>
+
+
+ Portal - This is transported to the container
+
+
+
+
+ I am the container
+
+
+ >
+ );
+};
+
+export default App;
diff --git a/packages/react-docs/pages/components/portal/index.page.mdx b/packages/react-docs/pages/components/portal/index.page.mdx
new file mode 100644
index 0000000000..ed9d311f7b
--- /dev/null
+++ b/packages/react-docs/pages/components/portal/index.page.mdx
@@ -0,0 +1,39 @@
+# Portal
+
+A Portal is a declarative component that allows you to render children into a DOM node that exists outside the DOM hierarchy of the parent component. This is useful for rendering elements, such as drawers, modals, or popovers, above other elements in the document.
+
+## Import
+
+```js
+import {
+ Portal,
+} from '@tonic-ui/react';
+```
+
+## Usage
+
+To use the `Portal` component, you can wrap any element or component within it, and it will be rendered at the end of the document body.
+
+{render('./portal')}
+
+### Using a custom container
+
+You can also specify a custom container for the portal to be rendered in by passing the `containerRef` prop and its value to the `ref` of the container element.
+
+{render('./custom-container')}
+
+### Nested portals
+
+It is also possible to nest portals inside a parent portal. To do this, pass `appendToParentPortal={true}` to the nested portal. This will append the children of the nested portal to the container of the parent portal.
+
+{render('./nested-portals')}
+
+## Props
+
+### Portal
+
+| Name | Type | Default | Description |
+| :--- | :--- | :------ | :---------- |
+| appendToParentPortal | boolean | false | If `true`, the portal will check if it is within a parent portal and append itself to the parent's portal node. |
+| children | ReactNode | | |
+| containerRef | RefObject | | The `ref` to the component where the portal will be rendered. |
diff --git a/packages/react-docs/pages/components/portal/nested-portals.js b/packages/react-docs/pages/components/portal/nested-portals.js
new file mode 100644
index 0000000000..e76e3e31c4
--- /dev/null
+++ b/packages/react-docs/pages/components/portal/nested-portals.js
@@ -0,0 +1,33 @@
+import {
+ Box,
+ Portal,
+ useColorMode,
+ useColorStyle,
+} from '@tonic-ui/react';
+import React, { useRef } from 'react';
+
+const App = () => {
+ const ref = useRef();
+ const [colorMode] = useColorMode();
+ const [colorStyle] = useColorStyle({ colorMode });
+
+ return (
+ <>
+
+
+ Parent portal - This is transported to the container
+
+
+ Child portal - This is attached to its parent portal
+
+
+
+
+
+ I am the container
+
+ >
+ );
+};
+
+export default App;
diff --git a/packages/react-docs/pages/components/portal/portal.js b/packages/react-docs/pages/components/portal/portal.js
new file mode 100644
index 0000000000..2a95d1b4c8
--- /dev/null
+++ b/packages/react-docs/pages/components/portal/portal.js
@@ -0,0 +1,31 @@
+import {
+ Box,
+ Portal,
+ VisuallyHidden,
+ useColorMode,
+ useColorStyle,
+} from '@tonic-ui/react';
+import React from 'react';
+
+const App = () => {
+ const [colorMode] = useColorMode();
+ const [colorStyle] = useColorStyle({ colorMode });
+
+ return (
+ <>
+
+
+ {/* Open developer tool to inspect elements inside the body tag */}
+
+ Portal - This is transported to the end of the document body
+
+
+
+
+ I am the container
+
+ >
+ );
+};
+
+export default App;
diff --git a/packages/react-docs/pages/components/toast-manager/useToastManager.js b/packages/react-docs/pages/components/toast-manager/useToastManager.js
new file mode 100644
index 0000000000..8e28ea3041
--- /dev/null
+++ b/packages/react-docs/pages/components/toast-manager/useToastManager.js
@@ -0,0 +1,48 @@
+import {
+ Box,
+ Button,
+ Text,
+ Toast,
+ useToastManager,
+} from '@tonic-ui/react';
+import React, { useCallback } from 'react';
+
+const App = () => {
+ const toast = useToastManager();
+ const handleClickOpenToast = useCallback(() => {
+ const render = ({ onClose, placement }) => {
+ const styleProps = {
+ 'top-left': { pt: '2x', px: '4x' },
+ 'top': { pt: '2x', px: '4x' },
+ 'top-right': { pt: '2x', px: '4x' },
+ 'bottom-left': { pb: '2x', px: '4x' },
+ 'bottom': { pb: '2x', px: '4x' },
+ 'bottom-right': { pb: '2x', px: '4x' },
+ }[placement];
+
+ return (
+
+
+ This is a toast notification
+
+
+ );
+ };
+ const options = {
+ placement: 'bottom-right',
+ duration: 5000,
+ };
+ toast(render, options);
+ }, [toast]);
+
+ return (
+
+ );
+};
+
+export default App;
diff --git a/packages/react-docs/pages/components/toast-manager/useToastManager.page.mdx b/packages/react-docs/pages/components/toast-manager/useToastManager.page.mdx
index 550b9175d8..b7af809e7e 100644
--- a/packages/react-docs/pages/components/toast-manager/useToastManager.page.mdx
+++ b/packages/react-docs/pages/components/toast-manager/useToastManager.page.mdx
@@ -212,42 +212,4 @@ The toast state is a placement object, each placement contains an array of objec
## Demos
-```jsx
-function Example() {
- const toast = useToastManager();
- const handleClickOpenToast = () => {
- const render = ({ onClose, placement }) => {
- const styleProps = {
- 'top-left': { pt: '2x', px: '4x' },
- 'top': { pt: '2x', px: '4x' },
- 'top-right': { pt: '2x', px: '4x' },
- 'bottom-left': { pb: '2x', px: '4x' },
- 'bottom': { pb: '2x', px: '4x' },
- 'bottom-right': { pb: '2x', px: '4x' },
- }[placement];
-
- return (
-
-
- This is a toast notification
-
-
- );
- };
- const options = {
- placement: 'bottom-right',
- duration: 5000,
- };
- toast(render, options);
- };
-
- return (
-
- );
-}
-```
+{render('./useToastManager')}
diff --git a/packages/react/src/portal/__tests__/Portal.test.js b/packages/react/src/portal/__tests__/Portal.test.js
index ab8e262369..e50316f451 100644
--- a/packages/react/src/portal/__tests__/Portal.test.js
+++ b/packages/react/src/portal/__tests__/Portal.test.js
@@ -1,7 +1,6 @@
import { screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
import { render } from '@tonic-ui/react/test-utils/render';
-import { Box, Button, Portal, PortalManager, usePortalManager } from '@tonic-ui/react/src';
+import { Portal } from '@tonic-ui/react/src';
import React from 'react';
describe('Portal', () => {
@@ -73,97 +72,3 @@ describe('Portal', () => {
expect(container).toContainElement(content);
});
});
-
-describe('PortalManager / usePortalManager', () => {
- it('should add a portal to the PortalManager and later removed by calling the close function', async () => {
- const user = userEvent.setup();
-
- const TestComponent = () => {
- const portal = usePortalManager();
- const addPortal = React.useCallback(() => {
- portal((close) => (
-
- This is a portal component
-
-
- ));
- }, [portal]);
-
- return (
-
- );
- };
-
- render(
-
-
-
- );
-
- await user.click(await screen.findByTestId('btn-add-portal'));
-
- const portalComponent = screen.getByTestId('portal-component');
- expect(portalComponent).toBeInTheDocument();
-
- await user.click(await screen.findByTestId('btn-remove-portal'));
-
- expect(portalComponent).not.toBeInTheDocument();
- });
-
- it('should add a portal to the PortalManager and later removed by passing the portal\'s id', async () => {
- const user = userEvent.setup();
-
- const TestComponent = () => {
- const portalIdRef = React.useRef(null);
- const portal = usePortalManager();
- const handleClickAddPortal = React.useCallback((event) => {
- const id = portal(() => (
-
- This is a portal component
-
- ));
- portalIdRef.current = id;
- }, [portal]);
- const handleClickRemovePortal = React.useCallback((event) => {
- const id = portalIdRef.current;
- portal.remove(id);
- }, [portal]);
-
- return (
- <>
-
-
- >
- );
- };
-
- render(
-
-
-
- );
-
- await user.click(await screen.findByTestId('btn-add-portal'));
-
- const portalComponent = screen.getByTestId('portal-component');
- expect(portalComponent).toBeInTheDocument();
-
- await user.click(await screen.findByTestId('btn-remove-portal'));
-
- expect(portalComponent).not.toBeInTheDocument();
- });
-});
diff --git a/packages/react/src/portal/__tests__/PortalManager.test.js b/packages/react/src/portal/__tests__/PortalManager.test.js
new file mode 100644
index 0000000000..9a6b98b06b
--- /dev/null
+++ b/packages/react/src/portal/__tests__/PortalManager.test.js
@@ -0,0 +1,99 @@
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '@tonic-ui/react/test-utils/render';
+import { Box, Button, PortalManager, usePortalManager } from '@tonic-ui/react/src';
+import React, { useCallback, useRef } from 'react';
+
+describe('PortalManager', () => {
+ it('should add a portal to the PortalManager and later removed by calling the close function', async () => {
+ const user = userEvent.setup();
+
+ const TestComponent = () => {
+ const portal = usePortalManager();
+ const addPortal = useCallback(() => {
+ portal((close) => (
+
+ This is a portal component
+
+
+ ));
+ }, [portal]);
+
+ return (
+
+ );
+ };
+
+ render(
+
+
+
+ );
+
+ await user.click(await screen.findByTestId('btn-add-portal'));
+
+ const portalComponent = screen.getByTestId('portal-component');
+ expect(portalComponent).toBeInTheDocument();
+
+ await user.click(await screen.findByTestId('btn-remove-portal'));
+
+ expect(portalComponent).not.toBeInTheDocument();
+ });
+
+ it('should add a portal to the PortalManager and later removed by passing the portal\'s id', async () => {
+ const user = userEvent.setup();
+
+ const TestComponent = () => {
+ const portalIdRef = useRef(null);
+ const portal = usePortalManager();
+ const handleClickAddPortal = useCallback((event) => {
+ const id = portal(() => (
+
+ This is a portal component
+
+ ));
+ portalIdRef.current = id;
+ }, [portal]);
+ const handleClickRemovePortal = useCallback((event) => {
+ const id = portalIdRef.current;
+ portal.remove(id);
+ }, [portal]);
+
+ return (
+ <>
+
+
+ >
+ );
+ };
+
+ render(
+
+
+
+ );
+
+ await user.click(await screen.findByTestId('btn-add-portal'));
+
+ const portalComponent = screen.getByTestId('portal-component');
+ expect(portalComponent).toBeInTheDocument();
+
+ await user.click(await screen.findByTestId('btn-remove-portal'));
+
+ expect(portalComponent).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/react/src/portal/usePortalManager.js b/packages/react/src/portal/usePortalManager.js
index 2388c6a961..2ea03c2103 100644
--- a/packages/react/src/portal/usePortalManager.js
+++ b/packages/react/src/portal/usePortalManager.js
@@ -1,7 +1,10 @@
-import { useContext, useMemo } from 'react';
+import { useContext, useRef } from 'react';
import { PortalManagerContext } from './context';
const usePortalManager = () => {
+ const createPortalRef = useRef(null);
+ const portalManagerRef = useRef(null);
+
if (!useContext) {
throw new Error('The `useContext` hook is not available with your React version.');
}
@@ -12,14 +15,17 @@ const usePortalManager = () => {
throw new Error('The `usePortalManager` hook must be called from a descendent of the `PortalManager`.');
}
- const portal = useMemo(() => {
- const fn = function (...args) {
- return context.add(...args);
+ createPortalRef.current = context.add;
+
+ if (!portalManagerRef.current) {
+ portalManagerRef.current = function (...args) {
+ return createPortalRef.current?.(...args);
};
- return Object.assign(fn, context);
- }, [context]);
+ }
+
+ Object.assign(portalManagerRef.current, context);
- return portal;
+ return portalManagerRef.current;
};
export default usePortalManager;
diff --git a/packages/react/src/toast/ToastManager.js b/packages/react/src/toast/ToastManager.js
index 064dee064c..5aa6456ae3 100644
--- a/packages/react/src/toast/ToastManager.js
+++ b/packages/react/src/toast/ToastManager.js
@@ -2,7 +2,7 @@ import { useHydrated, useOnceWhen } from '@tonic-ui/react-hooks';
import { runIfFn, warnDeprecatedProps } from '@tonic-ui/utils';
import { ensureArray, ensureString } from 'ensure-type';
import memoize from 'micro-memoize';
-import React, { useState } from 'react';
+import React, { useCallback, useState } from 'react';
import { isElement, isValidElementType } from 'react-is';
import {
TransitionGroup,
@@ -70,7 +70,7 @@ const ToastManager = ({
/**
* Create properties for a new toast
*/
- const createToast = (message, options) => {
+ const createToast = useCallback((message, options) => {
const id = options?.id ?? uniqueId();
const duration = options?.duration;
const placement = ensureString(options?.placement ?? placementProp);
@@ -92,19 +92,21 @@ const ToastManager = ({
// The function to close the toast
onClose,
};
- };
+ }, [close, placementProp]);
/**
* Close a toast record at its placement
*/
- const close = (id, placement) => {
- placement = placement ?? getToastPlacementByState(state, id);
+ const close = useCallback((id, placement) => {
+ setState((prevState) => {
+ placement = placement ?? getToastPlacementByState(prevState, id);
- setState((prevState) => ({
- ...prevState,
- [placement]: ensureArray(prevState[placement]).filter((toast) => toast.id !== id),
- }));
- };
+ return {
+ ...prevState,
+ [placement]: ensureArray(prevState[placement]).filter((toast) => toast.id !== id),
+ };
+ });
+ }, []);
/**
* Close all toasts at once with the given placements, including the following:
@@ -115,12 +117,11 @@ const ToastManager = ({
* • bottom-left
* • bottom-right
*/
- const closeAll = (options) => {
- const placements = options?.placements
- ? ensureArray(options?.placements)
- : Object.keys(state);
-
+ const closeAll = useCallback((options) => {
setState((prevState) => {
+ const placements = options?.placements
+ ? ensureArray(options?.placements)
+ : Object.keys(prevState);
const nextState = placements.reduce((acc, placement) => {
acc[placement] = [];
return acc;
@@ -131,29 +132,29 @@ const ToastManager = ({
...nextState,
};
});
- };
+ }, []);
/**
* Find the first toast in the array that matches the provided id. Otherwise, `undefined` is returned if not found.
* If no values satisfy the testing function, undefined is returned.
*/
- const find = (id) => {
+ const find = useCallback((id) => {
const placement = getToastPlacementByState(state, id);
return ensureArray(state[placement]).find((toast) => toast.id === id);
- };
+ }, [state]);
/**
* Find the first toast in the array that matches the provided id. Otherwise, -1 is returned if not found.
*/
- const findIndex = (id) => {
+ const findIndex = useCallback((id) => {
const placement = getToastPlacementByState(state, id);
return ensureArray(state[placement]).findIndex((toast) => toast.id === id);
- };
+ }, [state]);
/**
* Create a toast at the specified placement and return the id
*/
- const notify = (message, options) => {
+ const notify = useCallback((message, options) => {
const { id, duration, placement } = options;
const toast = createToast(message, { id, duration, placement });
@@ -196,12 +197,12 @@ const ToastManager = ({
});
return toast.id;
- };
+ }, [createToast]);
/**
* Update a specific toast with new options based on the given id. Returns `true` if the toast exists, else `false`.
*/
- const update = (id, options) => {
+ const update = useCallback((id, options) => {
const placement = find(id)?.placement;
const index = findIndex(id);
@@ -219,7 +220,7 @@ const ToastManager = ({
});
return true;
- };
+ }, [find, findIndex]);
const context = getMemoizedState({
// Methods
diff --git a/packages/react/src/toast/useToastManager.js b/packages/react/src/toast/useToastManager.js
index a0b57b361a..7558b49a64 100644
--- a/packages/react/src/toast/useToastManager.js
+++ b/packages/react/src/toast/useToastManager.js
@@ -1,7 +1,10 @@
-import { useContext, useMemo } from 'react';
+import { useContext, useRef } from 'react';
import { ToastManagerContext } from './context';
const useToastManager = () => {
+ const createToastRef = useRef(null);
+ const toastManagerRef = useRef(null);
+
if (!useContext) {
throw new Error('The `useContext` hook is not available with your React version.');
}
@@ -12,14 +15,17 @@ const useToastManager = () => {
throw new Error('The `useToastManager` hook must be called from a descendent of the `ToastManager`.');
}
- const toast = useMemo(() => {
- const fn = function (...args) {
- return context.notify(...args);
+ createToastRef.current = context.notify;
+
+ if (!toastManagerRef.current) {
+ toastManagerRef.current = function (...args) {
+ return createToastRef.current?.(...args);
};
- return Object.assign(fn, context);
- }, [context]);
+ }
+
+ Object.assign(toastManagerRef.current, context);
- return toast;
+ return toastManagerRef.current;
};
export default useToastManager;