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;