Skip to content

Commit

Permalink
LG-4361 : Avoid most infinite render loops in ControlledToast (#2412)
Browse files Browse the repository at this point in the history
* Avoid most infinite render loops in ControlledToast

* Add changeset
  • Loading branch information
jetpacmonkey authored Jun 27, 2024
1 parent d70758d commit cadc11b
Show file tree
Hide file tree
Showing 4 changed files with 77 additions and 10 deletions.
5 changes: 5 additions & 0 deletions .changeset/proud-avocados-burn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@leafygreen-ui/toast': patch
---

Fixed the controlled Toast component to avoid most infinite render loops
16 changes: 16 additions & 0 deletions packages/toast/src/ControlledToast/ControlledToast.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,22 @@ describe('packages/toast/controlled', () => {
expect(toast).toBeInTheDocument();
});

test('handles rerender when `open` is true without hanging', async () => {
const { findByTestId, rerender } = render(
<Toast open title="Test Rerender" data-testid="test-toast-rerender" />,
{
wrapper: ({ children }) => <ToastProvider>{children}</ToastProvider>,
},
);

rerender(
<Toast open title="Test Rerender" data-testid="test-toast-rerender" />,
);

const toast = await findByTestId('test-toast-rerender');
expect(toast).toBeInTheDocument();
});

test('does not render when `open` is true and component is unmounted', async () => {
const { queryByTestId } = render(
<ToastProvider>
Expand Down
32 changes: 22 additions & 10 deletions packages/toast/src/ControlledToast/ControlledToast.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React, { useEffect, useState } from 'react';
import { useEffect, useRef } from 'react';
import defaultsDeep from 'lodash/defaultsDeep';

import { defaultToastProps } from '../InternalToast/defaultProps';
import { ToastId, useToast } from '../ToastContext';

import { ControlledToastProps } from './ControlledToast.types';
import useStableControlledToastProps from './useStableControlledToastProps';

/**
* A controlled toast component.
Expand All @@ -15,23 +16,34 @@ import { ControlledToastProps } from './ControlledToast.types';
*/
export const ControlledToast = ({ open, ...props }: ControlledToastProps) => {
props = defaultsDeep(props, defaultToastProps);
const { pushToast, popToast } = useToast();
const [toastId, setToastId] = useState<ToastId | null>(null);
const { pushToast, popToast, updateToast } = useToast();
const toastIdRef = useRef<ToastId | null>(null);

const stableProps = useStableControlledToastProps(props);

useEffect(() => {
if (open && !toastId) {
const _id = pushToast({ isControlled: true, ...props });
setToastId(_id);
const toastId = toastIdRef.current;

if (open) {
if (toastId == null) {
toastIdRef.current = pushToast({ isControlled: true, ...stableProps });
} else {
updateToast(toastId, stableProps);
}
} else if (!open && toastId) {
popToast(toastId);
setToastId(null);
toastIdRef.current = null;
}
}, [open, popToast, pushToast, updateToast, stableProps]);

useEffect(() => {
return () => {
// Remove toast on unmount
if (toastId) popToast(toastId);
if (toastIdRef.current != null) {
popToast(toastIdRef.current);
}
};
}, [open, popToast, props, pushToast, toastId]);
}, [popToast]);

return <></>;
return null;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useCallback, useRef } from 'react';
import isEqual from 'lodash/isEqual';

import { ControlledToastProps } from './ControlledToast.types';

export default function useStableControlledToastProps(
props: Omit<ControlledToastProps, 'open'>,
) {
// onClose is the only function prop, so handle it separately here
// NOTE: Any functions inside props in the toast's title or description can still theoretically cause a render loop
// unless they use useCallback
const onCloseRef = useRef(props.onClose);
onCloseRef.current = props.onClose;

// A function that maintains a consistent identity and always calls the most recent version of the prop
const stableOnClose = useCallback<
NonNullable<ControlledToastProps['onClose']>
>((...args) => onCloseRef.current?.(...args), []);

const propsWithStableOnClose = {
...props,
onClose: stableOnClose,
};

const lastPropsRef = useRef<Omit<ControlledToastProps, 'open'> | null>(null);
const lastProps = lastPropsRef.current;
const stableProps =
lastProps != null && isEqual(propsWithStableOnClose, lastProps)
? lastProps
: propsWithStableOnClose;
lastPropsRef.current = stableProps;

return stableProps;
}

0 comments on commit cadc11b

Please sign in to comment.