diff --git a/.changelog/5958-fix-render-phase-updates.md b/.changelog/5958-fix-render-phase-updates.md
new file mode 100644
index 0000000000..fa106d3dc6
--- /dev/null
+++ b/.changelog/5958-fix-render-phase-updates.md
@@ -0,0 +1,3 @@
+### Fixed
+
+- Fixed state updates while rendering that React development builds logged as "Cannot update a component while rendering a different component", from the toaster, the typing indicator, the "New messages" button, and part grouping, in PR [#5958](https://github.com/microsoft/BotFramework-WebChat/pull/5958), by [@OEvgeny](https://github.com/OEvgeny)
diff --git a/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html b/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html
index 04b302d31e..adefcf09cb 100644
--- a/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html
+++ b/__tests__/html2/activityOrdering/partGroupingShouldUseMaxTimestamp.html
@@ -24,10 +24,8 @@
import { version } from 'react';
const IGNORING_KEYWORDS = [
- 'Cannot update a component',
'Support for defaultProps',
'ReactDOM.render is no longer supported',
- 'To locate the bad setState()',
'has been deprecated, use `import {'
];
diff --git a/__tests__/html2/scrollToEndButton/scrollToEndButton.noUpdateWhileRendering.html b/__tests__/html2/scrollToEndButton/scrollToEndButton.noUpdateWhileRendering.html
new file mode 100644
index 0000000000..efdb736b8a
--- /dev/null
+++ b/__tests__/html2/scrollToEndButton/scrollToEndButton.noUpdateWhileRendering.html
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/__tests__/html2/scrollToEndButton/scrollToEndButton.noUpdateWhileRendering.html.snap-1.png b/__tests__/html2/scrollToEndButton/scrollToEndButton.noUpdateWhileRendering.html.snap-1.png
new file mode 100644
index 0000000000..e531ed9346
Binary files /dev/null and b/__tests__/html2/scrollToEndButton/scrollToEndButton.noUpdateWhileRendering.html.snap-1.png differ
diff --git a/__tests__/html2/themeProvider/override.html b/__tests__/html2/themeProvider/override.html
index ca18774bb9..1e69e12434 100644
--- a/__tests__/html2/themeProvider/override.html
+++ b/__tests__/html2/themeProvider/override.html
@@ -54,10 +54,7 @@
!(
message.includes(
'Support for defaultProps will be removed from function components in a future major release. Use JavaScript default parameters instead.'
- ) ||
- message.includes('ReactDOM.render is no longer supported in React 18. Use createRoot instead.') ||
- // TODO: [P0] We should fix the "Cannot update a component while rendering a different component" error.
- (message.includes('Cannot update a component') && message.includes('while rendering a different component'))
+ ) || message.includes('ReactDOM.render is no longer supported in React 18. Use createRoot instead.')
)
) {
consoleError(...args);
@@ -108,14 +105,11 @@
stylesRoot,
variant: 'fluent'
},
- createElement(
- ReactWebChat,
- {
- className: 'webchat-with-overrides',
- directLine,
- store
- }
- )
+ createElement(ReactWebChat, {
+ className: 'webchat-with-overrides',
+ directLine,
+ store
+ })
)
)
)
diff --git a/__tests__/html2/toast/noUpdateWhileRendering.html b/__tests__/html2/toast/noUpdateWhileRendering.html
new file mode 100644
index 0000000000..b1f99f61d4
--- /dev/null
+++ b/__tests__/html2/toast/noUpdateWhileRendering.html
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/__tests__/html2/toast/noUpdateWhileRendering.html.snap-1.png b/__tests__/html2/toast/noUpdateWhileRendering.html.snap-1.png
new file mode 100644
index 0000000000..e142cff088
Binary files /dev/null and b/__tests__/html2/toast/noUpdateWhileRendering.html.snap-1.png differ
diff --git a/__tests__/html2/typing/typingIndicator.liveRegion.noUpdateWhileRendering.html b/__tests__/html2/typing/typingIndicator.liveRegion.noUpdateWhileRendering.html
new file mode 100644
index 0000000000..f88329898d
--- /dev/null
+++ b/__tests__/html2/typing/typingIndicator.liveRegion.noUpdateWhileRendering.html
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/__tests__/html2/typing/typingIndicator.liveRegion.noUpdateWhileRendering.html.snap-1.png b/__tests__/html2/typing/typingIndicator.liveRegion.noUpdateWhileRendering.html.snap-1.png
new file mode 100644
index 0000000000..fb78f33e69
Binary files /dev/null and b/__tests__/html2/typing/typingIndicator.liveRegion.noUpdateWhileRendering.html.snap-1.png differ
diff --git a/packages/api-graph/src/private/GraphProvider.tsx b/packages/api-graph/src/private/GraphProvider.tsx
index 7872eaf540..116567b015 100644
--- a/packages/api-graph/src/private/GraphProvider.tsx
+++ b/packages/api-graph/src/private/GraphProvider.tsx
@@ -7,7 +7,7 @@ import {
type GraphSubscriber,
type Identifier
} from 'botframework-webchat-core/graph.js';
-import React, { memo, useEffect, useMemo, useState } from 'react';
+import React, { memo, useLayoutEffect, useMemo, useState } from 'react';
import { custom, function_, is, object, optional, parse, pipe, readonly, type InferInput } from 'valibot';
import GraphContext, { graphContextSchema, GraphContextType } from './GraphContext';
@@ -33,7 +33,9 @@ function GraphProvider(props: GraphProviderProps) {
const [orderedActivityNodes, setOrderedActivityNodes] = useState(EMPTY_ARRAY);
- useEffect(() => {
+ // Layout effect: activities already in the store (e.g. rehydrated) must be in this state before the re-render of any other layout effect.
+ // Otherwise, that re-render would see activities in Redux which are not in the graph yet.
+ useLayoutEffect(() => {
// Sync between graph and `orderedActivities`.
const handleChange: GraphSubscriber = record => {
const state = graph.getState();
diff --git a/packages/component/src/BasicTranscript.tsx b/packages/component/src/BasicTranscript.tsx
index a80949ff6e..763cc6ab73 100644
--- a/packages/component/src/BasicTranscript.tsx
+++ b/packages/component/src/BasicTranscript.tsx
@@ -8,6 +8,7 @@ import React, {
Fragment,
memo,
useCallback,
+ useLayoutEffect,
useMemo,
useRef,
type KeyboardEventHandler,
@@ -493,15 +494,10 @@ const InternalTranscriptScrollable = ({ children, onFocusFiller }: InternalTrans
const stickyChangedToTrue = prevSticky !== sticky && sticky;
- useMemo(
- () =>
- stickyChangedToTrue &&
- // TODO: [P2] Both `markActivityKeyAsRead` and `markAllAsAcknowledged` hook are setters of useState.
- // This means, in a render loop, we will be calling setter and will cause another re-render.
- // This is not trivial but we should think if there is a way to avoid this.
- markAllAsAcknowledged(),
- [markAllAsAcknowledged, stickyChangedToTrue]
- );
+ // `markAllAsAcknowledged` is a setter of , it cannot be called while rendering this component.
+ useLayoutEffect(() => {
+ stickyChangedToTrue && markAllAsAcknowledged();
+ }, [markAllAsAcknowledged, stickyChangedToTrue]);
// We need to check if `children` is `false` or not.
// If `children` is `false`, React.Children.count(children) will still return 1 (truthy).
diff --git a/packages/component/src/ChatHistory/private/ScrollToEndButton.tsx b/packages/component/src/ChatHistory/private/ScrollToEndButton.tsx
index 0bf0f06dac..fdaebb7e10 100644
--- a/packages/component/src/ChatHistory/private/ScrollToEndButton.tsx
+++ b/packages/component/src/ChatHistory/private/ScrollToEndButton.tsx
@@ -1,5 +1,5 @@
import { hooks } from 'botframework-webchat-api';
-import React, { Fragment, memo, MutableRefObject, useCallback, useMemo } from 'react';
+import React, { Fragment, memo, MutableRefObject, useCallback, useLayoutEffect, useMemo } from 'react';
import { useAnimatingToEnd, useAtEnd, useScrollToEnd, useSticky } from 'react-scroll-to-bottom';
import { useRefFrom } from 'use-ref-from';
@@ -46,17 +46,17 @@ const useScrollToEndRenderResult = (terminatorRef: MutableRefObject {
- // This code need to be careful reviewed as it will cause another render. The code should be converging.
- // After we call `markActivityKeyAsRead`, everything will be read and nothing will be unread.
- // That means, in next render, `unreadActivityKeys` will be emptied and the `markActivityKeyAsRead` will not get called again.
- if (sticky && unreadActivityKeys.length) {
- markActivityKeyAsRead(unreadActivityKeys[unreadActivityKeys.length - 1]);
-
- return [];
- }
+ const nextUnreadActivityKeys = useMemo(
+ () => (sticky && unreadActivityKeys.length ? [] : unreadActivityKeys),
+ [sticky, unreadActivityKeys]
+ );
- return unreadActivityKeys;
+ // This code need to be careful reviewed as it will cause another render. The code should be converging.
+ // After we call `markActivityKeyAsRead`, everything will be read and nothing will be unread.
+ // That means, in next render, `unreadActivityKeys` will be emptied and the `markActivityKeyAsRead` will not get called again.
+ // Layout effect: updating while rendering this component is not allowed.
+ useLayoutEffect(() => {
+ sticky && unreadActivityKeys.length && markActivityKeyAsRead(unreadActivityKeys[unreadActivityKeys.length - 1]);
}, [markActivityKeyAsRead, sticky, unreadActivityKeys]);
const nextUnreadActivityKeysRef = useRefFrom(nextUnreadActivityKeys);
diff --git a/packages/component/src/Transcript/LiveRegionTranscript.tsx b/packages/component/src/Transcript/LiveRegionTranscript.tsx
index a56adb2524..8baf10d7e2 100644
--- a/packages/component/src/Transcript/LiveRegionTranscript.tsx
+++ b/packages/component/src/Transcript/LiveRegionTranscript.tsx
@@ -7,7 +7,7 @@ import LiveRegionActivity from '../LiveRegion/LiveRegionActivity';
import tabbableElements from '../Utils/tabbableElements';
import useLocalizeAccessKey from '../hooks/internal/useLocalizeAccessKey';
import useSuggestedActionsAccessKey from '../hooks/internal/useSuggestedActionsAccessKey';
-import { useQueueStaticElement } from '../providers/LiveRegionTwin';
+import { useLiveRegion, useQueueStaticElement } from '../providers/LiveRegionTwin';
import LiveRegionSendFailed from './LiveRegion/SendFailed';
import isPresentational from './LiveRegion/isPresentational';
import useTypistNames from './useTypistNames';
@@ -128,7 +128,7 @@ const LiveRegionTranscript = ({ activityElementMapRef }: LiveRegionTranscriptPro
keyedActivities
]);
- useMemo(() => typingIndicator && queueStaticElement(typingIndicator), [queueStaticElement, typingIndicator]);
+ useLiveRegion(() => typingIndicator, [typingIndicator]);
return ;
};
diff --git a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts
index d2e402724b..2fdb1b78c8 100644
--- a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts
+++ b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts
@@ -7,7 +7,6 @@ import ActivityLogicalGroupingContext, {
type GroupState,
type LogicalGrouping
} from './private/ActivityLogicalGroupingContext';
-import useStateWithOptimisticRef from './private/useStateWithOptimisticRef';
const activityLogicalGroupingComposerPropsSchema = pipe(
object({
@@ -22,9 +21,8 @@ const ActivityLogicalGroupingComposer = (props: ActivityLogicalGroupingComposerP
const { children } = validateProps(activityLogicalGroupingComposerPropsSchema, props);
const logicalGroupingsRef = useRef