Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
613369d
Add a useSyncExternalStore() ponyfill to react-hooks
Nov 9, 2026
22a79f8
Read store values with useSyncExternalStore()
Nov 9, 2026
c02270f
Subscribe the graph to the store only while it has subscribers
Nov 9, 2026
b47e596
Replicate suggested actions to Redux after rendering, not while rende…
Nov 9, 2026
8673622
Render typingIndicator.scroll.html with React DOM 18
Nov 10, 2026
726d1eb
Keep the microphone button enabled as dictation starts
Nov 10, 2026
48be6ae
Fire mocked speech recognition events after start() returns
Nov 10, 2026
d8f64b9
Merge origin/main into OEvgeny/feat-use-sync-external-store
Nov 10, 2026
c3102ee
Show suggested actions in the same commit as their activity
Nov 10, 2026
8b48581
Check that dictation and suggested actions do not render half-way on …
Nov 10, 2026
68ecdc7
Wait for a frame before firing mocked speech recognition events
Nov 10, 2026
33be371
Keep React 18 in window.React working with the bundled React DOM 16
Nov 10, 2026
e12eda9
Move createKeyedStore() and useKeyedStoreValue() to react-hooks
Nov 10, 2026
67cc55e
Keep the suggested actions in a keyed store
Nov 10, 2026
68ec4a0
Check the version of React 18 in the useSyncExternalStore() spec
Nov 10, 2026
6becbc5
Explain when the useSyncExternalStore() fallback runs and when the ke…
Nov 10, 2026
107a499
Add the changelog fragment
Nov 10, 2026
fdc8952
Add the Fixed changelog entry
Nov 10, 2026
98c9197
Merge remote-tracking branch 'origin/main' into OEvgeny/feat-use-sync…
Nov 12, 2026
d599d58
Rename ReduxGraphProvider to ReduxStoreProvider
Nov 12, 2026
4bab5e6
Add added, changed and deleted activities to the cause of the activit…
Nov 12, 2026
2159def
Test that the graph is in sync with the store when read without subsc…
Nov 12, 2026
68eab02
Catch up with the store when the graph is read without subscribers
Nov 12, 2026
083fdbc
Extend the catch-up scenario of createGraphFromStore with a read with…
Nov 12, 2026
3bf66bc
Move the Redux context and its hooks to the redux-store package
Nov 12, 2026
d478c94
Say why the spec does not read the graph after both subscribers unsub…
Nov 12, 2026
ed06497
Merge remote-tracking branch 'origin/OEvgeny/feat-use-sync-external-s…
Nov 12, 2026
b83190d
Merge origin/main into OEvgeny/fix-activity-graph-catch-up
Nov 12, 2026
efac166
Add the changelog fragment
Nov 12, 2026
7edbda4
Catch up before a transaction, not inside one
Nov 13, 2026
340be84
Merge branch 'main' into OEvgeny/fix-activity-graph-catch-up
OEvgeny Nov 13, 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/6049-fix-activity-graph-catch-up.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed

