diff --git a/.changelog/5966-fix-logical-group-key-hook.md b/.changelog/5966-fix-logical-group-key-hook.md
new file mode 100644
index 0000000000..767e71d43a
--- /dev/null
+++ b/.changelog/5966-fix-logical-group-key-hook.md
@@ -0,0 +1,3 @@
+### Fixed
+
+- Fixed grouped messages showing avatars and breaking keyboard navigation in React Strict Mode, in PR [#5966](https://github.com/microsoft/BotFramework-WebChat/pull/5966), by [@OEvgeny](https://github.com/OEvgeny)
diff --git a/__tests__/html2/part-grouping/strictMode.html b/__tests__/html2/part-grouping/strictMode.html
new file mode 100644
index 0000000000..634034cd30
--- /dev/null
+++ b/__tests__/html2/part-grouping/strictMode.html
@@ -0,0 +1,116 @@
+
+
+
+ Part grouping: keeps the group after Strict Mode re-runs effects
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/__tests__/html2/part-grouping/strictMode.html.snap-1.png b/__tests__/html2/part-grouping/strictMode.html.snap-1.png
new file mode 100644
index 0000000000..f9949778b1
Binary files /dev/null and b/__tests__/html2/part-grouping/strictMode.html.snap-1.png differ
diff --git a/__tests__/html2/part-grouping/strictMode.html.snap-2.png b/__tests__/html2/part-grouping/strictMode.html.snap-2.png
new file mode 100644
index 0000000000..39bfe37ad3
Binary files /dev/null and b/__tests__/html2/part-grouping/strictMode.html.snap-2.png differ
diff --git a/__tests__/html2/part-grouping/strictMode.html.snap-3.png b/__tests__/html2/part-grouping/strictMode.html.snap-3.png
new file mode 100644
index 0000000000..adbc85e88d
Binary files /dev/null and b/__tests__/html2/part-grouping/strictMode.html.snap-3.png differ
diff --git a/__tests__/html2/part-grouping/strictMode.html.snap-4.png b/__tests__/html2/part-grouping/strictMode.html.snap-4.png
new file mode 100644
index 0000000000..a9523451b6
Binary files /dev/null and b/__tests__/html2/part-grouping/strictMode.html.snap-4.png differ
diff --git a/packages/component/src/Activity/StackedLayout.tsx b/packages/component/src/Activity/StackedLayout.tsx
index 377fcea483..8d9eb45cc1 100644
--- a/packages/component/src/Activity/StackedLayout.tsx
+++ b/packages/component/src/Activity/StackedLayout.tsx
@@ -21,7 +21,7 @@ import getFirstBaseOfSoftwareSourceCode from '../Utils/orgSchema/getFirstBaseOfS
import textFormatToContentType from '../Utils/textFormatToContentType';
import useLocalizeActivityGreetingAlt from '../hooks/internal/useLocalizeActivityGreetingAlt.js';
import useLocalizeProgrammingLanguage from '../hooks/internal/useLocalizeProgrammingLanguage';
-import { useGetLogicalGroupKey } from '../providers/ActivityLogicalGrouping';
+import { useLogicalGroupKey } from '../providers/ActivityLogicalGrouping';
import AttachmentRow from './AttachmentRow';
import Bubble from './Bubble';
import CodeBlockContent from './CodeBlockContent';
@@ -144,8 +144,8 @@ const StackedLayout = ({
const nubSize = fromUser ? bubbleFromUserNubSize : bubbleNubSize;
const otherInitials = fromUser ? botInitials : userInitials;
const otherNubSize = fromUser ? bubbleNubSize : bubbleFromUserNubSize;
- const activityKey = useGetKeyByActivity()(activity);
- const isInGroup = !!useGetLogicalGroupKey()(activityKey);
+ const [logicalGroupKey] = useLogicalGroupKey(useGetKeyByActivity()(activity));
+ const isInGroup = !!logicalGroupKey;
const hasAvatar = (initials || typeof initials === 'string') && !isInGroup;
const hasOtherAvatar = otherInitials || typeof otherInitials === 'string';
diff --git a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/PartGroupingActivity.tsx b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/PartGroupingActivity.tsx
index 4b221a11c4..f0fde958d4 100644
--- a/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/PartGroupingActivity.tsx
+++ b/packages/component/src/Middleware/ActivityGrouping/ui/PartGrouping/private/PartGroupingActivity.tsx
@@ -13,7 +13,7 @@ import StackedLayoutStatus from '../../../../../Activity/StackedLayoutStatus';
import useActivityElementMapRef from '../../../../../providers/ChatHistoryDOM/useActivityElementRef';
import useActiveDescendantId from '../../../../../providers/TranscriptFocus/useActiveDescendantId';
import useFocusByGroupKey from '../../../../../providers/TranscriptFocus/useFocusByGroupKey';
-import useGetGroupDescendantIdByActivityKey from '../../../../../providers/TranscriptFocus/useGetGroupDescendantIdByActivityKey';
+import useGroupDescendantIdByGroupKey from '../../../../../providers/TranscriptFocus/useGroupDescendantIdByGroupKey';
import FocusTrap from '../../../../../Transcript/FocusTrap';
import useRenderActivityProps from '../../../../../Transcript/hooks/useRenderActivityProps';
import {
@@ -50,7 +50,6 @@ type PartGroupingActivityProps = InferOutput(value => is(object({}), value)),
children: optional(reactNode()),
className: optional(string()),
groupKey: string()
@@ -64,15 +63,12 @@ type FocusablePartGroupingActivityProps = InferOutput void>(
(withFocus?: boolean) => focusByGroupKey(groupKey, withFocus),
@@ -220,11 +216,7 @@ function PartGroupingActivity(props: PartGroupingActivityProps) {
);
return (
-
+
`part-${random().toString(36).slice(2, 11)}`);
- const addLogicalGrouping = useAddLogicalGrouping();
+ const partGroupKey = useMemo(() => `part-${random().toString(36).slice(2, 11)}`, []);
const removeLogicalGrouping = useRemoveLogicalGrouping();
+ const setLogicalGrouping = useSetLogicalGrouping();
- const isCollapsedRef = useRefFrom(isCollapsed);
+ // In layout effects, not while rendering: setting re-renders the activities before paint.
+ // Strict Mode runs the cleanup below, then this effect again.
+ // Removed in its own effect, so a change of `activityKeys` updates the group instead of removing and adding it.
+ useLayoutEffect(() => {
+ setLogicalGrouping(partGroupKey, { activityKeys: Object.freeze(Array.from(activityKeys)), isCollapsed });
+ }, [activityKeys, isCollapsed, partGroupKey, setLogicalGrouping]);
- const getGroupState = useCallback(() => ({ isCollapsed: isCollapsedRef.current }), [isCollapsedRef]);
-
- useMemo(
- () =>
- addLogicalGrouping({
- key: partKeyRef.current,
- activityKeys: Array.from(activityKeys),
- getGroupState
- }),
- [activityKeys, addLogicalGrouping, getGroupState, partKeyRef]
- );
-
- const removeLogicalGroupingRef = useRefFrom(removeLogicalGrouping);
-
- useEffect(() => () => removeLogicalGroupingRef.current(partKeyRef.current), [partKeyRef, removeLogicalGroupingRef]);
+ useLayoutEffect(() => () => removeLogicalGrouping(partGroupKey), [partGroupKey, removeLogicalGrouping]);
return partGroupKey;
}
diff --git a/packages/component/src/hooks/internal/createKeyedStore.ts b/packages/component/src/hooks/internal/createKeyedStore.ts
new file mode 100644
index 0000000000..5a2454d4c0
--- /dev/null
+++ b/packages/component/src/hooks/internal/createKeyedStore.ts
@@ -0,0 +1,38 @@
+type ReadonlyKeyedStore = {
+ readonly get: (key: string) => T | undefined;
+ /** Calls the listener after the value of the key changed, until unsubscribed. */
+ readonly subscribe: (key: string, listener: () => void) => () => void;
+};
+
+type KeyedStore = ReadonlyKeyedStore & {
+ readonly delete: (key: string) => void;
+ /** Notifies the listeners of the key if the value changed. Listeners may re-render: call it after commit, e.g. from an effect. */
+ readonly set: (key: string, value: T | undefined) => void;
+};
+
+/** Creates a store of values by key, which notifies only the listeners of keys whose value changed. */
+export default function createKeyedStore(): KeyedStore {
+ const eventTarget = new EventTarget();
+ const values = new Map();
+
+ const set = (key: string, value: T | undefined): void => {
+ if (!Object.is(values.get(key), value)) {
+ typeof value === 'undefined' ? values.delete(key) : values.set(key, value);
+
+ eventTarget.dispatchEvent(new Event(key));
+ }
+ };
+
+ return Object.freeze({
+ delete: (key: string) => set(key, undefined),
+ get: (key: string) => values.get(key),
+ set,
+ subscribe: (key: string, listener: () => void) => {
+ eventTarget.addEventListener(key, listener);
+
+ return () => eventTarget.removeEventListener(key, listener);
+ }
+ });
+}
+
+export { type KeyedStore, type ReadonlyKeyedStore };
diff --git a/packages/component/src/hooks/internal/useKeyedStoreValue.spec.tsx b/packages/component/src/hooks/internal/useKeyedStoreValue.spec.tsx
new file mode 100644
index 0000000000..dbf274cd56
--- /dev/null
+++ b/packages/component/src/hooks/internal/useKeyedStoreValue.spec.tsx
@@ -0,0 +1,163 @@
+/** @jest-environment @happy-dom/jest-environment */
+
+import * as JestGlobals from '@jest/globals';
+import { scenario } from '@testduet/given-when-then';
+import React, { Fragment, memo, useLayoutEffect } from 'react';
+import { render, unmountComponentAtNode } from 'react-dom';
+import { act } from 'react-dom/test-utils';
+
+import createKeyedStore, { type KeyedStore } from './createKeyedStore';
+import useKeyedStoreValue from './useKeyedStoreValue';
+
+type Renders = { readonly a: (string | undefined)[]; readonly b: (string | undefined)[] };
+
+function Reader({
+ renders,
+ store,
+ valueKey
+}: {
+ readonly renders: Renders;
+ readonly store: KeyedStore;
+ readonly valueKey: 'a' | 'b';
+}) {
+ // eslint-disable-next-line security/detect-object-injection
+ renders[valueKey].push(useKeyedStoreValue(store, valueKey)[0]);
+
+ return null;
+}
+
+// Memoized like activities in the transcript, so only its subscription can re-render it.
+const MemoizedReader = memo(Reader);
+
+// Placed before ``: its layout effect runs after `` rendered and before `` subscribes.
+function SetOnMount({
+ store,
+ value,
+ valueKey
+}: {
+ readonly store: KeyedStore;
+ readonly value: string;
+ readonly valueKey: string;
+}) {
+ useLayoutEffect(() => store.set(valueKey, value), [store, value, valueKey]);
+
+ return null;
+}
+
+scenario(
+ 'useKeyedStoreValue',
+ bdd => {
+ bdd
+ .given('readers of "a" and "b"', () => {
+ const container = document.createElement('div');
+ const renders: Renders = { a: [], b: [] };
+ const store = createKeyedStore();
+
+ act(() => {
+ render(
+
+
+
+ ,
+ container
+ );
+ });
+
+ return { container, renders, store };
+ })
+ .when('"a" is set', ({ store }) => {
+ act(() => store.set('a', '1'));
+ })
+ .then('only the reader of "a" should re-render', ({ renders }) => {
+ expect(renders.a).toStrictEqual([undefined, '1']);
+ expect(renders.b).toStrictEqual([undefined]);
+ })
+ .when('"a" is set to the same value', ({ store }) => {
+ act(() => store.set('a', '1'));
+ })
+ .then('the reader of "a" should not re-render', ({ renders }) =>
+ expect(renders.a).toStrictEqual([undefined, '1'])
+ )
+ .when('"a" is deleted', ({ store }) => {
+ act(() => store.delete('a'));
+ })
+ .then('the reader of "a" should re-render without a value', ({ renders }) =>
+ expect(renders.a).toStrictEqual([undefined, '1', undefined])
+ )
+ .when('the readers unmount and "a" is set', ({ container, store }) => {
+ act(() => {
+ unmountComponentAtNode(container);
+ });
+
+ act(() => store.set('a', '2'));
+ })
+ .then('the reader of "a" should not render again, it unsubscribed', ({ renders }) =>
+ expect(renders.a).toStrictEqual([undefined, '1', undefined])
+ );
+
+ bdd
+ .given('a store', () => ({ renders: { a: [], b: [] } as Renders, store: createKeyedStore() }))
+ .when('"a" is set after the reader rendered, before it subscribed', ({ renders, store }) => {
+ act(() => {
+ render(
+
+
+
+ ,
+ document.createElement('div')
+ );
+ });
+ })
+ .then('the reader should re-render with the missed value', ({ renders }) =>
+ expect(renders.a).toStrictEqual([undefined, 'missed'])
+ );
+
+ bdd
+ .given('a reader of "a", "a" is "1" and "b" is "2"', () => {
+ const container = document.createElement('div');
+ const renders: Renders = { a: [], b: [] };
+ const store = createKeyedStore();
+
+ store.set('a', '1');
+ store.set('b', '2');
+
+ // `{false}` keeps the reader at the same position as in the next render, so it updates instead of remounting.
+ act(() => {
+ render(
+
+ {false}
+
+ ,
+ container
+ );
+ });
+
+ return { container, renders, store };
+ })
+ .when(
+ 'the reader switches to "b" as "b" changes to "1" before it subscribes',
+ ({ container, renders, store }) => {
+ act(() => {
+ render(
+
+
+
+ ,
+ container
+ );
+ });
+ }
+ )
+ .then('the reader should re-render with "1", the new value of "b"', ({ renders }) =>
+ // Rendered "2" first, then "1" after the re-check, although "1" is also the value it rendered for "a".
+ expect(renders.b).toStrictEqual(['2', '1'])
+ )
+ .when('"a" is set', ({ store }) => {
+ act(() => store.set('a', '3'));
+ })
+ .then('the reader should not re-render, it unsubscribed from "a"', ({ renders }) =>
+ expect(renders.b).toStrictEqual(['2', '1'])
+ );
+ },
+ JestGlobals
+);
diff --git a/packages/component/src/hooks/internal/useKeyedStoreValue.ts b/packages/component/src/hooks/internal/useKeyedStoreValue.ts
new file mode 100644
index 0000000000..4a2d40fd55
--- /dev/null
+++ b/packages/component/src/hooks/internal/useKeyedStoreValue.ts
@@ -0,0 +1,33 @@
+import { useLayoutEffect, useMemo, useState } from 'react';
+
+import { type ReadonlyKeyedStore } from './createKeyedStore';
+
+/**
+ * Returns the value of the key in the store, and re-renders when it changes.
+ *
+ * When React 18 is the minimum, replace the body with
+ * `useSyncExternalStore(useCallback(listener => store.subscribe(key, listener), [key, store]), () => store.get(key))`.
+ */
+export default function useKeyedStoreValue(store: ReadonlyKeyedStore, key: string): readonly [T | undefined] {
+ const value = store.get(key);
+
+ // The state only triggers re-renders. It holds the key: after a key switch, the re-check below always re-renders,
+ // even if the value of the new key has just changed to the value rendered for the previous key.
+ const [, setState] = useState(() => Object.freeze([key, value]));
+
+ useLayoutEffect(() => {
+ const handleChange = () =>
+ setState(state => {
+ const nextValue = store.get(key);
+
+ return state[0] === key && Object.is(state[1], nextValue) ? state : Object.freeze([key, nextValue]);
+ });
+
+ // Catches changes between render and subscription.
+ handleChange();
+
+ return store.subscribe(key, handleChange);
+ }, [key, store]);
+
+ return useMemo(() => Object.freeze([value] as const), [value]);
+}
diff --git a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts
index 2fdb1b78c8..c07b513975 100644
--- a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts
+++ b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts
@@ -1,10 +1,10 @@
import { reactNode, validateProps } from '@msinternal/botframework-webchat-react-valibot';
-import { createElement, memo, useCallback, useMemo, useRef } from 'react';
+import { createElement, memo, useCallback, useMemo } from 'react';
import { object, optional, pipe, readonly, type InferInput } from 'valibot';
+import createKeyedStore from '../../hooks/internal/createKeyedStore';
import ActivityLogicalGroupingContext, {
type ActivityLogicalGroupingContextType,
- type GroupState,
type LogicalGrouping
} from './private/ActivityLogicalGroupingContext';
@@ -20,71 +20,43 @@ type ActivityLogicalGroupingComposerProps = InferInput {
const { children } = validateProps(activityLogicalGroupingComposerPropsSchema, props);
- const logicalGroupingsRef = useRef