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

fix(fuselage-toastbar): React 18 compatibility #1531

Merged
merged 3 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/many-monkeys-deliver.md
Original file line number Diff line number Diff line change
@@ -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.
92 changes: 29 additions & 63 deletions packages/fuselage-toastbar/src/ToastBar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Button primary onClick={handleDispatch}>
Dispatch ToastBar
</Button>
);
};

export const TopEnd: StoryFn = () => {
const dispatchToastMessage = useToastBarDispatch();

const handleDispatch = () =>
dispatchToastMessage({
type: 'success',
message: DEFAULT_MESSAGE,
position,
});

useEffect(() => {
handleDispatch();
}, []);
}, [dispatchToastMessage, position]);

return (
<Button primary onClick={handleDispatch}>
<Button
primary
onClick={() =>
dispatchToastMessage({
type: 'success',
message: DEFAULT_MESSAGE,
position,
})
}
>
Dispatch ToastBar
</Button>
);
};

export const BottomStart: StoryFn = () => {
const dispatchToastMessage = useToastBarDispatch();

const handleDispatch = () =>
dispatchToastMessage({
type: 'success',
message: DEFAULT_MESSAGE,
position: 'bottom-start',
});

useEffect(() => {
handleDispatch();
}, []);

return (
<Button primary onClick={handleDispatch}>
Dispatch ToastBar
</Button>
);
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 (
<Button primary onClick={handleDispatch}>
Dispatch ToastBar
</Button>
);
export const BottomEnd = Template.bind({});
BottomEnd.args = {
position: 'bottom-end',
};
55 changes: 50 additions & 5 deletions packages/fuselage-toastbar/src/ToastBarPortal.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,62 @@
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);
};

Expand Down
24 changes: 14 additions & 10 deletions packages/fuselage-toastbar/src/ToastBarProvider.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,15 +15,19 @@ const ToastBarProvider = ({ children }: ToastBarProps): ReactElement => {
const [toasts, setToasts] = useState<ToastBarPayload[]>([]);

const contextValue = {
dispatch: (
option: Omit<ToastBarPayload, 'id' | 'time'> & { 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<ToastBarPayload, 'id' | 'time'> & { 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 (
Expand Down
23 changes: 0 additions & 23 deletions packages/fuselage-toastbar/src/lib/utils/createAnchor.ts

This file was deleted.

11 changes: 0 additions & 11 deletions packages/fuselage-toastbar/src/lib/utils/deleteAnchor.ts

This file was deleted.

Loading