Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
616f762
Fix state updates while rendering in the live region and read/acknowl…
Oct 22, 2026
e63c522
Keep the activity-to-group map of part grouping in a ref
Oct 22, 2026
d20a9f6
Stop ignoring "Cannot update a component while rendering a different …
Oct 22, 2026
1121f33
Add the changelog fragment
Oct 22, 2026
c4d28ed
Sync activities already in the store into the graph before other layo…
Oct 22, 2026
3a4813a
Add useLogicalGroupKey() and register part groups in a layout effect
Oct 22, 2026
46cf270
Remove an activity from its logical group when it leaves the group
Oct 22, 2026
a90257f
Merge remote-tracking branch 'origin/main' into OEvgeny/fix-logical-g…
Oct 22, 2026
a10e123
Add the changelog fragment
Oct 22, 2026
f1f2578
Subscribe by key: useGroupState(), useGroupDescendantIdByGroupKey()
Oct 22, 2026
b9d83a6
Keep a newer listener set on a repeated unsubscribe; test moving an a…
Oct 23, 2026
1e884ff
useKeyedSubscriptionValue: re-render with useState instead of a ref a…
Oct 23, 2026
39e625e
usePartGroupingLogicalGroup: create the group key with useMemo
Oct 23, 2026
b7bb61d
Rename addLogicalGrouping to setLogicalGrouping(key, grouping), symme…
Oct 23, 2026
adecbdf
Store isCollapsed on the logical grouping instead of a group state ob…
Oct 23, 2026
6adb788
useKeyedSubscriptionValue: keep the key with the value in the state, …
Oct 23, 2026
6af523a
useGroupDescendantIdByGroupKey: subscribe by group key instead of rel…
Oct 23, 2026
14e31ea
Simplify logical grouping to a keyed store
Oct 23, 2026
e0a12a0
Address review nits: private stores hook, getLogicalGrouping, one des…
Oct 23, 2026
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
3 changes: 3 additions & 0 deletions .changelog/5966-fix-logical-group-key-hook.md
Original file line number Diff line number Diff line change
@@ -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)
116 changes: 116 additions & 0 deletions __tests__/html2/part-grouping/strictMode.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<!doctype html>
<html lang="en-US">
<head>
<title>Part grouping: keeps the group after Strict Mode re-runs effects</title>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
<script type="importmap">
{
"imports": {
"@testduet/wait-for": "https://esm.sh/@testduet/wait-for",
"react": "https://esm.sh/react@18.3.1?dev",
"react-dom": "https://esm.sh/react-dom@18.3.1?dev",
"react-dom/client": "https://esm.sh/react-dom@18.3.1/client?dev"
}
}
</script>
<script crossorigin="anonymous" src="/test-harness.js"></script>
<script crossorigin="anonymous" src="/test-page-object.js"></script>
<script type="module">
// Strict Mode only re-runs effects with the development build of React 18 or later.
import React from 'react';
import ReactDOM from 'react-dom';

window.React = React;
window.ReactDOM = ReactDOM;
</script>
<script crossorigin="anonymous" defer src="/__dist__/webchat-es5.js"></script>
</head>
<body>
<main id="webchat"></main>
<script type="module">
import { waitFor } from '@testduet/wait-for';
import { createElement, StrictMode } from 'react';
import { createRoot } from 'react-dom/client';

