Repository navigation
Add useLogicalGroupKey() so rows re-render when their logical group changes #5966
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
Merged
Merged
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…
e63c522
Keep the activity-to-group map of part grouping in a ref
d20a9f6
Stop ignoring "Cannot update a component while rendering a different …
1121f33
Add the changelog fragment
c4d28ed
Sync activities already in the store into the graph before other layo…
3a4813a
Add useLogicalGroupKey() and register part groups in a layout effect
46cf270
Remove an activity from its logical group when it leaves the group
a90257f
Merge remote-tracking branch 'origin/main' into OEvgeny/fix-logical-g…
a10e123
Add the changelog fragment
f1f2578
Subscribe by key: useGroupState(), useGroupDescendantIdByGroupKey()
b9d83a6
Keep a newer listener set on a repeated unsubscribe; test moving an a…
1e884ff
useKeyedSubscriptionValue: re-render with useState instead of a ref a…
39e625e
usePartGroupingLogicalGroup: create the group key with useMemo
b7bb61d
Rename addLogicalGrouping to setLogicalGrouping(key, grouping), symme…
adecbdf
Store isCollapsed on the logical grouping instead of a group state ob…
6adb788
useKeyedSubscriptionValue: keep the key with the value in the state, …
6af523a
useGroupDescendantIdByGroupKey: subscribe by group key instead of rel…
14e31ea
Simplify logical grouping to a keyed store
e0a12a0
Address review nits: private stores hook, getLogicalGrouping, one des…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.