From 9bac684348e2d2bd5b4b6fbc862d8a09d7ac757d Mon Sep 17 00:00:00 2001 From: Tasso Date: Tue, 21 Jan 2025 15:13:20 -0300 Subject: [PATCH 1/3] fix(fuselage-toastbar): React 18 compatibility --- .changeset/many-monkeys-deliver.md | 7 +++ .../fuselage-toastbar/src/ToastBarPortal.ts | 52 +++++++++++++++++-- .../src/lib/utils/createAnchor.ts | 23 -------- .../src/lib/utils/deleteAnchor.ts | 11 ---- 4 files changed, 54 insertions(+), 39 deletions(-) create mode 100644 .changeset/many-monkeys-deliver.md delete mode 100644 packages/fuselage-toastbar/src/lib/utils/createAnchor.ts delete mode 100644 packages/fuselage-toastbar/src/lib/utils/deleteAnchor.ts diff --git a/.changeset/many-monkeys-deliver.md b/.changeset/many-monkeys-deliver.md new file mode 100644 index 0000000000..d03af30531 --- /dev/null +++ b/.changeset/many-monkeys-deliver.md @@ -0,0 +1,7 @@ +--- +'@rocket.chat/fuselage-toastbar': patch +--- + +Enable compatibility with React 18 + +React 18's Strict Mode fires effects twice, which breaks Fuselage's toast bar portal. diff --git a/packages/fuselage-toastbar/src/ToastBarPortal.ts b/packages/fuselage-toastbar/src/ToastBarPortal.ts index adede81d56..3371f9ab9b 100644 --- a/packages/fuselage-toastbar/src/ToastBarPortal.ts +++ b/packages/fuselage-toastbar/src/ToastBarPortal.ts @@ -1,17 +1,59 @@ import type { ReactElement, ReactNode } from 'react'; -import { memo, useEffect, useState } from 'react'; +import { memo, useLayoutEffect } from 'react'; import { createPortal } from 'react-dom'; -import { createAnchor } from './lib/utils/createAnchor'; -import { deleteAnchor } from './lib/utils/deleteAnchor'; +const ensureAnchorElement = (id: string): HTMLElement => { + const existingAnchor = document.getElementById(id); + if (existingAnchor) return existingAnchor; + + const newAnchor = document.createElement('div'); + newAnchor.id = id; + document.body.appendChild(newAnchor); + return newAnchor; +}; + +const getAnchorRefCount = (anchorElement: HTMLElement): number => { + const { refCount } = anchorElement.dataset; + if (refCount) return parseInt(refCount, 10); + return 0; +}; + +const setAnchorRefCount = (anchorElement: HTMLElement, refCount: number): void => { + anchorElement.dataset.refCount = String(refCount); +}; + +const refAnchorElement = (anchorElement: HTMLElement): void => { + setAnchorRefCount(anchorElement, getAnchorRefCount(anchorElement) + 1); + + if (anchorElement.parentElement !== document.body) { + document.body.appendChild(anchorElement); + } +}; + +const unrefAnchorElement = (anchorElement: HTMLElement): void => { + const refCount = getAnchorRefCount(anchorElement) - 1; + setAnchorRefCount(anchorElement, refCount); + + if (refCount <= 0) { + document.body.removeChild(anchorElement); + } +}; type ToastBarPortalProps = { children?: ReactNode; }; const ToastBarPortal = ({ children }: ToastBarPortalProps): ReactElement => { - const [toastBarRoot] = useState(() => createAnchor('toastBarRoot')); - useEffect(() => (): void => deleteAnchor(toastBarRoot), [toastBarRoot]); + const toastBarRoot = ensureAnchorElement('toastBarRoot'); + + useLayoutEffect(() => { + refAnchorElement(toastBarRoot); + + return () => { + unrefAnchorElement(toastBarRoot); + }; + }, [toastBarRoot]); + return createPortal(children, toastBarRoot); }; diff --git a/packages/fuselage-toastbar/src/lib/utils/createAnchor.ts b/packages/fuselage-toastbar/src/lib/utils/createAnchor.ts deleted file mode 100644 index 68cc94bb01..0000000000 --- a/packages/fuselage-toastbar/src/lib/utils/createAnchor.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { registerAnchor } from './deleteAnchor'; - -type T = keyof HTMLElementTagNameMap; - -export const createAnchor: { - ( - id: string, - tag?: T, - ): T extends undefined - ? HTMLElementTagNameMap['div'] - : HTMLElementTagNameMap[T]; -} = (id: string, tag = 'div') => { - const anchor = document.getElementById(id); - if (anchor && anchor.tagName.toLowerCase() === tag) { - return anchor as any; - } - const a = document.createElement(tag); - a.id = id; - document.body.appendChild(a); - - registerAnchor(a, () => document.body.removeChild(a)); - return a; -}; diff --git a/packages/fuselage-toastbar/src/lib/utils/deleteAnchor.ts b/packages/fuselage-toastbar/src/lib/utils/deleteAnchor.ts deleted file mode 100644 index 8f455abc97..0000000000 --- a/packages/fuselage-toastbar/src/lib/utils/deleteAnchor.ts +++ /dev/null @@ -1,11 +0,0 @@ -const anchor = new WeakMap void>(); - -export const deleteAnchor = (element: HTMLElement): void => { - const fn = anchor.get(element); - if (fn) { - fn(); - } -}; -export const registerAnchor = (element: HTMLElement, fn: () => void): void => { - anchor.set(element, fn); -}; From f56b86d4c7118b33f3a9ca4f626678bd50ba9e9f Mon Sep 17 00:00:00 2001 From: Tasso Date: Tue, 21 Jan 2025 16:32:14 -0300 Subject: [PATCH 2/3] Fix lint issues --- .../src/ToastBar.stories.tsx | 92 ++++++------------- .../fuselage-toastbar/src/ToastBarPortal.ts | 55 +++++------ 2 files changed, 58 insertions(+), 89 deletions(-) diff --git a/packages/fuselage-toastbar/src/ToastBar.stories.tsx b/packages/fuselage-toastbar/src/ToastBar.stories.tsx index 9eeab41cac..29da4cf5ea 100644 --- a/packages/fuselage-toastbar/src/ToastBar.stories.tsx +++ b/packages/fuselage-toastbar/src/ToastBar.stories.tsx @@ -53,85 +53,51 @@ export const Default: StoryFn = () => { ); }; -export const TopStart: StoryFn = () => { +const Template: StoryFn<{ + position: 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end'; +}> = ({ position }) => { const dispatchToastMessage = useToastBarDispatch(); - const handleDispatch = () => - dispatchToastMessage({ - type: 'success', - message: DEFAULT_MESSAGE, - position: 'top-start', - }); - useEffect(() => { - handleDispatch(); - }, []); - - return ( - - ); -}; - -export const TopEnd: StoryFn = () => { - const dispatchToastMessage = useToastBarDispatch(); - - const handleDispatch = () => dispatchToastMessage({ type: 'success', message: DEFAULT_MESSAGE, + position, }); - - useEffect(() => { - handleDispatch(); - }, []); + }, [dispatchToastMessage, position]); return ( - ); }; -export const BottomStart: StoryFn = () => { - const dispatchToastMessage = useToastBarDispatch(); - - const handleDispatch = () => - dispatchToastMessage({ - type: 'success', - message: DEFAULT_MESSAGE, - position: 'bottom-start', - }); - - useEffect(() => { - handleDispatch(); - }, []); - - return ( - - ); +export const TopStart = Template.bind({}); +TopStart.args = { + position: 'top-start', }; -export const BottomEnd: StoryFn = () => { - const dispatchToastMessage = useToastBarDispatch(); - - const handleDispatch = () => - dispatchToastMessage({ - type: 'success', - message: DEFAULT_MESSAGE, - position: 'bottom-end', - }); +export const TopEnd = Template.bind({}); +TopEnd.args = { + position: 'top-end', +}; - useEffect(() => { - handleDispatch(); - }, []); +export const BottomStart = Template.bind({}); +BottomStart.args = { + position: 'bottom-start', +}; - return ( - - ); +export const BottomEnd = Template.bind({}); +BottomEnd.args = { + position: 'bottom-end', }; diff --git a/packages/fuselage-toastbar/src/ToastBarPortal.ts b/packages/fuselage-toastbar/src/ToastBarPortal.ts index 3371f9ab9b..6d7ad43429 100644 --- a/packages/fuselage-toastbar/src/ToastBarPortal.ts +++ b/packages/fuselage-toastbar/src/ToastBarPortal.ts @@ -3,40 +3,43 @@ import { memo, useLayoutEffect } from 'react'; import { createPortal } from 'react-dom'; const ensureAnchorElement = (id: string): HTMLElement => { - const existingAnchor = document.getElementById(id); - if (existingAnchor) return existingAnchor; + const existingAnchor = document.getElementById(id); + if (existingAnchor) return existingAnchor; - const newAnchor = document.createElement('div'); - newAnchor.id = id; - document.body.appendChild(newAnchor); - return newAnchor; + const newAnchor = document.createElement('div'); + newAnchor.id = id; + document.body.appendChild(newAnchor); + return newAnchor; }; const getAnchorRefCount = (anchorElement: HTMLElement): number => { - const { refCount } = anchorElement.dataset; - if (refCount) return parseInt(refCount, 10); - return 0; + const { refCount } = anchorElement.dataset; + if (refCount) return parseInt(refCount, 10); + return 0; }; -const setAnchorRefCount = (anchorElement: HTMLElement, refCount: number): void => { - anchorElement.dataset.refCount = String(refCount); +const setAnchorRefCount = ( + anchorElement: HTMLElement, + refCount: number, +): void => { + anchorElement.dataset.refCount = String(refCount); }; const refAnchorElement = (anchorElement: HTMLElement): void => { - setAnchorRefCount(anchorElement, getAnchorRefCount(anchorElement) + 1); + setAnchorRefCount(anchorElement, getAnchorRefCount(anchorElement) + 1); - if (anchorElement.parentElement !== document.body) { - document.body.appendChild(anchorElement); - } + if (anchorElement.parentElement !== document.body) { + document.body.appendChild(anchorElement); + } }; const unrefAnchorElement = (anchorElement: HTMLElement): void => { - const refCount = getAnchorRefCount(anchorElement) - 1; - setAnchorRefCount(anchorElement, refCount); + const refCount = getAnchorRefCount(anchorElement) - 1; + setAnchorRefCount(anchorElement, refCount); - if (refCount <= 0) { - document.body.removeChild(anchorElement); - } + if (refCount <= 0) { + document.body.removeChild(anchorElement); + } }; type ToastBarPortalProps = { @@ -46,13 +49,13 @@ type ToastBarPortalProps = { const ToastBarPortal = ({ children }: ToastBarPortalProps): ReactElement => { const toastBarRoot = ensureAnchorElement('toastBarRoot'); - useLayoutEffect(() => { - refAnchorElement(toastBarRoot); + useLayoutEffect(() => { + refAnchorElement(toastBarRoot); - return () => { - unrefAnchorElement(toastBarRoot); - }; - }, [toastBarRoot]); + return () => { + unrefAnchorElement(toastBarRoot); + }; + }, [toastBarRoot]); return createPortal(children, toastBarRoot); }; From d5ebe8e65ef24343c0a257f27d3ac9f11bd5bb02 Mon Sep 17 00:00:00 2001 From: Tasso Date: Wed, 22 Jan 2025 13:17:21 -0300 Subject: [PATCH 3/3] Prevent infinite loop --- .../src/ToastBarProvider.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/fuselage-toastbar/src/ToastBarProvider.tsx b/packages/fuselage-toastbar/src/ToastBarProvider.tsx index 01ac530bd3..089b984a6c 100644 --- a/packages/fuselage-toastbar/src/ToastBarProvider.tsx +++ b/packages/fuselage-toastbar/src/ToastBarProvider.tsx @@ -1,5 +1,5 @@ import type { ReactNode, ReactElement } from 'react'; -import { useState, memo } from 'react'; +import { useState, memo, useCallback } from 'react'; import type { ToastBarPayload } from './ToastBarContext'; import { ToastBarContext } from './ToastBarContext'; @@ -15,15 +15,19 @@ const ToastBarProvider = ({ children }: ToastBarProps): ReactElement => { const [toasts, setToasts] = useState([]); const contextValue = { - dispatch: ( - option: Omit & { time?: number }, - ) => - setToasts((toasts) => [ - ...toasts, - { ...option, time: option.time || 5, id: Math.random().toString() }, - ]), - dismiss: (id: ToastBarPayload['id']) => - setToasts((prevState) => prevState.filter((toast) => toast.id !== id)), + dispatch: useCallback( + (option: Omit & { time?: number }) => + setToasts((toasts) => [ + ...toasts, + { ...option, time: option.time || 5, id: Math.random().toString() }, + ]), + [], + ), + dismiss: useCallback( + (id: ToastBarPayload['id']) => + setToasts((prevState) => prevState.filter((toast) => toast.id !== id)), + [], + ), }; return (