run(async function () {
const {
WebChat: { ReactWebChat }
} = window; // Imports in UMD fashion.

const { directLine, store } = testHelpers.createDirectLineEmulator();
const root = createRoot(document.getElementById('webchat'));

const render = styleOptions =>
root.render(createElement(StrictMode, {}, createElement(ReactWebChat, { directLine, store, styleOptions })));

// GIVEN: Web Chat rendered in Strict Mode, which mounts, cleans up, and mounts every effect again.
render({ botAvatarInitials: 'B', hideSendBox: true });

await pageConditions.uiConnected();

const createActivity = (id, text) => ({
entities: [
{
'@context': 'https://schema.org',
'@id': '',
'@type': 'Message',
isPartOf: { '@id': '_:group-1', '@type': 'HowTo' },
type: 'https://schema.org/Message'
}
],
from: { role: 'bot' },
id,
text,
type: 'message'
});

const activeDescendant = () =>
document.getElementById(pageElements.transcript().getAttribute('aria-activedescendant'));
const avatarInitials = () =>
Array.from(document.querySelectorAll('.webchat__initialsAvatar__initials'), ({ textContent }) => textContent);

// WHEN: 2 messages of the same group arrive.
directLine.emulateIncomingActivity(createActivity('a-00001', 'Part 1 of the group.'));
directLine.emulateIncomingActivity(createActivity('a-00002', 'Part 2 of the group.'));

await pageConditions.numActivitiesShown(2);

// WHEN: Web Chat re-renders the messages without changing the group.
render({ botAvatarInitials: 'R', hideSendBox: true });

// THEN: Only the group shows an avatar, the messages inside it do not.
await waitFor(() => expect(avatarInitials()).toEqual(['R']));
await pageConditions.scrollStabilized();

await host.snapshot('local');

// WHEN: The transcript is focused.
pageObjects.focusTranscript();

// THEN: The last message of the group is focused.
await waitFor(() => expect(pageElements.focusedActivity()?.textContent).toContain('Part 2 of the group.'));

await host.snapshot('local');

// WHEN: Pressing ARROW_UP.
await host.sendKeys('ARROW_UP');

// THEN: The first message of the group is focused.
await waitFor(() => expect(pageElements.focusedActivity()?.textContent).toContain('Part 1 of the group.'));

await host.snapshot('local');

// WHEN: Pressing ARROW_UP again.
await host.sendKeys('ARROW_UP');

// THEN: The group is focused, not a message in it.
await waitFor(() => {
expect(activeDescendant()?.closest('.part-grouping-activity')).toBeTruthy();
expect(pageElements.focusedActivity()).toBeUndefined();
});

await host.snapshot('local');
});
</script>
</body>
</html>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions packages/component/src/Activity/StackedLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -50,7 +50,6 @@ type PartGroupingActivityProps = InferOutput<typeof partGroupingActivityPropsSch

const partGroupingFocusableActivityPropsSchema = pipe(
object({
activity: custom<WebChatActivity>(value => is(object({}), value)),
children: optional(reactNode()),
className: optional(string()),
groupKey: string()
Expand All @@ -64,15 +63,12 @@ type FocusablePartGroupingActivityProps = InferOutput<typeof partGroupingFocusab
const FocusablePartGroupingActivity = memo(function FocusablePartGroupingActivity(
props: FocusablePartGroupingActivityProps
) {
const { activity, children, className, groupKey } = validateProps(partGroupingFocusableActivityPropsSchema, props);
const { children, className, groupKey } = validateProps(partGroupingFocusableActivityPropsSchema, props);

const [activeDescendantId] = useActiveDescendantId();

const getGroupDescendantIdByActivityKey = useGetGroupDescendantIdByActivityKey();
const focusByGroupKey = useFocusByGroupKey();

const getKeyByActivity = useGetKeyByActivity();
const groupingActivityDescendantId = getGroupDescendantIdByActivityKey(getKeyByActivity(activity));
const [groupingActivityDescendantId] = useGroupDescendantIdByGroupKey(groupKey);

const focusSelf = useCallback<(withFocus?: boolean) => void>(
(withFocus?: boolean) => focusByGroupKey(groupKey, withFocus),
Expand Down Expand Up @@ -220,11 +216,7 @@ function PartGroupingActivity(props: PartGroupingActivityProps) {
);

return (
<FocusablePartGroupingActivity
activity={firstActivity}
className={classNames['part-grouping-activity']}
groupKey={groupKey}
>
<FocusablePartGroupingActivity className={classNames['part-grouping-activity']} groupKey={groupKey}>
<StackedLayoutRoot
hideAvatar={hasAvatar && !showAvatar}
isGroup={true}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import random from 'math-random';
import { useCallback, useEffect, useMemo } from 'react';
import { useRefFrom } from 'use-ref-from';
import { useStateWithRef } from 'use-state-with-ref';
import { useLayoutEffect, useMemo } from 'react';

import { useAddLogicalGrouping, useRemoveLogicalGrouping } from '../../../../../providers/ActivityLogicalGrouping';
import { useRemoveLogicalGrouping, useSetLogicalGrouping } from '../../../../../providers/ActivityLogicalGrouping';

type UsePartGroupingLogicalGroupOptions = {
activityKeys: readonly string[];
Expand All @@ -12,27 +10,18 @@ type UsePartGroupingLogicalGroupOptions = {

function usePartGroupingLogicalGroup({ activityKeys, isCollapsed }: UsePartGroupingLogicalGroupOptions): string {
// eslint-disable-next-line no-magic-numbers
const [partGroupKey, _setPartKey, partKeyRef] = useStateWithRef(() => `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]);
Comment thread
compulim marked this conversation as resolved.

return partGroupKey;
}
Expand Down
38 changes: 38 additions & 0 deletions packages/component/src/hooks/internal/createKeyedStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
type ReadonlyKeyedStore<T> = {
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<T> = ReadonlyKeyedStore<T> & {
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<T>(): KeyedStore<T> {
const eventTarget = new EventTarget();
const values = new Map<string, T>();

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 };
Loading
Loading