- Fixed development builds throwing when a React 19.2 `<Activity>` shows Web Chat again, in PR [#6049](https://github.com/microsoft/BotFramework-WebChat/pull/6049), by [@OEvgeny](https://github.com/OEvgeny)
98 changes: 98 additions & 0 deletions __tests__/html2/react19/activity.hideShow.transcript.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<!doctype html>
<html lang="en-US">
<head>
<title>React 19: Web Chat keeps its transcript when an &lt;Activity&gt; hides and shows it</title>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
</head>
<body>
<main id="webchat"></main>
<script type="importmap">
{
"imports": {
"@testduet/wait-for": "https://esm.sh/@testduet/wait-for",
"botframework-webchat": "/__dist__/packages/bundle/static/botframework-webchat.js",
"react": "https://esm.sh/react@19.3.1?dev",
"react-dom": "https://esm.sh/react-dom@19.3.1?dev",
"react-dom/client": "https://esm.sh/react-dom@19.3.1/client?dev"
}
}
</script>
<script type="module">
import '/test-harness.mjs';
import '/test-page-object.mjs';

import { waitFor } from '@testduet/wait-for';
import { createStoreWithOptions, ReactWebChat, testIds } from 'botframework-webchat';
import { Activity, createElement, version } from 'react';
import { createRoot } from 'react-dom/client';

run(async function () {
// TODO: This is for `createDirectLineEmulator` and `pageElements` only, should find ways to eliminate this line.
window.WebChat = { createStoreWithOptions, testIds };

const { directLine, store } = testHelpers.createDirectLineEmulator();

// GIVEN: React 19.2 or later: a hidden <Activity> runs the cleanup of the effects of its children, and keeps their state.
expect(version).toBe('19.3.1');

const root = createRoot(document.getElementById('webchat'));
// A new element each time, as JSX does: showing Web Chat again renders it before its effects run again.
const render = mode =>
root.render(
createElement(
Activity,
{ mode },
createElement(ReactWebChat, { directLine, store, styleOptions: { hideSendBox: true } })
)
);

// WHEN: Web Chat is rendered in a visible <Activity> and the bot sends a message.
render('visible');

await pageConditions.uiConnected();
await directLine.emulateIncomingActivity('Hello, World!');
await pageConditions.numActivitiesShown(1);
await pageConditions.scrollStabilized();

const [activityElement] = pageElements.activities();

// THEN: Web Chat shows the message.
await host.snapshot('local');

// WHEN: The <Activity> hides Web Chat.
render('hidden');

await waitFor(() => expect(activityElement).toHaveProperty('offsetParent', null));

// WHEN: The host adds a message to the store while Web Chat is hidden.
// Not through the Direct Line emulator: a hidden Web Chat is disconnected, as when unmounted.
store.dispatch({
payload: {
activity: {
from: { id: 'bot', role: 'bot' },
id: 'a-00002',
text: 'Added while hidden.',
timestamp: new Date().toISOString(),
type: 'message'
}
},
type: 'DIRECT_LINE/INCOMING_ACTIVITY'
});

// WHEN: The <Activity> shows Web Chat again.
render('visible');

// THEN: Web Chat shows the element of the first message, which it kept while hidden, and the message added while hidden.
// No snapshot: after it is shown again, Web Chat loses some styles (React 19.2 <Activity> support is not complete yet).
await waitFor(() => expect(activityElement).not.toHaveProperty('offsetParent', null));
await pageConditions.numActivitiesShown(2);

expect(pageElements.activities()[0]).toBe(activityElement);
expect(pageElements.activityContents().map(({ textContent }) => textContent)).toEqual([
'Hello, World!',
'Added while hidden.'
]);
});
</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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<!doctype html>
<html lang="en-US">
<head>
<title>React 19: Web Chat shows activities added while an &lt;Activity&gt; hid it, without rendering on show</title>
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
</head>
<body>
<main id="webchat"></main>
<script type="importmap">
{
"imports": {
"@testduet/wait-for": "https://esm.sh/@testduet/wait-for",
"botframework-webchat": "/__dist__/packages/bundle/static/botframework-webchat.js",
"react": "https://esm.sh/react@19.3.1?dev",
"react-dom": "https://esm.sh/react-dom@19.3.1?dev",
"react-dom/client": "https://esm.sh/react-dom@19.3.1/client?dev"
}
}
</script>
<script type="module">
import '/test-harness.mjs';
import '/test-page-object.mjs';

import { waitFor } from '@testduet/wait-for';
import { createStoreWithOptions, ReactWebChat, testIds } from 'botframework-webchat';
import { Activity, createElement, version } from 'react';
import { createRoot } from 'react-dom/client';

run(async function () {
// TODO: This is for `createDirectLineEmulator` and `pageElements` only, should find ways to eliminate this line.
window.WebChat = { createStoreWithOptions, testIds };

const { directLine, store } = testHelpers.createDirectLineEmulator();

// GIVEN: React 19.2 or later: a hidden <Activity> runs the cleanup of the effects of its children, and keeps their state.
expect(version).toBe('19.3.1');

const root = createRoot(document.getElementById('webchat'));
// The same element each time: showing Web Chat again does not render it, only its effects run again.
const webChat = createElement(ReactWebChat, { directLine, store, styleOptions: { hideSendBox: true } });
const render = mode => root.render(createElement(Activity, { mode }, webChat));

// WHEN: Web Chat is rendered in a visible <Activity> and the bot sends a message.
render('visible');

await pageConditions.uiConnected();
await directLine.emulateIncomingActivity('Hello, World!');
await pageConditions.numActivitiesShown(1);
await pageConditions.scrollStabilized();

const [activityElement] = pageElements.activities();

// THEN: Web Chat shows the message.
await host.snapshot('local');

// WHEN: The <Activity> hides Web Chat.
render('hidden');

await waitFor(() => expect(activityElement).toHaveProperty('offsetParent', null));

// WHEN: The host adds a message to the store while Web Chat is hidden.
// Not through the Direct Line emulator: a hidden Web Chat is disconnected, as when unmounted.
store.dispatch({
payload: {
activity: {
from: { id: 'bot', role: 'bot' },
id: 'a-00002',
text: 'Added while hidden.',
timestamp: new Date().toISOString(),
type: 'message'
}
},
type: 'DIRECT_LINE/INCOMING_ACTIVITY'
});

// WHEN: The <Activity> shows Web Chat again.
render('visible');

// THEN: Web Chat shows the element of the first message, which it kept while hidden, and the message added while hidden.
// No snapshot: after it is shown again, Web Chat loses some styles (React 19.2 <Activity> support is not complete yet).
await waitFor(() => expect(activityElement).not.toHaveProperty('offsetParent', null));
await pageConditions.numActivitiesShown(2);

expect(pageElements.activities()[0]).toBe(activityElement);
expect(pageElements.activityContents().map(({ textContent }) => textContent)).toEqual([
'Hello, World!',
'Added while hidden.'
]);
});
</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.
58 changes: 52 additions & 6 deletions packages/core/src/graph/createGraphFromStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const incoming = (id: string) => ({

type RawJSON = readonly { readonly '@value': { readonly id: string } }[] | undefined;

function activityIdsOf(graph: ReturnType<typeof createGraphFromStore>): readonly string[] {
function activityIdsOf(graph: Pick<ReturnType<typeof createGraphFromStore>, 'getState'>): readonly string[] {
return Array.from(graph.getState().values()).flatMap(
node =>
(node['urn:microsoft:webchat:direct-line-activity:raw-json'] as RawJSON)?.map(({ '@value': { id } }) => id) ?? []
Expand Down Expand Up @@ -83,17 +83,15 @@ scenario('createGraphFromStore()', bdd => {

return outcome;
})
.then('the graph should not follow the store anymore', ({ subscriptions }, { graph, subscriber1 }) => {
.then('the graph should not follow the store anymore', ({ subscriptions }, { subscriber1 }) => {
// Does not read the graph: a read would catch up with the store, before the subscription below does.
expect(subscriptions.size).toBe(0);
expect(activityIdsOf(graph)).toEqual(['a-1', 'a-2']);
expect(subscriber1).toHaveBeenCalledTimes(1);
})
.when('a subscriber subscribes again', (_, { graph }) => {
const subscriber = jest.fn();

graph.subscribe(subscriber);

return { graph, subscriber };
return { graph, subscriber, unsubscribe: graph.subscribe(subscriber) };
})
.then(
'the graph should catch up with the store and notify the subscriber',
Expand All @@ -102,5 +100,53 @@ scenario('createGraphFromStore()', bdd => {
expect(subscriber).toHaveBeenCalledTimes(1);
expect(subscriptions.size).toBe(1);
}
)
// A hidden <Activity> (React 19.2+) runs the cleanup of effects, so the graph has no subscribers. When the host shows
// Web Chat again with a new element, it renders and reads the graph before its effects subscribe again.
.when('the subscriber unsubscribes, then an activity is received', ({ store }, outcome) => {
outcome.unsubscribe();
store.dispatch(incoming('a-4'));

return outcome;
})
.then(
'reading the graph should catch up with the store, without subscribing to it',
({ subscriptions }, { graph, subscriber }) => {
expect(activityIdsOf(graph)).toEqual(['a-1', 'a-2', 'a-3', 'a-4']);
expect(subscriptions.size).toBe(0);
expect(subscriber).toHaveBeenCalledTimes(1);
}
)
.and('reading the graph again should return the same state', (_, { graph }) =>
expect(graph.getState()).toBe(graph.getState())
);

bdd
.given('a graph without subscribers, created from a store with an activity', () => {
const store = createStore();

store.dispatch(incoming('a-1'));

return { graph: createGraphFromStore(store), store };
})
.when('an activity is received, then a transaction is made on the graph', ({ graph, store }) => {
let activityIdsInTransaction: readonly string[] = [];

store.dispatch(incoming('a-2'));

// The transaction reads the graph (auto-inversion) before the graph caught up with the store.
graph.act(graph => {
activityIdsInTransaction = activityIdsOf(graph);
graph.upsert({ '@id': '_:m1', '@type': 'MessageLike', text: 'Hello, World!' });
});

return { activityIdsInTransaction, graph };
})
.then('the transaction should see both activities', (_, { activityIdsInTransaction }) =>
expect(activityIdsInTransaction).toEqual(['a-1', 'a-2'])
)
.and('the graph should have both activities and the node', (_, { graph }) => {
expect(activityIdsOf(graph)).toEqual(['a-1', 'a-2']);
expect(graph.getState().has('_:m1')).toBe(true);
});
});
30 changes: 29 additions & 1 deletion packages/core/src/graph/createGraphFromStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,20 @@ import type { Activity } from '../reducers/activities/sort/types';

function createGraphFromStore(store: ReturnType<typeof createStore>): SlantGraph {
const graph = new SlantGraph();
const actOnGraph = graph.act.bind(graph);
let prevActivities: readonly Activity[] | undefined;
let transacting = false;

// act() is not re-entrant: reads inside a transaction (e.g. by middleware) must not catch up with the store.
const transact: typeof graph.act = fn => {
transacting = true;

try {
actOnGraph(fn);
} finally {
transacting = false;
}
};

// TODO: [P0] Except channel audience, we should be specific about all audience, e.g. name.
const channelAudience = parse(SlantNodeSchema, {
Expand Down Expand Up @@ -46,7 +59,7 @@ function createGraphFromStore(store: ReturnType<typeof createStore>): SlantGraph
// TODO: [P*] Supports deleting node from the graph.
const addedActivities = activitySet.difference(prevActivitySet);

graph.act(graph => {
transact(graph => {
for (const activity of addedActivities) {
const {
from: { role }
Expand Down Expand Up @@ -121,9 +134,24 @@ function createGraphFromStore(store: ReturnType<typeof createStore>): SlantGraph
// Follows the store only while the graph has subscribers: a graph nobody subscribes to (e.g. after unmount, or from a
// render that never committed) does not stay subscribed to the store.
const subscribeToGraph = graph.subscribe.bind(graph);
const getGraphState = graph.getState.bind(graph);
const subscribers = new Set<GraphSubscriber>();
let unsubscribeFromStore: (() => void) | undefined;

// Catches up with changes made while the graph has no subscribers, e.g. when a hidden <Activity> (React 19.2+) ran the
// cleanup of effects and renders before subscribing again. Returns the same state until the store activities change.
graph.getState = () => {
unsubscribeFromStore || transacting || handleStoreChange();

return getGraphState();
};

// Catches up before a transaction, so its middleware sees the activities, e.g. to link nodes to them.
graph.act = fn => {
unsubscribeFromStore || handleStoreChange();
transact(fn);
};

graph.subscribe = subscriber => {
const unsubscribe = subscribeToGraph(subscriber);

Expand Down
Loading