From 613369dc0d9bbd5f5bad3d1f1befca9fcfabbf13 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 9 Nov 2026 15:27:01 -0800 Subject: [PATCH 01/26] Add a useSyncExternalStore() ponyfill to react-hooks React 18 and later use their own hook. React 16/17 use a fallback that subscribes in one layout effect, which runs again only when subscribe changes. --- packages/react-hooks/src/index.ts | 1 + .../src/useSyncExternalStore.spec.tsx | 323 ++++++++++++++++++ .../react-hooks/src/useSyncExternalStore.ts | 67 ++++ 3 files changed, 391 insertions(+) create mode 100644 packages/react-hooks/src/useSyncExternalStore.spec.tsx create mode 100644 packages/react-hooks/src/useSyncExternalStore.ts diff --git a/packages/react-hooks/src/index.ts b/packages/react-hooks/src/index.ts index 4673f64313..7a09419382 100644 --- a/packages/react-hooks/src/index.ts +++ b/packages/react-hooks/src/index.ts @@ -2,3 +2,4 @@ export { default as useDebugDeps } from './useDebugDeps'; export { default as useFileDropZone, type DropZoneState } from './useFileDropZone'; export { default as useMemoIterable } from './useMemoIterable'; export { default as useMemoWithPrevious } from './useMemoWithPrevious'; +export { default as useSyncExternalStore } from './useSyncExternalStore'; diff --git a/packages/react-hooks/src/useSyncExternalStore.spec.tsx b/packages/react-hooks/src/useSyncExternalStore.spec.tsx new file mode 100644 index 0000000000..d892e90777 --- /dev/null +++ b/packages/react-hooks/src/useSyncExternalStore.spec.tsx @@ -0,0 +1,323 @@ +/** @jest-environment @happy-dom/jest-environment */ +/// + +import type { ReactElement, ReactNode } from 'react'; + +type Subscribe = (onStoreChange: () => void) => () => void; + +type ReaderProps = { readonly getSnapshot: () => number; readonly subscribe: Subscribe }; + +type Environment = { + readonly act: (fn: () => void) => void; + // Classic JSX runtime: JSX below uses this `React`, the React under test. + readonly React: typeof import('react'); + readonly render: (element: ReactElement) => { rerender(element: ReactElement): void; unmount(): void }; + readonly useSyncExternalStore: typeof import('./useSyncExternalStore').default; +}; + +// React 16.8.6 of the repository, with the fallback. +function loadReact16(): Environment { + let environment!: Environment; + + jest.isolateModules(() => { + const { act } = jest.requireActual('react-dom/test-utils'); + const ReactDOM = jest.requireActual('react-dom'); + + environment = { + act, + React: jest.requireActual('react'), + render: element => { + const container = document.createElement('div'); + + act(() => { + ReactDOM.render(element, container); + }); + + return { + rerender: element => + act(() => { + ReactDOM.render(element, container); + }), + unmount: () => + act(() => { + ReactDOM.unmountComponentAtNode(container); + }) + }; + }, + useSyncExternalStore: + jest.requireActual('./useSyncExternalStore').default + }; + }); + + return environment; +} + +// React 18.3.1 installed for the repack of "react-dom" (the repository pins React 16.8.6), with `useSyncExternalStore()` of React. +function loadReact18(): Environment { + let environment!: Environment; + + jest.isolateModules(() => { + // The same copy of React as React DOM 18 requires. + const React = jest.requireActual('../../repack/react-dom/node_modules/react'); + + jest.doMock('react', () => React); + + const { act } = jest.requireActual( + '../../repack/react-dom/node_modules/react-dom/test-utils' + ); + const { createRoot } = jest.requireActual<{ + createRoot(container: Element): { render(element: ReactElement): void; unmount(): void }; + }>('../../repack/react-dom/node_modules/react-dom/client'); + + environment = { + act, + React, + render: element => { + const root = createRoot(document.createElement('div')); + + act(() => root.render(element)); + + return { + rerender: element => act(() => root.render(element)), + unmount: () => act(() => root.unmount()) + }; + }, + // Its import of "react" gets the mock above. + useSyncExternalStore: + jest.requireActual('./useSyncExternalStore').default + }; + }); + + return environment; +} + +function createStore(initialValue: number) { + const listeners = new Set<() => void>(); + let value = initialValue; + + return { + getSnapshot: () => value, + listeners, + set(nextValue: number) { + value = nextValue; + listeners.forEach(listener => listener()); + }, + subscribe: jest.fn((listener: () => void) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }) + }; +} + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + jest.dontMock('react'); + jest.restoreAllMocks(); +}); + +describe.each([ + ['React 16 (fallback)', loadReact16], + ['React 18 (built-in)', loadReact18] +])('useSyncExternalStore() on %s', (_, load) => { + let environment: Environment; + + beforeEach(() => { + environment = load(); + }); + + function renderReader(props: ReaderProps) { + const { React, render, useSyncExternalStore } = environment; + const renders: number[] = []; + + function Reader({ getSnapshot, subscribe }: ReaderProps) { + renders.push(useSyncExternalStore(subscribe, getSnapshot)); + + return null; + } + + const { rerender, unmount } = render(); + + return { + renders, + rerender: (props: ReaderProps) => rerender(), + unmount + }; + } + + test('should render the snapshot, and render again only when it changes', () => { + const store = createStore(0); + const { renders } = renderReader({ getSnapshot: store.getSnapshot, subscribe: store.subscribe }); + + environment.act(() => store.set(1)); + environment.act(() => store.set(1)); // Notifies with the same snapshot. + + expect(renders).toEqual([0, 1]); + }); + + test('should render again for a change between render and subscription', () => { + const { act, React, render, useSyncExternalStore } = environment; + const store = createStore(0); + const renders: number[] = []; + + // Before the reader: its layout effect runs after the reader rendered, before it subscribes. + function SetOnMount() { + React.useLayoutEffect(() => store.set(1), []); + + return null; + } + + function Reader() { + renders.push(useSyncExternalStore(store.subscribe, store.getSnapshot)); + + return null; + } + + render( + + + + + ); + + expect(renders).toEqual([0, 1]); + + act(() => store.set(2)); + + expect(renders).toEqual([0, 1, 2]); + }); + + test('should subscribe again when "subscribe" changes, not when "getSnapshot" changes', () => { + const store = createStore(0); + const otherStore = createStore(10); + const { renders, rerender, unmount } = renderReader({ getSnapshot: store.getSnapshot, subscribe: store.subscribe }); + + rerender({ getSnapshot: () => store.getSnapshot() + 100, subscribe: store.subscribe }); + + expect(renders).toEqual([0, 100]); + expect(store.subscribe).toHaveBeenCalledTimes(1); + + rerender({ getSnapshot: otherStore.getSnapshot, subscribe: otherStore.subscribe }); + + expect(renders).toEqual([0, 100, 10]); + expect(store.listeners.size).toBe(0); + expect(otherStore.listeners.size).toBe(1); + + unmount(); + + expect(otherStore.listeners.size).toBe(0); + }); + + test('should subscribe once under , and unsubscribe after unmount', () => { + const { React, render, useSyncExternalStore } = environment; + const store = createStore(0); + + function Reader() { + useSyncExternalStore(store.subscribe, store.getSnapshot); + + return null; + } + + const { unmount } = render( + + + + ); + + expect(store.listeners.size).toBe(1); + + unmount(); + + expect(store.listeners.size).toBe(0); + }); + + test('should throw the error from "getSnapshot" while rendering', () => { + const { act, React, render, useSyncExternalStore } = environment; + const artificialError = new Error('Artificial error.'); + const errors: unknown[] = []; + const store = createStore(0); + + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + class ErrorBoundary extends React.Component<{ readonly children?: ReactNode }, { readonly error: unknown }> { + constructor(props: { readonly children?: ReactNode }) { + super(props); + + this.state = { error: undefined }; + } + + static getDerivedStateFromError(error: unknown) { + errors.includes(error) || errors.push(error); + + return { error }; + } + + override render() { + const { children } = this.props; + const { error } = this.state; + + return error ? null : children; + } + } + + function Reader() { + useSyncExternalStore(store.subscribe, () => { + if (store.getSnapshot() === 1) { + throw artificialError; + } + + return store.getSnapshot(); + }); + + return null; + } + + render( + + + + ); + + try { + act(() => store.set(1)); + } catch (error) { + // React 16 in development rethrows the error to the code that caused the render, before the error boundary gets it. + errors.includes(error) || errors.push(error); + } + + // React 18 renders again after an error, which throws the same error. + expect(errors).toEqual([artificialError]); + }); +}); + +describe('useSyncExternalStore() on React 18', () => { + test('should render a change from outside of React in a microtask, as the built-in hook does', async () => { + const { React, render, useSyncExternalStore } = loadReact18(); + const renders: number[] = []; + const store = createStore(0); + + function Reader() { + renders.push(useSyncExternalStore(store.subscribe, store.getSnapshot)); + + return null; + } + + render(); + + // Outside of act(), like a message from the chat adapter. + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = false; + store.set(1); + + expect(renders).toEqual([0]); + + // Sync lane: React renders in a microtask queued by the change; `useState()` would render in a later task. + await Promise.resolve(); + + expect(renders).toEqual([0, 1]); + }); +}); diff --git a/packages/react-hooks/src/useSyncExternalStore.ts b/packages/react-hooks/src/useSyncExternalStore.ts new file mode 100644 index 0000000000..a71fdf04b7 --- /dev/null +++ b/packages/react-hooks/src/useSyncExternalStore.ts @@ -0,0 +1,67 @@ +import React, { useLayoutEffect, useReducer, useRef } from 'react'; + +type Subscribe = (onStoreChange: () => void) => () => void; + +type UseSyncExternalStore = (subscribe: Subscribe, getSnapshot: () => T, getServerSnapshot?: () => T) => T; + +function increment(value: number): number { + return value + 1; +} + +// React 18 and later. Read from the default export: webpack 5 fails the build on a named or a namespace import of +// an export that React 16/17 does not have. +const { useSyncExternalStore: nativeUseSyncExternalStore } = React as typeof React & { + readonly useSyncExternalStore?: UseSyncExternalStore | undefined; +}; + +// React 16/17: subscribes in a layout effect, which runs again only when `subscribe` changes. +function useSyncExternalStoreFallback(subscribe: Subscribe, getSnapshot: () => T): T { + const [, forceRender] = useReducer(increment, 0); + const value = getSnapshot(); + const latest = Object.freeze({ getSnapshot, value }); + const latestRef = useRef(latest); + + // Legacy rendering of React 16/17 does not discard renders, so the ref is written while rendering. + latestRef.current = latest; + + useLayoutEffect(() => { + const handleChange = () => { + const { current } = latestRef; + + try { + const nextValue = current.getSnapshot(); + + if (Object.is(current.value, nextValue)) { + return; + } + + // Remembers the new value now, so another change before the re-render does not re-render again. + latestRef.current = Object.freeze({ ...current, value: nextValue }); + } catch { + // Re-renders to throw the error from `getSnapshot()`. + } + + forceRender(); + }; + + // Catches changes between render and subscription. + handleChange(); + + return subscribe(handleChange); + }, [forceRender, latestRef, subscribe]); + + return value; +} + +/** + * Ponyfill of `useSyncExternalStore()`: the one of React 18 and later, otherwise a fallback for React 16/17. + * + * `getSnapshot` must return the same value until the store changes. Keep `subscribe` stable: the fallback subscribes again when it changes. + */ +const useSyncExternalStore: UseSyncExternalStore = nativeUseSyncExternalStore + ? // Without `getServerSnapshot`, React 18 and later throw while rendering on the server. + (subscribe, getSnapshot, getServerSnapshot = getSnapshot) => + nativeUseSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) + : useSyncExternalStoreFallback; + +export default useSyncExternalStore; From 22a79f8c4a644594c50691b5a8e0e0df65d626ed Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 9 Nov 2026 15:32:54 -0800 Subject: [PATCH 02/26] Read store values with useSyncExternalStore() useSelector(), and useKeyedStoreValue() now use the ponyfill. reads the graph and the Redux activities as one snapshot, instead of a store subscription while rendering plus two layout effects. It follows the store through the batched subscription of useSelector(), so React 16/17 re-render it in the same batch as the selectors. Selectors move to module scope, so getSnapshot() stays the same between renders. --- .../api-graph/src/private/GraphProvider.tsx | 171 ++++++------------ packages/api/src/hooks/Composer.tsx | 6 +- .../src/hooks/internal/ReduxGraphProvider.tsx | 34 ++++ .../src/hooks/internal/WebChatReduxContext.ts | 81 ++------- .../internal/WebChatReduxProvider.spec.tsx | 23 +-- .../src/hooks/internal/useVoiceHandlers.ts | 7 +- .../hooks/internal/useVoiceStateWritable.ts | 4 +- .../useActivities.deleteActivity.spec.tsx | 10 +- packages/api/src/hooks/useActivities.ts | 50 +++-- .../api/src/hooks/useConnectivityStatus.ts | 4 +- packages/api/src/hooks/useDictateInterims.ts | 4 +- packages/api/src/hooks/useDictateState.ts | 4 +- packages/api/src/hooks/useNotifications.ts | 4 +- .../api/src/hooks/useReferenceGrammarID.ts | 4 +- .../api/src/hooks/useSendBoxAttachments.ts | 8 +- packages/api/src/hooks/useSendBoxValue.ts | 4 +- .../api/src/hooks/useSendTypingIndicator.ts | 4 +- .../hooks/useShouldSpeakIncomingActivity.ts | 4 +- .../api/src/hooks/useVoiceRecordingMuted.ts | 4 +- .../src/hooks/internal/useKeyedStoreValue.ts | 29 +-- 20 files changed, 193 insertions(+), 266 deletions(-) create mode 100644 packages/api/src/hooks/internal/ReduxGraphProvider.tsx diff --git a/packages/api-graph/src/private/GraphProvider.tsx b/packages/api-graph/src/private/GraphProvider.tsx index eac3710f24..d526c2305e 100644 --- a/packages/api-graph/src/private/GraphProvider.tsx +++ b/packages/api-graph/src/private/GraphProvider.tsx @@ -1,22 +1,14 @@ import type { DirectLineActivityNode } from '@msinternal/botframework-webchat-core-graph'; +import { useSyncExternalStore } from '@msinternal/botframework-webchat-react-hooks'; import { reactNode, validateProps } from '@msinternal/botframework-webchat-react-valibot'; import { createStore, WebChatActivity } from 'botframework-webchat-core'; -import { - createGraphFromStore, - isOfType, - type GraphSubscriber, - type Identifier -} from 'botframework-webchat-core/graph.js'; -import React, { memo, useLayoutEffect, useMemo, useReducer, useState } from 'react'; +import { createGraphFromStore, isOfType, type SlantGraph } from 'botframework-webchat-core/graph.js'; +import React, { memo, useCallback, useMemo } from 'react'; import { custom, function_, is, object, optional, parse, pipe, readonly, type InferInput } from 'valibot'; import GraphContext, { graphContextSchema, GraphContextType } from './GraphContext'; -const EMPTY_ARRAY = Object.freeze([]); - -function increment(value: number): number { - return value + 1; -} +const EMPTY_ARRAY: readonly WebChatActivity[] = Object.freeze([]); const graphProviderPropsSchema = pipe( object({ @@ -30,117 +22,72 @@ const graphProviderPropsSchema = pipe( type GraphProviderProps = InferInput; -function GraphProvider(props: GraphProviderProps) { - const { children, store } = validateProps(graphProviderPropsSchema, props); - - const [, forceRender] = useReducer(increment, 0); - - const [graph, subscribeToActivities] = useMemo(() => { - const graph = createGraphFromStore(store); - const listeners = new Set<() => void>(); - let { activities } = store.getState(); - - // The graph does not delete nodes (e.g. DIRECT_LINE/DELETE_ACTIVITY), so re-render to filter them out when Redux activities change. - // Subscribes while rendering, after the graph: a layout effect would be notified after the components below, which read Redux activities. - // Remove when the graph supports deleting nodes. - store.subscribe(() => { - const { activities: nextActivities } = store.getState(); - - if (!Object.is(activities, nextActivities)) { - activities = nextActivities; - listeners.forEach(listener => listener()); - } - }); - - return Object.freeze([ - graph, - (listener: () => void) => { - listeners.add(listener); - - return () => { - listeners.delete(listener); - }; - } - ] as const); - }, [store]); - - useLayoutEffect(() => subscribeToActivities(forceRender), [forceRender, subscribeToActivities]); - - const [orderedActivityNodes, setOrderedActivityNodes] = useState(EMPTY_ARRAY); - - // 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(); - - setOrderedActivityNodes(prevOrderedMessages => { - let nextOrderedMessageMap: Map | undefined; - - for (const id of record.upsertedNodeIdentifiers) { - const node = state.get(id); - - if (node && isOfType(node, 'urn:microsoft:webchat:direct-line-activity')) { - const activityNode = node as DirectLineActivityNode; - - if (!nextOrderedMessageMap) { - nextOrderedMessageMap = new Map(prevOrderedMessages.map(node => [node['@id'], node])); - } - - const permanentId = activityNode['@id']; - - nextOrderedMessageMap.delete(permanentId); - nextOrderedMessageMap.set(permanentId, activityNode); - } - } - - if (nextOrderedMessageMap) { - // TODO: [P0] Insertion sort is cheaper by 20x if inserting 1 activity into a list of 1,000 activities. - return Object.freeze( - Array.from(nextOrderedMessageMap.values()).sort((x, y) => x.position[0] - y.position[0]) - ); - } - - return prevOrderedMessages; - }); - }; - - const unsubscribe = graph.subscribe(handleChange); - - // Triggers initial sync. - // Activities queued before Web Chat mounted should be synchronized. - handleChange({ upsertedNodeIdentifiers: new Set(graph.getState().keys()) }); +// Activities in the order of the graph, without the ones no longer in Redux: the graph does not delete nodes +// (e.g. DIRECT_LINE/DELETE_ACTIVITY or pruned livestream revisions). Returns the same array until either changes. +function createGetOrderedActivities( + graph: SlantGraph, + store: GraphProviderProps['store'] +): () => readonly WebChatActivity[] { + let prevGraphState: ReturnType | undefined; + let prevStoreActivities: readonly WebChatActivity[] | undefined; + let orderedActivities = EMPTY_ARRAY; + + return () => { + const graphState = graph.getState(); + const { activities: storeActivities }: { activities: readonly WebChatActivity[] } = store.getState(); + + if (graphState !== prevGraphState || storeActivities !== prevStoreActivities) { + const storeActivitySet = new Set(storeActivities); + + prevGraphState = graphState; + prevStoreActivities = storeActivities; + + // TODO: [P0] Insertion sort is cheaper by 20x if inserting 1 activity into a list of 1,000 activities. + orderedActivities = Object.freeze( + Array.from(graphState.values()) + .filter((node): node is DirectLineActivityNode => + isOfType(node, 'urn:microsoft:webchat:direct-line-activity') + ) + .sort((x, y) => x.position[0] - y.position[0]) + .map(node => node['urn:microsoft:webchat:direct-line-activity:raw-json'][0]['@value'] as WebChatActivity) + .filter(activity => storeActivitySet.has(activity)) + ); + } - return () => { - unsubscribe(); - setOrderedActivityNodes(EMPTY_ARRAY); - }; - }, [graph, setOrderedActivityNodes]); + return orderedActivities; + }; +} - const { activities: storeActivities } = store.getState(); +function GraphProvider(props: GraphProviderProps) { + const { children, store } = validateProps(graphProviderPropsSchema, props); - const orderedActivitiesState = useMemo(() => { - // Filter out stale graph nodes for activities no longer in Redux (e.g. pruned livestream revisions). - // The graph does not support deletion, so stale nodes linger after computeSortedActivities prunes them. - const validActivitySet = new Set(storeActivities); - const activities: WebChatActivity[] = []; + const graph = useMemo(() => createGraphFromStore(store), [store]); - for (const node of orderedActivityNodes) { - const activity = node['urn:microsoft:webchat:direct-line-activity:raw-json'][0]['@value'] as WebChatActivity; + // Follows the graph for new and updated activities, and Redux for deleted activities. + const subscribe = useCallback( + (listener: () => void) => { + const unsubscribeGraph = graph.subscribe(listener); + const unsubscribeStore = store.subscribe(listener); - validActivitySet.has(activity) && activities.push(activity); - } + return () => { + unsubscribeGraph(); + unsubscribeStore(); + }; + }, + [graph, store] + ); - return Object.freeze([Object.freeze(activities)] as const); - }, [orderedActivityNodes, storeActivities]); + const orderedActivities = useSyncExternalStore( + subscribe, + useMemo(() => createGetOrderedActivities(graph, store), [graph, store]) + ); const context = useMemo( () => parse(graphContextSchema, { - orderedActivitiesState + orderedActivitiesState: Object.freeze([orderedActivities] as const) }), - [orderedActivitiesState] + [orderedActivities] ); return {children}; diff --git a/packages/api/src/hooks/Composer.tsx b/packages/api/src/hooks/Composer.tsx index ae75cb0829..5078558d05 100644 --- a/packages/api/src/hooks/Composer.tsx +++ b/packages/api/src/hooks/Composer.tsx @@ -1,4 +1,3 @@ -import { GraphProvider } from '@msinternal/botframework-webchat-api-graph'; import { PolymiddlewareComposer, type Polymiddleware } from '@msinternal/botframework-webchat-api-middleware'; import { type LegacyActivityMiddleware, @@ -96,6 +95,7 @@ import TypingIndicatorMiddleware from '../types/TypingIndicatorMiddleware'; import createCustomEvent from '../utils/createCustomEvent'; import mapMap from '../utils/mapMap'; import normalizeLanguage from '../utils/normalizeLanguage'; +import ReduxGraphProvider from './internal/ReduxGraphProvider'; import Tracker from './internal/Tracker'; import WebChatAPIContext, { type WebChatAPIContextType } from './internal/WebChatAPIContext'; import { useDispatch } from './internal/WebChatReduxContext'; @@ -847,13 +847,13 @@ const ComposerWithStore = (propsWithStore: ComposerWithStoreProps) => { return ( - + - + ); diff --git a/packages/api/src/hooks/internal/ReduxGraphProvider.tsx b/packages/api/src/hooks/internal/ReduxGraphProvider.tsx new file mode 100644 index 0000000000..2c34ed0522 --- /dev/null +++ b/packages/api/src/hooks/internal/ReduxGraphProvider.tsx @@ -0,0 +1,34 @@ +import { GraphProvider } from '@msinternal/botframework-webchat-api-graph'; +import { reactNode, validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; +import React, { memo, useMemo } from 'react'; +import { object, optional, pipe, readonly } from 'valibot'; + +import { useWebChatReduxContext } from './WebChatReduxContext'; + +const reduxGraphProviderPropsSchema = pipe( + object({ + children: optional(reactNode()) + }), + readonly() +); + +type ReduxGraphProviderProps = InferReactProps; + +/** + * `` for the store of ``, following it through the subscription of `useSelector()`. + * + * React 16/17 then re-render `` in the same batch as the selectors, after the graph has the change. + */ +function ReduxGraphProvider(props: ReduxGraphProviderProps) { + const { children } = validateProps(reduxGraphProviderPropsSchema, props); + const { store, subscribe } = useWebChatReduxContext(); + + const graphStore = useMemo(() => Object.freeze({ ...store, subscribe }), [store, subscribe]); + + return {children}; +} + +ReduxGraphProvider.displayName = 'ReduxGraphProvider'; + +export default memo(ReduxGraphProvider); +export { reduxGraphProviderPropsSchema, type ReduxGraphProviderProps }; diff --git a/packages/api/src/hooks/internal/WebChatReduxContext.ts b/packages/api/src/hooks/internal/WebChatReduxContext.ts index 5d75268e9e..b12d0d9437 100644 --- a/packages/api/src/hooks/internal/WebChatReduxContext.ts +++ b/packages/api/src/hooks/internal/WebChatReduxContext.ts @@ -1,5 +1,6 @@ +import { useSyncExternalStore } from '@msinternal/botframework-webchat-react-hooks'; import type { createStore } from 'botframework-webchat-core'; -import { createContext, useContext, useLayoutEffect, useReducer, useRef } from 'react'; +import { createContext, useCallback, useContext } from 'react'; type WebChatReduxStore = ReturnType; @@ -12,14 +13,6 @@ const context = createContext(undefined); context.displayName = 'WebChatReduxContext'; -function increment(value: number): number { - return value + 1; -} - -function refEquality(x: T, y: T): boolean { - return x === y; -} - function useWebChatReduxContext(): WebChatReduxContextType { const value = useContext(context); @@ -39,66 +32,26 @@ function useDispatch(): WebChatReduxStore['dispatch'] { } /** - * Selects a value from the Redux store and re-renders when it changes, like `useSelector()` of `react-redux@7`. + * Selects a value from the Redux store and re-renders when it changes, with `useSyncExternalStore()`. * - * Re-renders are scheduled by `useReducer()`, at the same priority as `useState()` of other components that follow the store. - * `useSyncExternalStore()` would re-render with a higher priority on React 18 and later, - * ahead of activities that `` propagates through `useState()`. + * Declare `selector` outside of the component: a new function on every render makes React check the store again after every render. */ -function useSelector( - selector: (state: ReturnType) => T, - equalityFn: (x: T, y: T) => boolean = refEquality -): T { +function useSelector(selector: (state: ReturnType) => T): T { const { store, subscribe } = useWebChatReduxContext(); - const [, forceRender] = useReducer(increment, 0); - const latestRef = useRef< - | { - readonly equalityFn: (x: T, y: T) => boolean; - readonly selector: (state: ReturnType) => T; - readonly value: T; - } - | undefined - >(); - - const nextValue = selector(store.getState()); - - // Keeps the previous value if it is equal, so a custom `equalityFn` returns a stable reference. - const value = - latestRef.current && equalityFn(latestRef.current.value, nextValue) ? latestRef.current.value : nextValue; - - useLayoutEffect(() => { - latestRef.current = Object.freeze({ equalityFn, selector, value }); - }); - - useLayoutEffect(() => { - const handleChange = () => { - const { current } = latestRef; - try { - const nextValue = current.selector(store.getState()); - - if (current.equalityFn(current.value, nextValue)) { - return; - } - - // Remembers the new value now, so a dispatch before the re-render (e.g. during render) does not re-render again. - latestRef.current = Object.freeze({ ...current, value: nextValue }); - } catch { - // Re-renders to throw the error from the selector. - } - - forceRender(); - }; - - // Catches changes between render and subscription. - handleChange(); - - return subscribe(handleChange); - }, [forceRender, latestRef, store, subscribe]); - - return value; + return useSyncExternalStore( + subscribe, + useCallback(() => selector(store.getState()), [selector, store]) + ); } export default context; -export { useDispatch, useSelector, useStore, type WebChatReduxContextType, type WebChatReduxStore }; +export { + useDispatch, + useSelector, + useStore, + useWebChatReduxContext, + type WebChatReduxContextType, + type WebChatReduxStore +}; diff --git a/packages/api/src/hooks/internal/WebChatReduxProvider.spec.tsx b/packages/api/src/hooks/internal/WebChatReduxProvider.spec.tsx index 101fb1dc9c..3ae64f3e34 100644 --- a/packages/api/src/hooks/internal/WebChatReduxProvider.spec.tsx +++ b/packages/api/src/hooks/internal/WebChatReduxProvider.spec.tsx @@ -12,6 +12,8 @@ import WebChatReduxProvider from './WebChatReduxProvider'; type State = { readonly count: number; readonly other: number }; +const selectCount = ({ count }: State) => count; + function reducer( state: State = { count: 0, other: 0 }, { payload, type }: { payload?: number | undefined; type: string } @@ -33,13 +35,13 @@ scenario( const renders: (readonly [number, number])[] = []; const Child = memo(({ countFromParent }: { readonly countFromParent: number }) => { - renders.push([countFromParent, useSelector(({ count }: State) => count)]); + renders.push([countFromParent, useSelector(selectCount)]); return null; }); function Parent() { - return count)} />; + return ; } const store = createStore(reducer); @@ -67,16 +69,11 @@ scenario( ); bdd - .given('a component selecting a new array on every call, compared by its content', () => { - const renders: (readonly number[])[] = []; + .given('a component selecting one value of the state', () => { + const renders: number[] = []; function Counter() { - renders.push( - useSelector( - ({ count }: State) => [count], - ([x], [y]) => x === y - ) - ); + renders.push(useSelector(selectCount)); return null; } @@ -96,13 +93,13 @@ scenario( return renders; }) - .then('should not re-render', (_, renders) => expect(renders).toEqual([[0]])) + .then('should not re-render', (_, renders) => expect(renders).toEqual([0])) .when('dispatching an action that changes the selected value', ({ renders, store }) => { store.dispatch({ type: 'INCREMENT_COUNT' }); return renders; }) - .then('should re-render once', (_, renders) => expect(renders).toEqual([[0], [1]])); + .then('should re-render once', (_, renders) => expect(renders).toEqual([0, 1])); bdd .given('a component dispatching while rendering', () => { @@ -110,7 +107,7 @@ scenario( const store = createStore(reducer); function Counter() { - renders.push(useSelector(({ count }: State) => count)); + renders.push(useSelector(selectCount)); // Dispatches on every render, like calling the setter of `useSendBoxValue()` in render. store.dispatch({ payload: 1, type: 'SET_COUNT' }); diff --git a/packages/api/src/hooks/internal/useVoiceHandlers.ts b/packages/api/src/hooks/internal/useVoiceHandlers.ts index 63eb4c3925..ae14015314 100644 --- a/packages/api/src/hooks/internal/useVoiceHandlers.ts +++ b/packages/api/src/hooks/internal/useVoiceHandlers.ts @@ -1,11 +1,12 @@ import type { VoiceHandler } from 'botframework-webchat-core'; import { useSelector } from './WebChatReduxContext'; +const selectVoiceHandlers = (state: { voice: { voiceHandlers: Map } }) => + state.voice.voiceHandlers; + /** * Internal hook to get all registered voice handlers from Redux state. */ export default function useVoiceHandlers(): readonly [ReadonlyMap] { - return Object.freeze([ - useSelector((state: { voice: { voiceHandlers: Map } }) => state.voice.voiceHandlers) - ]); + return Object.freeze([useSelector(selectVoiceHandlers)]); } diff --git a/packages/api/src/hooks/internal/useVoiceStateWritable.ts b/packages/api/src/hooks/internal/useVoiceStateWritable.ts index 4d3a409edb..034b66e59b 100644 --- a/packages/api/src/hooks/internal/useVoiceStateWritable.ts +++ b/packages/api/src/hooks/internal/useVoiceStateWritable.ts @@ -2,6 +2,8 @@ import { setVoiceState, type VoiceState } from 'botframework-webchat-core'; import { useCallback } from 'react'; import { useDispatch, useSelector } from './WebChatReduxContext'; +const selectVoiceState = ({ voice }) => voice.voiceState; + /** * Internal hook to set the voice state. */ @@ -13,6 +15,6 @@ export default function useVoiceStateWritable(): readonly [VoiceState, (state: V }, [dispatch] ); - const value = useSelector(({ voice }) => voice.voiceState); + const value = useSelector(selectVoiceState); return Object.freeze([value, setter]); } diff --git a/packages/api/src/hooks/useActivities.deleteActivity.spec.tsx b/packages/api/src/hooks/useActivities.deleteActivity.spec.tsx index 96e4ee35d7..2a5302989c 100644 --- a/packages/api/src/hooks/useActivities.deleteActivity.spec.tsx +++ b/packages/api/src/hooks/useActivities.deleteActivity.spec.tsx @@ -17,8 +17,8 @@ const deleteActivity = (activityID: string) => ({ payload: { activityID }, type: function renderActivities() { let modules: { readonly act: typeof import('@testing-library/react/pure').act; - readonly GraphProvider: typeof import('@msinternal/botframework-webchat-api-graph').GraphProvider; readonly React: typeof import('react'); + readonly ReduxGraphProvider: typeof import('./internal/ReduxGraphProvider').default; readonly render: typeof import('@testing-library/react/pure').render; readonly useActivities: typeof import('./useActivities').default; readonly WebChatReduxProvider: typeof import('./internal/WebChatReduxProvider').default; @@ -29,15 +29,15 @@ function renderActivities() { modules = { act, - GraphProvider: require('@msinternal/botframework-webchat-api-graph').GraphProvider, React: require('react'), + ReduxGraphProvider: require('./internal/ReduxGraphProvider').default, render, useActivities: require('./useActivities').default, WebChatReduxProvider: require('./internal/WebChatReduxProvider').default }; }); - const { act, GraphProvider, React, render, useActivities, WebChatReduxProvider } = modules; + const { act, React, ReduxGraphProvider, render, useActivities, WebChatReduxProvider } = modules; const errors: unknown[] = []; const renders: (readonly string[])[] = []; const store = createStore(); @@ -75,9 +75,9 @@ function renderActivities() { const { unmount } = render( - + - + ); diff --git a/packages/api/src/hooks/useActivities.ts b/packages/api/src/hooks/useActivities.ts index 21ffa83806..c6db5813e8 100644 --- a/packages/api/src/hooks/useActivities.ts +++ b/packages/api/src/hooks/useActivities.ts @@ -1,8 +1,7 @@ import { useOrderedActivities } from '@msinternal/botframework-webchat-api-graph'; import type { WebChatActivity } from 'botframework-webchat-core'; -import { useSelector, useStore } from './internal/WebChatReduxContext'; -import usePrevious from './internal/usePrevious'; +import { useSelector } from './internal/WebChatReduxContext'; declare const process: { env: { @@ -10,6 +9,8 @@ declare const process: { }; }; +const selectActivities = ({ activities }) => activities; + function useActivitiesForProduction(): readonly [readonly WebChatActivity[]] { return useOrderedActivities(); } @@ -17,44 +18,35 @@ function useActivitiesForProduction(): readonly [readonly WebChatActivity[]] { function useActivitiesForDevelopment(): readonly [readonly WebChatActivity[]] { const activitiesFromGraphState = useActivitiesForProduction(); - // Checks if store changed. - const store = useStore(); - const prevStore = usePrevious(store); - const [activitiesFromGraph] = activitiesFromGraphState; - const activitiesFromRedux = useSelector(({ activities }) => activities); + const activitiesFromRedux = useSelector(selectActivities); + + if (activitiesFromGraph.length !== activitiesFromRedux.length) { + throw new Error( + `botframework-webchat-internal: Activities from graph and Redux are of different size (graph has ${activitiesFromGraph.length} activities, Redux has ${activitiesFromRedux.length} activities)`, + { + cause: { + activitiesFromGraph, + activitiesFromRedux + } + } + ); + } - // If store changed, skip one assertion turn. - // This is because is using `useState()` for propagating changes. - // It is always one render behind if `store` changed. - if (prevStore === store) { - if (activitiesFromGraph.length !== activitiesFromRedux.length) { + for (let index = 0; index < activitiesFromGraph.length; index++) { + if (!Object.is(activitiesFromGraph.at(index), activitiesFromRedux.at(index))) { throw new Error( - `botframework-webchat-internal: Activities from graph and Redux are of different size (graph has ${activitiesFromGraph.length} activities, Redux has ${activitiesFromRedux.length} activities)`, + `botframework-webchat-internal: Activities from graph and Redux are different at index ${index}`, { cause: { activitiesFromGraph, - activitiesFromRedux + activitiesFromRedux, + index } } ); } - - for (let index = 0; index < activitiesFromGraph.length; index++) { - if (!Object.is(activitiesFromGraph.at(index), activitiesFromRedux.at(index))) { - throw new Error( - `botframework-webchat-internal: Activities from graph and Redux are different at index ${index}`, - { - cause: { - activitiesFromGraph, - activitiesFromRedux, - index - } - } - ); - } - } } return activitiesFromGraphState; diff --git a/packages/api/src/hooks/useConnectivityStatus.ts b/packages/api/src/hooks/useConnectivityStatus.ts index a4909cf5b6..37449991c6 100644 --- a/packages/api/src/hooks/useConnectivityStatus.ts +++ b/packages/api/src/hooks/useConnectivityStatus.ts @@ -1,5 +1,7 @@ import { useSelector } from './internal/WebChatReduxContext'; +const selectConnectivityStatus = ({ connectivityStatus }) => connectivityStatus; + export default function useConnectivityStatus(): [string] { - return [useSelector(({ connectivityStatus }) => connectivityStatus)]; + return [useSelector(selectConnectivityStatus)]; } diff --git a/packages/api/src/hooks/useDictateInterims.ts b/packages/api/src/hooks/useDictateInterims.ts index 3ec6602340..eecf390df6 100644 --- a/packages/api/src/hooks/useDictateInterims.ts +++ b/packages/api/src/hooks/useDictateInterims.ts @@ -1,6 +1,8 @@ import { useSelector } from './internal/WebChatReduxContext'; import useWebChatAPIContext from './internal/useWebChatAPIContext'; +const selectDictateInterims = ({ dictateInterims }) => dictateInterims; + export default function useDictateInterims(): [string[], (interims: string[]) => void] { - return [useSelector(({ dictateInterims }) => dictateInterims) || [], useWebChatAPIContext().setDictateInterims]; + return [useSelector(selectDictateInterims) || [], useWebChatAPIContext().setDictateInterims]; } diff --git a/packages/api/src/hooks/useDictateState.ts b/packages/api/src/hooks/useDictateState.ts index cf0ca87a89..8d6d471514 100644 --- a/packages/api/src/hooks/useDictateState.ts +++ b/packages/api/src/hooks/useDictateState.ts @@ -1,6 +1,8 @@ import { useSelector } from './internal/WebChatReduxContext'; // TODO: [P3] We should update this code to use core/src/selectors/dictateState.js +const selectDictateState = ({ dictateState }) => dictateState; + export default function useDictateState(): [number] { - return [useSelector(({ dictateState }) => dictateState)]; + return [useSelector(selectDictateState)]; } diff --git a/packages/api/src/hooks/useNotifications.ts b/packages/api/src/hooks/useNotifications.ts index 02a322fb3b..063d0b0b79 100644 --- a/packages/api/src/hooks/useNotifications.ts +++ b/packages/api/src/hooks/useNotifications.ts @@ -1,6 +1,8 @@ import { type Notification } from '../types/Notification'; import { useSelector } from './internal/WebChatReduxContext'; +const selectNotifications = ({ notifications }) => notifications; + export default function useNotifications(): [Notification[]] { - return [useSelector(({ notifications }) => notifications)]; + return [useSelector(selectNotifications)]; } diff --git a/packages/api/src/hooks/useReferenceGrammarID.ts b/packages/api/src/hooks/useReferenceGrammarID.ts index 0bb302dddb..16b3464b21 100644 --- a/packages/api/src/hooks/useReferenceGrammarID.ts +++ b/packages/api/src/hooks/useReferenceGrammarID.ts @@ -1,5 +1,7 @@ import { useSelector } from './internal/WebChatReduxContext'; +const selectReferenceGrammarID = ({ referenceGrammarID }) => referenceGrammarID; + export default function useReferenceGrammarID(): [string] { - return [useSelector(({ referenceGrammarID }) => referenceGrammarID)]; + return [useSelector(selectReferenceGrammarID)]; } diff --git a/packages/api/src/hooks/useSendBoxAttachments.ts b/packages/api/src/hooks/useSendBoxAttachments.ts index 82ee0cbd1f..a6d1e4a16a 100644 --- a/packages/api/src/hooks/useSendBoxAttachments.ts +++ b/packages/api/src/hooks/useSendBoxAttachments.ts @@ -4,16 +4,16 @@ import { useMemo } from 'react'; import { useSelector } from './internal/WebChatReduxContext'; import useWebChatAPIContext from './internal/useWebChatAPIContext'; +// TODO: We should use the selector from "core" package. +const selectSendBoxAttachments = ({ sendBoxAttachments }) => sendBoxAttachments as readonly SendBoxAttachment[]; + export default function useSendBoxAttachments(): readonly [ readonly SendBoxAttachment[], // TODO: This should be Dispatch>, however Redux doesn't support this signature. // When we move out of Redux, we should change it. (attachments: readonly SendBoxAttachment[]) => void ] { - // TODO: We should use the selector from "core" package. - const sendBoxAttachments = useSelector( - ({ sendBoxAttachments }) => sendBoxAttachments as readonly SendBoxAttachment[] - ); + const sendBoxAttachments = useSelector(selectSendBoxAttachments); const { setSendBoxAttachments } = useWebChatAPIContext(); return useMemo( diff --git a/packages/api/src/hooks/useSendBoxValue.ts b/packages/api/src/hooks/useSendBoxValue.ts index aa7611d9ac..20311c17a3 100644 --- a/packages/api/src/hooks/useSendBoxValue.ts +++ b/packages/api/src/hooks/useSendBoxValue.ts @@ -1,6 +1,8 @@ import { useSelector } from './internal/WebChatReduxContext'; import useWebChatAPIContext from './internal/useWebChatAPIContext'; +const selectSendBoxValue = ({ sendBoxValue }) => sendBoxValue; + export default function useSendBoxValue(): [string, (value: string) => void] { - return [useSelector(({ sendBoxValue }) => sendBoxValue), useWebChatAPIContext().setSendBox]; + return [useSelector(selectSendBoxValue), useWebChatAPIContext().setSendBox]; } diff --git a/packages/api/src/hooks/useSendTypingIndicator.ts b/packages/api/src/hooks/useSendTypingIndicator.ts index 68d3a6a266..f401fe4020 100644 --- a/packages/api/src/hooks/useSendTypingIndicator.ts +++ b/packages/api/src/hooks/useSendTypingIndicator.ts @@ -1,5 +1,7 @@ import { useSelector } from './internal/WebChatReduxContext'; +const selectSendTypingIndicator = ({ sendTypingIndicator }) => sendTypingIndicator; + export default function useSendTypingIndicator(): [boolean] { - return [useSelector(({ sendTypingIndicator }) => sendTypingIndicator)]; + return [useSelector(selectSendTypingIndicator)]; } diff --git a/packages/api/src/hooks/useShouldSpeakIncomingActivity.ts b/packages/api/src/hooks/useShouldSpeakIncomingActivity.ts index 1cc3ba860a..eb5fd36474 100644 --- a/packages/api/src/hooks/useShouldSpeakIncomingActivity.ts +++ b/packages/api/src/hooks/useShouldSpeakIncomingActivity.ts @@ -3,11 +3,13 @@ import { useCallback } from 'react'; import { useSelector } from './internal/WebChatReduxContext'; import useWebChatAPIContext from './internal/useWebChatAPIContext'; +const selectShouldSpeakIncomingActivity = ({ shouldSpeakIncomingActivity }) => shouldSpeakIncomingActivity; + export default function useShouldSpeakIncomingActivity(): [boolean, (value: boolean) => void] { const { startSpeakingActivity, stopSpeakingActivity } = useWebChatAPIContext(); return [ - useSelector(({ shouldSpeakIncomingActivity }) => shouldSpeakIncomingActivity), + useSelector(selectShouldSpeakIncomingActivity), useCallback( value => { value ? startSpeakingActivity() : stopSpeakingActivity(); diff --git a/packages/api/src/hooks/useVoiceRecordingMuted.ts b/packages/api/src/hooks/useVoiceRecordingMuted.ts index e291b5d772..43217a4a3b 100644 --- a/packages/api/src/hooks/useVoiceRecordingMuted.ts +++ b/packages/api/src/hooks/useVoiceRecordingMuted.ts @@ -2,6 +2,8 @@ import { muteVoiceRecording, unmuteVoiceRecording } from 'botframework-webchat-c import { useCallback } from 'react'; import { useDispatch, useSelector } from './internal/WebChatReduxContext'; +const selectMicrophoneMuted = ({ voice }) => voice.microphoneMuted; + /** * Hook to get and set voice recording mute state in speech-to-speech mode. * @@ -11,7 +13,7 @@ import { useDispatch, useSelector } from './internal/WebChatReduxContext'; */ export default function useVoiceRecordingMuted(): readonly [boolean, (muted: boolean) => void] { const dispatch = useDispatch(); - const value = useSelector(({ voice }) => voice.microphoneMuted); + const value = useSelector(selectMicrophoneMuted); const setter = useCallback( (muted: boolean) => { diff --git a/packages/component/src/hooks/internal/useKeyedStoreValue.ts b/packages/component/src/hooks/internal/useKeyedStoreValue.ts index 4a2d40fd55..6265654bc1 100644 --- a/packages/component/src/hooks/internal/useKeyedStoreValue.ts +++ b/packages/component/src/hooks/internal/useKeyedStoreValue.ts @@ -1,33 +1,16 @@ -import { useLayoutEffect, useMemo, useState } from 'react'; +import { useSyncExternalStore } from '@msinternal/botframework-webchat-react-hooks'; +import { useCallback, useMemo } 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]); + const value = useSyncExternalStore( + useCallback(listener => store.subscribe(key, listener), [key, store]), + useCallback(() => store.get(key), [key, store]) + ); return useMemo(() => Object.freeze([value] as const), [value]); } From c02270fa07b5fb1b6271dc4f9540647220139675 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 9 Nov 2026 15:37:10 -0800 Subject: [PATCH 03/26] Subscribe the graph to the store only while it has subscribers createGraphFromStore() subscribed to the store when called and never unsubscribed. calls it while rendering, so every mount, and every render that never committed (e.g. the first one of ), left a store subscription behind. The graph now subscribes on its first subscriber, catches up with the store, and unsubscribes after its last one. --- .../internal/ReduxGraphProvider.spec.tsx | 64 +++++++++++ .../src/graph/createGraphFromStore.spec.ts | 106 ++++++++++++++++++ .../core/src/graph/createGraphFromStore.ts | 32 +++++- 3 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 packages/api/src/hooks/internal/ReduxGraphProvider.spec.tsx create mode 100644 packages/core/src/graph/createGraphFromStore.spec.ts diff --git a/packages/api/src/hooks/internal/ReduxGraphProvider.spec.tsx b/packages/api/src/hooks/internal/ReduxGraphProvider.spec.tsx new file mode 100644 index 0000000000..4d19540c36 --- /dev/null +++ b/packages/api/src/hooks/internal/ReduxGraphProvider.spec.tsx @@ -0,0 +1,64 @@ +/** @jest-environment @happy-dom/jest-environment */ +/// + +import * as JestGlobals from '@jest/globals'; +import { render } from '@testing-library/react'; +import { scenario } from '@testduet/given-when-then'; +import { createStore } from 'botframework-webchat-core'; +import React, { Fragment, StrictMode } from 'react'; + +import useActivities from '../useActivities'; +import ReduxGraphProvider from './ReduxGraphProvider'; +import WebChatReduxProvider from './WebChatReduxProvider'; + +function Activities() { + useActivities(); + + return null; +} + +scenario( + '', + bdd => { + bdd.given + .oneOf([ + ['without ', () => ({ Wrapper: Fragment })], + ['with ', () => ({ Wrapper: StrictMode })] + ]) + .and('a store which counts its subscriptions', ({ Wrapper }) => { + const store = createStore(); + const { subscribe } = store; + const subscriptions = new Set<() => void>(); + + store.subscribe = listener => { + const unsubscribe = subscribe(listener); + const subscription = () => { + subscriptions.delete(subscription); + unsubscribe(); + }; + + subscriptions.add(subscription); + + return subscription; + }; + + return { store, subscriptions, Wrapper }; + }) + .when('rendered with a component using useActivities()', ({ store, Wrapper }) => + render( + + + + + + + + ) + ) + // The batched subscription of useSelector() serves the graph, and useActivities(). + .then('should subscribe to the store once', ({ subscriptions }) => expect(subscriptions.size).toBe(1)) + .when('unmounted', (_, { unmount }) => unmount()) + .then('should unsubscribe from the store', ({ subscriptions }) => expect(subscriptions.size).toBe(0)); + }, + JestGlobals +); diff --git a/packages/core/src/graph/createGraphFromStore.spec.ts b/packages/core/src/graph/createGraphFromStore.spec.ts new file mode 100644 index 0000000000..00f7362666 --- /dev/null +++ b/packages/core/src/graph/createGraphFromStore.spec.ts @@ -0,0 +1,106 @@ +/** @jest-environment @happy-dom/jest-environment */ +/// + +import { scenario } from '@testduet/given-when-then'; + +import createStore from '../createStore'; +import createGraphFromStore from './createGraphFromStore'; + +const incoming = (id: string) => ({ + payload: { + activity: { channelData: {}, from: { id: 'bot', role: 'bot' }, id, text: id, type: 'message' } + }, + type: 'DIRECT_LINE/INCOMING_ACTIVITY' +}); + +type RawJSON = readonly { readonly '@value': { readonly id: string } }[] | undefined; + +function activityIdsOf(graph: ReturnType): 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) ?? [] + ); +} + +scenario('createGraphFromStore()', bdd => { + bdd + .given('a store with an activity, which counts its subscriptions', () => { + const store = createStore(); + const { subscribe } = store; + const subscriptions = new Set<() => void>(); + + store.subscribe = listener => { + const unsubscribe = subscribe(listener); + const subscription = () => { + subscriptions.delete(subscription); + unsubscribe(); + }; + + subscriptions.add(subscription); + + return subscription; + }; + + store.dispatch(incoming('a-1')); + + return { store, subscriptions }; + }) + .when('a graph is created from the store', ({ store }) => ({ graph: createGraphFromStore(store) })) + .then('the graph should have the activity, without subscribing to the store', ({ subscriptions }, { graph }) => { + expect(activityIdsOf(graph)).toEqual(['a-1']); + expect(subscriptions.size).toBe(0); + }) + .when('2 subscribers subscribe to the graph', (_, { graph }) => { + const subscriber1 = jest.fn(); + const subscriber2 = jest.fn(); + + return { + graph, + subscriber1, + subscriber2, + unsubscribe1: graph.subscribe(subscriber1), + unsubscribe2: graph.subscribe(subscriber2) + }; + }) + .then('the graph should subscribe to the store once', ({ subscriptions }) => expect(subscriptions.size).toBe(1)) + .when('an activity is received', ({ store }, outcome) => { + store.dispatch(incoming('a-2')); + + return outcome; + }) + .then( + 'the graph should have the activity and notify both subscribers', + (_, { graph, subscriber1, subscriber2 }) => { + expect(activityIdsOf(graph)).toEqual(['a-1', 'a-2']); + expect(subscriber1).toHaveBeenCalledTimes(1); + expect(subscriber2).toHaveBeenCalledTimes(1); + } + ) + .when('both subscribers unsubscribe, then an activity is received', ({ store }, outcome) => { + outcome.unsubscribe1(); + outcome.unsubscribe2(); + store.dispatch(incoming('a-3')); + + return outcome; + }) + .then('the graph should not follow the store anymore', ({ subscriptions }, { graph, subscriber1 }) => { + 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 }; + }) + .then( + 'the graph should catch up with the store and notify the subscriber', + ({ subscriptions }, { graph, subscriber }) => { + expect(activityIdsOf(graph)).toEqual(['a-1', 'a-2', 'a-3']); + expect(subscriber).toHaveBeenCalledTimes(1); + expect(subscriptions.size).toBe(1); + } + ); +}); diff --git a/packages/core/src/graph/createGraphFromStore.ts b/packages/core/src/graph/createGraphFromStore.ts index f1278b4753..88e9d6b2b2 100644 --- a/packages/core/src/graph/createGraphFromStore.ts +++ b/packages/core/src/graph/createGraphFromStore.ts @@ -1,4 +1,4 @@ -import { SlantGraph, SlantNodeSchema } from '@msinternal/botframework-webchat-core-graph'; +import { SlantGraph, SlantNodeSchema, type GraphSubscriber } from '@msinternal/botframework-webchat-core-graph'; import { parse } from 'valibot'; import type createStore from '../createStore'; import { getLocalIdFromActivity } from '../reducers/activities/sort/property/LocalId'; @@ -115,11 +115,35 @@ function createGraphFromStore(store: ReturnType): SlantGraph prevActivities = activities; }; - store.subscribe(handleStoreChange); - - // Activities already in the store (e.g. rehydrated) are added now, not on the next dispatch. + // Activities already in the store (e.g. rehydrated) are added now, not on the first subscription. handleStoreChange(); + // 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 subscribers = new Set(); + let unsubscribeFromStore: (() => void) | undefined; + + graph.subscribe = subscriber => { + const unsubscribe = subscribeToGraph(subscriber); + + subscribers.add(subscriber); + unsubscribeFromStore ||= store.subscribe(handleStoreChange); + + // Catches up with changes made while the graph had no subscribers. + handleStoreChange(); + + return () => { + unsubscribe(); + subscribers.delete(subscriber); + + if (!subscribers.size) { + unsubscribeFromStore?.(); + unsubscribeFromStore = undefined; + } + }; + }; + return graph; } From b47e5964deb151fbbebf79c69b2d02447e4c08f8 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 9 Nov 2026 15:43:16 -0800 Subject: [PATCH 04/26] Replicate suggested actions to Redux after rendering, not while rendering dispatched setRawState() while rendering. With useSyncExternalStore() on React 19, that dispatch notified components with a pending update, and React logged "Cannot update a component while rendering a different component". Dispatch from layout effects instead: the store still has the suggested actions by the time they show. --- .../suggestedActions/SuggestedActionsComposer.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx index 515634b93b..e00badf3d3 100644 --- a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx +++ b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx @@ -7,7 +7,7 @@ import { } from 'botframework-webchat-core'; import { setRawState } from 'botframework-webchat-core/internal.js'; import { reactNode, validateProps } from '@msinternal/botframework-webchat-react-valibot'; -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { type Action } from 'redux'; import { object, optional, pipe, readonly, safeParse, type InferInput } from 'valibot'; @@ -64,11 +64,13 @@ function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { [setOriginActivity, setSuggestedActionsRaw] ); - useMemo(() => dispatch(setRawState('suggestedActions', suggestedActions)), [dispatch, suggestedActions]); - useMemo( - () => dispatch(setRawState('suggestedActionsOriginActivity', { activity: originActivity })), - [dispatch, originActivity] - ); + // Layout effects: the store has them by the time they show. + useLayoutEffect(() => { + dispatch(setRawState('suggestedActions', suggestedActions)); + }, [dispatch, suggestedActions]); + useLayoutEffect(() => { + dispatch(setRawState('suggestedActionsOriginActivity', { activity: originActivity })); + }, [dispatch, originActivity]); useEffect(() => { dispatch({ payload: { sink: handleAction }, type: 'WEB_CHAT_INTERNAL/REGISTER_ACTION_SINK' }); From 867362283f81ae887442b0fd68781791d4268ec3 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 9 Nov 2026 16:19:09 -0800 Subject: [PATCH 05/26] Render typingIndicator.scroll.html with React DOM 18 The page puts React 18 in window.React, and its default variant rendered with the bundled React DOM 16. React DOM 16 has no useSyncExternalStore(). The Fluent variants already render with createRoot() of React DOM 18. --- __tests__/html2/typing/typingIndicator.scroll.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/__tests__/html2/typing/typingIndicator.scroll.html b/__tests__/html2/typing/typingIndicator.scroll.html index 8dc93a51ec..1fd955e1f0 100644 --- a/__tests__/html2/typing/typingIndicator.scroll.html +++ b/__tests__/html2/typing/typingIndicator.scroll.html @@ -87,7 +87,8 @@ ) ); } else { - renderWebChat({ directLine, store }, document.getElementById('webchat')); + // React 18 in `window.React` needs React DOM 18, the bundled React DOM is 16. + renderWebChat({ directLine, store }, createRoot(document.getElementById('webchat'))); } await pageConditions.uiConnected(); From 726d1eb7acabd11fbc3e3a4c85101c3e1e52d0da Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 9 Nov 2026 17:55:21 -0800 Subject: [PATCH 06/26] Keep the microphone button enabled as dictation starts Dictation progress sets the dictate state in Redux and dictateAbortable in a useState() of . With useSyncExternalStore() on React 18 and later, the Redux state rendered first, at sync priority, and the microphone button was disabled while dictating until dictateAbortable rendered in the next task. dictateAbortable is now in a keyed store read with useSyncExternalStore(), so both render together. --- packages/component/src/Composer.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/component/src/Composer.tsx b/packages/component/src/Composer.tsx index 0783b9662a..753f40c2ab 100644 --- a/packages/component/src/Composer.tsx +++ b/packages/component/src/Composer.tsx @@ -37,8 +37,10 @@ import { speechSynthesis as bypassSpeechSynthesis, SpeechSynthesisUtterance as BypassSpeechSynthesisUtterance } from './hooks/internal/BypassSpeechSynthesisPonyfill'; +import createKeyedStore from './hooks/internal/createKeyedStore'; import { StyleToEmotionObjectComposer, useStyleToEmotionObject } from './hooks/internal/styleToEmotionObject'; import UITracker from './hooks/internal/UITracker'; +import useKeyedStoreValue from './hooks/internal/useKeyedStoreValue'; import WebChatUIContext from './hooks/internal/WebChatUIContext'; import { FocusSendBoxScope } from './hooks/sendBoxFocus'; import { ScrollRelativeTranscriptScope } from './hooks/transcriptScrollRelative'; @@ -196,7 +198,14 @@ const ComposerCore = (props: ComposerCoreProps) => { webSpeechPonyfillFactory } = props; - const [dictateAbortable, setDictateAbortable] = useState(); + // A store, not a state: the store updates of the same dictation progress (e.g. dictate state) re-render at sync priority + // on React 18 and later, and a state would render later, with the microphone button disabled in between. + const dictateAbortableStore = useMemo(() => createKeyedStore(), []); + const [dictateAbortable] = useKeyedStoreValue(dictateAbortableStore, 'abortable'); + const setDictateAbortable = useCallback( + (abortable: boolean) => dictateAbortableStore.set('abortable', abortable), + [dictateAbortableStore] + ); const [referenceGrammarID] = useReferenceGrammarID(); const [styleOptions] = useStyleOptions(); const focusTranscriptCallbacksRef = useRef<((init: FocusTranscriptInit) => Promise)[]>([]); From 48be6ae36177ec0a598832265007b6fdacf47424 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 9 Nov 2026 17:55:21 -0800 Subject: [PATCH 07/26] Fire mocked speech recognition events after start() returns The mock dispatched its events inside start(), which react-dictate-button calls from an effect. On React 18 and later, "starting" and "dictating" then render in the same frame, unlike with a browser's recognizer, and the forced-colors dictation snapshots differed by a few anti-aliased pixels. --- __tests__/assets/esm/speech/speechPageObjects.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/__tests__/assets/esm/speech/speechPageObjects.js b/__tests__/assets/esm/speech/speechPageObjects.js index 9c171c4a63..bd3878b643 100644 --- a/__tests__/assets/esm/speech/speechPageObjects.js +++ b/__tests__/assets/esm/speech/speechPageObjects.js @@ -170,6 +170,10 @@ function actRecognizeOnce(ponyfill, actor, recognizeActor) { }); spyOn(speechRecognition, 'start').mockImplementationOnce(async () => { + // Like a browser, events come after start() returned: start() is called from an effect, events inside it would + // render "starting" and "dictating" in the same frame on React 18 and later. + await new Promise(resolve => setTimeout(resolve, 0)); + if (typeof recognizeActor === 'string') { start(); audioStart(); From c3102ee25ec58fdc70d512dd1490afc5e04c66f6 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 9 Nov 2026 18:18:58 -0800 Subject: [PATCH 08/26] Show suggested actions in the same commit as their activity The action sink sets the suggested actions while the store dispatches the incoming activity. With useSyncExternalStore() on React 18 and later, the activity rendered at sync priority and the suggested actions, a useState() of , one task later. The suggested actions are now in a store read with useSyncExternalStore(), so both render together. --- package-lock.json | 1 + packages/redux-store/package.json | 4 +- .../SuggestedActionsComposer.tsx | 73 +++++++++++++++---- 3 files changed, 63 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index c722714bcf..cd876dcfa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21639,6 +21639,7 @@ "valibot": "1.4.2" }, "devDependencies": { + "@msinternal/botframework-webchat-react-hooks": "0.0.0-0", "@msinternal/botframework-webchat-react-valibot": "0.0.0-0", "@msinternal/botframework-webchat-tsconfig": "0.0.0-0", "@types/react": "^16.14.70", diff --git a/packages/redux-store/package.json b/packages/redux-store/package.json index 02d941be49..ef72d6e071 100644 --- a/packages/redux-store/package.json +++ b/packages/redux-store/package.json @@ -43,7 +43,7 @@ "precommit:eslint": "../../node_modules/.bin/eslint --report-unused-disable-directives --max-warnings 0", "precommit:typecheck": "tsc --project ./src --emitDeclarationOnly false --esModuleInterop true --noEmit --pretty false", "preversion": "../../scripts/npm/preversion.sh", - "start": "../../scripts/npm/notify-build.sh \"src\" \"../react-valibot/package.json\" \"../core/package.json\"" + "start": "../../scripts/npm/notify-build.sh \"src\" \"../react-hooks/package.json\" \"../react-valibot/package.json\" \"../core/package.json\"" }, "pinDependencies": { "@types/react": [ @@ -56,11 +56,13 @@ ] }, "localDependencies": { + "@msinternal/botframework-webchat-react-hooks": "development", "@msinternal/botframework-webchat-react-valibot": "development", "@msinternal/botframework-webchat-tsconfig": "development", "botframework-webchat-core": "production" }, "devDependencies": { + "@msinternal/botframework-webchat-react-hooks": "0.0.0-0", "@msinternal/botframework-webchat-react-valibot": "0.0.0-0", "@msinternal/botframework-webchat-tsconfig": "0.0.0-0", "@types/react": "^16.14.70", diff --git a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx index e00badf3d3..ebd9950ab1 100644 --- a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx +++ b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx @@ -6,8 +6,9 @@ import { type DirectLineCardAction } from 'botframework-webchat-core'; import { setRawState } from 'botframework-webchat-core/internal.js'; +import { useSyncExternalStore } from '@msinternal/botframework-webchat-react-hooks'; import { reactNode, validateProps } from '@msinternal/botframework-webchat-react-valibot'; -import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'; +import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, type SetStateAction } from 'react'; import { type Action } from 'redux'; import { object, optional, pipe, readonly, safeParse, type InferInput } from 'valibot'; @@ -26,28 +27,71 @@ type SuggestedActionsComposerProps = InferInput; + +const INITIAL_STATE: SuggestedActionsState = Object.freeze({ + originActivity: undefined, + suggestedActions: EMPTY_ARRAY +}); + +/** Creates a store of the suggested actions. Listeners may re-render: call `setState()` outside of rendering. */ +function createSuggestedActionsStore() { + const listeners = new Set<() => void>(); + let state = INITIAL_STATE; + + return Object.freeze({ + getState: () => state, + setState: (nextState: SuggestedActionsState) => { + state = nextState; + + listeners.forEach(listener => listener()); + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + } + }); +} + function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { const { children, store: { dispatch } } = validateProps(suggestedActionsComposerPropsSchema, props); - const [originActivity, setOriginActivity] = useState(); - const [suggestedActions, setSuggestedActionsRaw] = useState(EMPTY_ARRAY); - const setSuggestedActions = useCallback( - suggestedActions => { - setOriginActivity(undefined); - setSuggestedActionsRaw(suggestedActions); - }, - [setSuggestedActionsRaw] + // A store, not a state: the action sink runs while the store dispatches the activity, which re-renders at sync priority + // on React 18 and later. A state would render later, the activity would show before its suggested actions. + const suggestedActionsStore = useMemo(createSuggestedActionsStore, []); + const { originActivity, suggestedActions } = useSyncExternalStore( + suggestedActionsStore.subscribe, + suggestedActionsStore.getState + ); + + const setSuggestedActions = useCallback( + (suggestedActions: SetStateAction) => + suggestedActionsStore.setState( + Object.freeze({ + originActivity: undefined, + suggestedActions: + typeof suggestedActions === 'function' + ? suggestedActions(suggestedActionsStore.getState().suggestedActions) + : suggestedActions + }) + ), + [suggestedActionsStore] ); // #region Replicate to Redux store const handleAction = useCallback( (action: Action) => { if (action.type === CLEAR_SUGGESTED_ACTIONS) { - setOriginActivity(undefined); - setSuggestedActionsRaw(EMPTY_ARRAY); + suggestedActionsStore.setState(INITIAL_STATE); } else if (action.type === SET_SUGGESTED_ACTIONS) { const result = safeParse(setSuggestedActionsActionSchema, action); @@ -56,12 +100,13 @@ function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { payload: { originActivity, suggestedActions } } = result.output; - setOriginActivity(originActivity); - setSuggestedActionsRaw(Object.freeze(Array.from(suggestedActions))); + suggestedActionsStore.setState( + Object.freeze({ originActivity, suggestedActions: Object.freeze(Array.from(suggestedActions)) }) + ); } } }, - [setOriginActivity, setSuggestedActionsRaw] + [suggestedActionsStore] ); // Layout effects: the store has them by the time they show. From 8b4858166af8398a94bc27c6fb51dda791b7dc18 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 9 Nov 2026 18:18:58 -0800 Subject: [PATCH 09/26] Check that dictation and suggested actions do not render half-way on React 18 --- .../dictating.microphoneButtonEnabled.html | 96 ++++++++++++++++++ ...ng.microphoneButtonEnabled.html.snap-1.png | Bin 0 -> 4410 bytes ...suggestedActions.sameCommitAsActivity.html | 85 ++++++++++++++++ ...tions.sameCommitAsActivity.html.snap-1.png | Bin 0 -> 10467 bytes 4 files changed, 181 insertions(+) create mode 100644 __tests__/html2/speech/recognition/dictating.microphoneButtonEnabled.html create mode 100644 __tests__/html2/speech/recognition/dictating.microphoneButtonEnabled.html.snap-1.png create mode 100644 __tests__/html2/suggestedActions/suggestedActions.sameCommitAsActivity.html create mode 100644 __tests__/html2/suggestedActions/suggestedActions.sameCommitAsActivity.html.snap-1.png diff --git a/__tests__/html2/speech/recognition/dictating.microphoneButtonEnabled.html b/__tests__/html2/speech/recognition/dictating.microphoneButtonEnabled.html new file mode 100644 index 0000000000..2f0eb0dcbe --- /dev/null +++ b/__tests__/html2/speech/recognition/dictating.microphoneButtonEnabled.html @@ -0,0 +1,96 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/speech/recognition/dictating.microphoneButtonEnabled.html.snap-1.png b/__tests__/html2/speech/recognition/dictating.microphoneButtonEnabled.html.snap-1.png new file mode 100644 index 0000000000000000000000000000000000000000..4df820fbd243f8a729846e5650fc0ed7ee8f7628 GIT binary patch literal 4410 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 literal 0 HcmV?d00001 diff --git a/__tests__/html2/suggestedActions/suggestedActions.sameCommitAsActivity.html b/__tests__/html2/suggestedActions/suggestedActions.sameCommitAsActivity.html new file mode 100644 index 0000000000..d50c891992 --- /dev/null +++ b/__tests__/html2/suggestedActions/suggestedActions.sameCommitAsActivity.html @@ -0,0 +1,85 @@ + + + + + + +
+ + + + diff --git a/__tests__/html2/suggestedActions/suggestedActions.sameCommitAsActivity.html.snap-1.png b/__tests__/html2/suggestedActions/suggestedActions.sameCommitAsActivity.html.snap-1.png new file mode 100644 index 0000000000000000000000000000000000000000..cfacce455cc8eb1084434b03a259dc795eb874c8 GIT binary patch literal 10467 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 literal 0 HcmV?d00001 From 68ecdc7966b4671c56be91fac7ec13288cf07716 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 9 Nov 2026 19:00:01 -0800 Subject: [PATCH 10/26] Wait for a frame before firing mocked speech recognition events Waiting for one task did not always paint "starting" first: under load, a forced-colors dictation snapshot differed by a few pixels again (1 of about 40 runs). --- __tests__/assets/esm/speech/speechPageObjects.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/__tests__/assets/esm/speech/speechPageObjects.js b/__tests__/assets/esm/speech/speechPageObjects.js index bd3878b643..45d05f623b 100644 --- a/__tests__/assets/esm/speech/speechPageObjects.js +++ b/__tests__/assets/esm/speech/speechPageObjects.js @@ -170,9 +170,9 @@ function actRecognizeOnce(ponyfill, actor, recognizeActor) { }); spyOn(speechRecognition, 'start').mockImplementationOnce(async () => { - // Like a browser, events come after start() returned: start() is called from an effect, events inside it would - // render "starting" and "dictating" in the same frame on React 18 and later. - await new Promise(resolve => setTimeout(resolve, 0)); + // Like a browser, events come after start() returned and "starting" is painted: start() is called from an effect, + // events inside it would render "starting" and "dictating" in the same frame on React 18 and later. + await new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve, 0))); if (typeof recognizeActor === 'string') { start(); From 33be371195548ceb8861431b61aa0d4d9268367f Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 9 Nov 2026 19:29:23 -0800 Subject: [PATCH 11/26] Keep React 18 in window.React working with the bundled React DOM 16 When the host sets window.React but not window.ReactDOM, the IIFE bundle renders with its React DOM 16, which cannot run React's useSyncExternalStore(). Hide it from window.React there, so the ponyfill falls back as with React 16/17, like before. typingIndicator.scroll.html covers this setup again. --- .../hosting/windowReact.withoutReactDOM.html | 84 ++++++++++++++++++ ...indowReact.withoutReactDOM.html.snap-1.png | Bin 0 -> 7242 bytes .../html2/typing/typingIndicator.scroll.html | 3 +- packages/isomorphic-react/src/React.js | 10 ++- 4 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 __tests__/html2/hosting/windowReact.withoutReactDOM.html create mode 100644 __tests__/html2/hosting/windowReact.withoutReactDOM.html.snap-1.png diff --git a/__tests__/html2/hosting/windowReact.withoutReactDOM.html b/__tests__/html2/hosting/windowReact.withoutReactDOM.html new file mode 100644 index 0000000000..cf99caea36 --- /dev/null +++ b/__tests__/html2/hosting/windowReact.withoutReactDOM.html @@ -0,0 +1,84 @@ + + + + + + + + + + + +
+ + + diff --git a/__tests__/html2/hosting/windowReact.withoutReactDOM.html.snap-1.png b/__tests__/html2/hosting/windowReact.withoutReactDOM.html.snap-1.png new file mode 100644 index 0000000000000000000000000000000000000000..575a4020535deadab25efc4cc4760be5c5857345 GIT binary patch literal 7242 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 A?*IS* literal 0 HcmV?d00001 diff --git a/__tests__/html2/typing/typingIndicator.scroll.html b/__tests__/html2/typing/typingIndicator.scroll.html index 1fd955e1f0..8dc93a51ec 100644 --- a/__tests__/html2/typing/typingIndicator.scroll.html +++ b/__tests__/html2/typing/typingIndicator.scroll.html @@ -87,8 +87,7 @@ ) ); } else { - // React 18 in `window.React` needs React DOM 18, the bundled React DOM is 16. - renderWebChat({ directLine, store }, createRoot(document.getElementById('webchat'))); + renderWebChat({ directLine, store }, document.getElementById('webchat')); } await pageConditions.uiConnected(); diff --git a/packages/isomorphic-react/src/React.js b/packages/isomorphic-react/src/React.js index 9875a34594..935997ef76 100644 --- a/packages/isomorphic-react/src/React.js +++ b/packages/isomorphic-react/src/React.js @@ -1,3 +1,11 @@ const React = require('react'); -module.exports = typeof window.React === 'undefined' ? React : window.React; +// The bundled React DOM 16 renders with `window.React` when `window.ReactDOM` is not set: React's useSyncExternalStore() +// would need React DOM 18 or later. Hide it, so the ponyfill falls back to its React 16/17 version. +// Remove when the bundled React DOM is 18 or later. +module.exports = + typeof window.React === 'undefined' + ? React + : typeof window.ReactDOM === 'undefined' + ? { ...window.React, useSyncExternalStore: undefined } + : window.React; From e12eda9b8304c44169b36a967903cd5937ac40f4 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 9 Nov 2026 19:35:06 -0800 Subject: [PATCH 12/26] Move createKeyedStore() and useKeyedStoreValue() to react-hooks --- packages/component/src/Composer.tsx | 4 +--- .../ActivityLogicalGroupingComposer.ts | 2 +- .../private/ActivityLogicalGroupingContext.ts | 3 ++- .../providers/ActivityLogicalGrouping/useLogicalGroupKey.ts | 3 ++- .../hooks/internal => react-hooks/src}/createKeyedStore.ts | 0 packages/react-hooks/src/index.ts | 2 ++ .../internal => react-hooks/src}/useKeyedStoreValue.spec.tsx | 0 .../hooks/internal => react-hooks/src}/useKeyedStoreValue.ts | 2 +- 8 files changed, 9 insertions(+), 7 deletions(-) rename packages/{component/src/hooks/internal => react-hooks/src}/createKeyedStore.ts (100%) rename packages/{component/src/hooks/internal => react-hooks/src}/useKeyedStoreValue.spec.tsx (100%) rename packages/{component/src/hooks/internal => react-hooks/src}/useKeyedStoreValue.ts (86%) diff --git a/packages/component/src/Composer.tsx b/packages/component/src/Composer.tsx index 753f40c2ab..3089317d2e 100644 --- a/packages/component/src/Composer.tsx +++ b/packages/component/src/Composer.tsx @@ -1,7 +1,7 @@ /* eslint-disable react/require-default-props */ import { singleToArray } from '@msinternal/botframework-webchat-base/utils'; -import { useMemoIterable } from '@msinternal/botframework-webchat-react-hooks'; +import { createKeyedStore, useKeyedStoreValue, useMemoIterable } from '@msinternal/botframework-webchat-react-hooks'; import { validateProps } from '@msinternal/botframework-webchat-react-valibot'; import { useStyles, webChatPrefixedClassName } from '@msinternal/botframework-webchat-styles/react'; import { @@ -37,10 +37,8 @@ import { speechSynthesis as bypassSpeechSynthesis, SpeechSynthesisUtterance as BypassSpeechSynthesisUtterance } from './hooks/internal/BypassSpeechSynthesisPonyfill'; -import createKeyedStore from './hooks/internal/createKeyedStore'; import { StyleToEmotionObjectComposer, useStyleToEmotionObject } from './hooks/internal/styleToEmotionObject'; import UITracker from './hooks/internal/UITracker'; -import useKeyedStoreValue from './hooks/internal/useKeyedStoreValue'; import WebChatUIContext from './hooks/internal/WebChatUIContext'; import { FocusSendBoxScope } from './hooks/sendBoxFocus'; import { ScrollRelativeTranscriptScope } from './hooks/transcriptScrollRelative'; diff --git a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts index c07b513975..55efa9864c 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/ActivityLogicalGroupingComposer.ts @@ -1,8 +1,8 @@ +import { createKeyedStore } from '@msinternal/botframework-webchat-react-hooks'; import { reactNode, validateProps } from '@msinternal/botframework-webchat-react-valibot'; 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 LogicalGrouping diff --git a/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts b/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts index cb8b253384..b192cfc9bc 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/private/ActivityLogicalGroupingContext.ts @@ -1,4 +1,5 @@ -import { type ReadonlyKeyedStore } from '../../../hooks/internal/createKeyedStore'; +import { type ReadonlyKeyedStore } from '@msinternal/botframework-webchat-react-hooks'; + import createContextAndHook from '../../createContextAndHook'; type LogicalGrouping = { diff --git a/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.ts b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.ts index a6c4d28d76..6ca3a67229 100644 --- a/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.ts +++ b/packages/component/src/providers/ActivityLogicalGrouping/useLogicalGroupKey.ts @@ -1,4 +1,5 @@ -import useKeyedStoreValue from '../../hooks/internal/useKeyedStoreValue'; +import { useKeyedStoreValue } from '@msinternal/botframework-webchat-react-hooks'; + import { useActivityLogicalGroupingContext } from './private/ActivityLogicalGroupingContext'; /** Returns the key of the logical group of the activity, or `undefined`. Re-renders only when it changes. */ diff --git a/packages/component/src/hooks/internal/createKeyedStore.ts b/packages/react-hooks/src/createKeyedStore.ts similarity index 100% rename from packages/component/src/hooks/internal/createKeyedStore.ts rename to packages/react-hooks/src/createKeyedStore.ts diff --git a/packages/react-hooks/src/index.ts b/packages/react-hooks/src/index.ts index 7a09419382..adba359492 100644 --- a/packages/react-hooks/src/index.ts +++ b/packages/react-hooks/src/index.ts @@ -1,5 +1,7 @@ +export { default as createKeyedStore, type KeyedStore, type ReadonlyKeyedStore } from './createKeyedStore'; export { default as useDebugDeps } from './useDebugDeps'; export { default as useFileDropZone, type DropZoneState } from './useFileDropZone'; +export { default as useKeyedStoreValue } from './useKeyedStoreValue'; export { default as useMemoIterable } from './useMemoIterable'; export { default as useMemoWithPrevious } from './useMemoWithPrevious'; export { default as useSyncExternalStore } from './useSyncExternalStore'; diff --git a/packages/component/src/hooks/internal/useKeyedStoreValue.spec.tsx b/packages/react-hooks/src/useKeyedStoreValue.spec.tsx similarity index 100% rename from packages/component/src/hooks/internal/useKeyedStoreValue.spec.tsx rename to packages/react-hooks/src/useKeyedStoreValue.spec.tsx diff --git a/packages/component/src/hooks/internal/useKeyedStoreValue.ts b/packages/react-hooks/src/useKeyedStoreValue.ts similarity index 86% rename from packages/component/src/hooks/internal/useKeyedStoreValue.ts rename to packages/react-hooks/src/useKeyedStoreValue.ts index 6265654bc1..e2c2321574 100644 --- a/packages/component/src/hooks/internal/useKeyedStoreValue.ts +++ b/packages/react-hooks/src/useKeyedStoreValue.ts @@ -1,7 +1,7 @@ -import { useSyncExternalStore } from '@msinternal/botframework-webchat-react-hooks'; import { useCallback, useMemo } from 'react'; import { type ReadonlyKeyedStore } from './createKeyedStore'; +import useSyncExternalStore from './useSyncExternalStore'; /** * Returns the value of the key in the store, and re-renders when it changes. From 67cc55ea7a265668c2d637d4efe36ae2039e17cb Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 9 Nov 2026 19:35:06 -0800 Subject: [PATCH 13/26] Keep the suggested actions in a keyed store --- .../SuggestedActionsComposer.tsx | 42 +++++-------------- 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx index ebd9950ab1..36eff13b8d 100644 --- a/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx +++ b/packages/redux-store/src/suggestedActions/SuggestedActionsComposer.tsx @@ -6,7 +6,7 @@ import { type DirectLineCardAction } from 'botframework-webchat-core'; import { setRawState } from 'botframework-webchat-core/internal.js'; -import { useSyncExternalStore } from '@msinternal/botframework-webchat-react-hooks'; +import { createKeyedStore, useKeyedStoreValue } from '@msinternal/botframework-webchat-react-hooks'; import { reactNode, validateProps } from '@msinternal/botframework-webchat-react-valibot'; import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, type SetStateAction } from 'react'; import { type Action } from 'redux'; @@ -37,28 +37,6 @@ const INITIAL_STATE: SuggestedActionsState = Object.freeze({ suggestedActions: EMPTY_ARRAY }); -/** Creates a store of the suggested actions. Listeners may re-render: call `setState()` outside of rendering. */ -function createSuggestedActionsStore() { - const listeners = new Set<() => void>(); - let state = INITIAL_STATE; - - return Object.freeze({ - getState: () => state, - setState: (nextState: SuggestedActionsState) => { - state = nextState; - - listeners.forEach(listener => listener()); - }, - subscribe: (listener: () => void) => { - listeners.add(listener); - - return () => { - listeners.delete(listener); - }; - } - }); -} - function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { const { children, @@ -67,20 +45,19 @@ function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { // A store, not a state: the action sink runs while the store dispatches the activity, which re-renders at sync priority // on React 18 and later. A state would render later, the activity would show before its suggested actions. - const suggestedActionsStore = useMemo(createSuggestedActionsStore, []); - const { originActivity, suggestedActions } = useSyncExternalStore( - suggestedActionsStore.subscribe, - suggestedActionsStore.getState - ); + const suggestedActionsStore = useMemo(() => createKeyedStore(), []); + const [state] = useKeyedStoreValue(suggestedActionsStore, 'state'); + const { originActivity, suggestedActions } = state ?? INITIAL_STATE; const setSuggestedActions = useCallback( (suggestedActions: SetStateAction) => - suggestedActionsStore.setState( + suggestedActionsStore.set( + 'state', Object.freeze({ originActivity: undefined, suggestedActions: typeof suggestedActions === 'function' - ? suggestedActions(suggestedActionsStore.getState().suggestedActions) + ? suggestedActions((suggestedActionsStore.get('state') ?? INITIAL_STATE).suggestedActions) : suggestedActions }) ), @@ -91,7 +68,7 @@ function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { const handleAction = useCallback( (action: Action) => { if (action.type === CLEAR_SUGGESTED_ACTIONS) { - suggestedActionsStore.setState(INITIAL_STATE); + suggestedActionsStore.set('state', INITIAL_STATE); } else if (action.type === SET_SUGGESTED_ACTIONS) { const result = safeParse(setSuggestedActionsActionSchema, action); @@ -100,7 +77,8 @@ function SuggestedActionsComposer(props: SuggestedActionsComposerProps) { payload: { originActivity, suggestedActions } } = result.output; - suggestedActionsStore.setState( + suggestedActionsStore.set( + 'state', Object.freeze({ originActivity, suggestedActions: Object.freeze(Array.from(suggestedActions)) }) ); } From 68ec4a06ac3e0b57b8f04bd061089b843f1dae86 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 9 Nov 2026 19:35:06 -0800 Subject: [PATCH 14/26] Check the version of React 18 in the useSyncExternalStore() spec --- packages/react-hooks/src/useSyncExternalStore.spec.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/react-hooks/src/useSyncExternalStore.spec.tsx b/packages/react-hooks/src/useSyncExternalStore.spec.tsx index d892e90777..1aa0394b48 100644 --- a/packages/react-hooks/src/useSyncExternalStore.spec.tsx +++ b/packages/react-hooks/src/useSyncExternalStore.spec.tsx @@ -57,9 +57,14 @@ function loadReact18(): Environment { let environment!: Environment; jest.isolateModules(() => { - // The same copy of React as React DOM 18 requires. + // The same copy of React as React DOM 18 requires: the only React 18 in the repository, the root pins React 16.8.6. const React = jest.requireActual('../../repack/react-dom/node_modules/react'); + // Fails loudly if a change of npm hoisting moves this copy, instead of testing another version of React. + if (!React.version.startsWith('18.')) { + throw new Error(`Expected React 18 in packages/repack/react-dom/node_modules, found ${React.version}.`); + } + jest.doMock('react', () => React); const { act } = jest.requireActual( From 6becbc58bdadafbc8600759ed30e1c45273e5f73 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 9 Nov 2026 19:55:56 -0800 Subject: [PATCH 15/26] Explain when the useSyncExternalStore() fallback runs and when the keyed store may notify --- packages/react-hooks/src/createKeyedStore.ts | 2 +- packages/react-hooks/src/useSyncExternalStore.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/react-hooks/src/createKeyedStore.ts b/packages/react-hooks/src/createKeyedStore.ts index 5a2454d4c0..7b903b3e3d 100644 --- a/packages/react-hooks/src/createKeyedStore.ts +++ b/packages/react-hooks/src/createKeyedStore.ts @@ -6,7 +6,7 @@ type ReadonlyKeyedStore = { 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. */ + /** Notifies the listeners of the key if the value changed. Listeners may re-render: never call it while rendering. */ readonly set: (key: string, value: T | undefined) => void; }; diff --git a/packages/react-hooks/src/useSyncExternalStore.ts b/packages/react-hooks/src/useSyncExternalStore.ts index a71fdf04b7..819ff47825 100644 --- a/packages/react-hooks/src/useSyncExternalStore.ts +++ b/packages/react-hooks/src/useSyncExternalStore.ts @@ -14,14 +14,16 @@ const { useSyncExternalStore: nativeUseSyncExternalStore } = React as typeof Rea readonly useSyncExternalStore?: UseSyncExternalStore | undefined; }; -// React 16/17: subscribes in a layout effect, which runs again only when `subscribe` changes. +// React 16/17, and React 18+ in the IIFE bundle without `window.ReactDOM`: subscribes in a layout effect, which runs +// again only when `subscribe` changes. function useSyncExternalStoreFallback(subscribe: Subscribe, getSnapshot: () => T): T { const [, forceRender] = useReducer(increment, 0); const value = getSnapshot(); const latest = Object.freeze({ getSnapshot, value }); const latestRef = useRef(latest); - // Legacy rendering of React 16/17 does not discard renders, so the ref is written while rendering. + // Written while rendering: legacy roots never discard a render, and under `createRoot` every render reads + // `getSnapshot()` itself, so a discarded render only leaves a newer value for the next change to compare with. latestRef.current = latest; useLayoutEffect(() => { From 107a4995ef5b24d4da2dde922b1812fecf99f59f Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 9 Nov 2026 19:56:21 -0800 Subject: [PATCH 16/26] Add the changelog fragment --- .changelog/6040-feat-use-sync-external-store.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changelog/6040-feat-use-sync-external-store.md diff --git a/.changelog/6040-feat-use-sync-external-store.md b/.changelog/6040-feat-use-sync-external-store.md new file mode 100644 index 0000000000..09f09c4845 --- /dev/null +++ b/.changelog/6040-feat-use-sync-external-store.md @@ -0,0 +1,3 @@ +### Changed + +- Hooks: read the store with `useSyncExternalStore()`, at sync priority on React 18 and later (with the IIFE bundle, when `window.ReactDOM` is set too), in PR [#6040](https://github.com/microsoft/BotFramework-WebChat/pull/6040), by [@OEvgeny](https://github.com/OEvgeny) From fdc89528e90b0775a76eb9f13227ff09ef5092ab Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Mon, 9 Nov 2026 19:56:47 -0800 Subject: [PATCH 17/26] Add the Fixed changelog entry --- .changelog/6040-feat-use-sync-external-store.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.changelog/6040-feat-use-sync-external-store.md b/.changelog/6040-feat-use-sync-external-store.md index 09f09c4845..81452f78b0 100644 --- a/.changelog/6040-feat-use-sync-external-store.md +++ b/.changelog/6040-feat-use-sync-external-store.md @@ -1,3 +1,7 @@ ### Changed - Hooks: read the store with `useSyncExternalStore()`, at sync priority on React 18 and later (with the IIFE bundle, when `window.ReactDOM` is set too), in PR [#6040](https://github.com/microsoft/BotFramework-WebChat/pull/6040), by [@OEvgeny](https://github.com/OEvgeny) + +### Fixed + +- Fixed store subscriptions left behind after unmount and by ``: `createGraphFromStore()` now subscribes to the store only while its graph has subscribers, in PR [#6040](https://github.com/microsoft/BotFramework-WebChat/pull/6040), by [@OEvgeny](https://github.com/OEvgeny) From d599d58bb3f92a84e4821e612ad1a280a4d1cda1 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 12 Nov 2026 13:15:33 -0800 Subject: [PATCH 18/26] Rename ReduxGraphProvider to ReduxStoreProvider --- packages/api/src/hooks/Composer.tsx | 6 +++--- ...ider.spec.tsx => ReduxStoreProvider.spec.tsx} | 8 ++++---- ...xGraphProvider.tsx => ReduxStoreProvider.tsx} | 16 ++++++++-------- .../hooks/useActivities.deleteActivity.spec.tsx | 10 +++++----- 4 files changed, 20 insertions(+), 20 deletions(-) rename packages/api/src/hooks/internal/{ReduxGraphProvider.spec.tsx => ReduxStoreProvider.spec.tsx} (92%) rename packages/api/src/hooks/internal/{ReduxGraphProvider.tsx => ReduxStoreProvider.tsx} (60%) diff --git a/packages/api/src/hooks/Composer.tsx b/packages/api/src/hooks/Composer.tsx index 5078558d05..c1d5584fb6 100644 --- a/packages/api/src/hooks/Composer.tsx +++ b/packages/api/src/hooks/Composer.tsx @@ -95,7 +95,7 @@ import TypingIndicatorMiddleware from '../types/TypingIndicatorMiddleware'; import createCustomEvent from '../utils/createCustomEvent'; import mapMap from '../utils/mapMap'; import normalizeLanguage from '../utils/normalizeLanguage'; -import ReduxGraphProvider from './internal/ReduxGraphProvider'; +import ReduxStoreProvider from './internal/ReduxStoreProvider'; import Tracker from './internal/Tracker'; import WebChatAPIContext, { type WebChatAPIContextType } from './internal/WebChatAPIContext'; import { useDispatch } from './internal/WebChatReduxContext'; @@ -847,13 +847,13 @@ const ComposerWithStore = (propsWithStore: ComposerWithStoreProps) => { return ( - + - + ); diff --git a/packages/api/src/hooks/internal/ReduxGraphProvider.spec.tsx b/packages/api/src/hooks/internal/ReduxStoreProvider.spec.tsx similarity index 92% rename from packages/api/src/hooks/internal/ReduxGraphProvider.spec.tsx rename to packages/api/src/hooks/internal/ReduxStoreProvider.spec.tsx index 4d19540c36..eb8fbc97bc 100644 --- a/packages/api/src/hooks/internal/ReduxGraphProvider.spec.tsx +++ b/packages/api/src/hooks/internal/ReduxStoreProvider.spec.tsx @@ -8,7 +8,7 @@ import { createStore } from 'botframework-webchat-core'; import React, { Fragment, StrictMode } from 'react'; import useActivities from '../useActivities'; -import ReduxGraphProvider from './ReduxGraphProvider'; +import ReduxStoreProvider from './ReduxStoreProvider'; import WebChatReduxProvider from './WebChatReduxProvider'; function Activities() { @@ -18,7 +18,7 @@ function Activities() { } scenario( - '', + '', bdd => { bdd.given .oneOf([ @@ -48,9 +48,9 @@ scenario( render( - + - +
) diff --git a/packages/api/src/hooks/internal/ReduxGraphProvider.tsx b/packages/api/src/hooks/internal/ReduxStoreProvider.tsx similarity index 60% rename from packages/api/src/hooks/internal/ReduxGraphProvider.tsx rename to packages/api/src/hooks/internal/ReduxStoreProvider.tsx index 2c34ed0522..a3a00c19e4 100644 --- a/packages/api/src/hooks/internal/ReduxGraphProvider.tsx +++ b/packages/api/src/hooks/internal/ReduxStoreProvider.tsx @@ -5,22 +5,22 @@ import { object, optional, pipe, readonly } from 'valibot'; import { useWebChatReduxContext } from './WebChatReduxContext'; -const reduxGraphProviderPropsSchema = pipe( +const reduxStoreProviderPropsSchema = pipe( object({ children: optional(reactNode()) }), readonly() ); -type ReduxGraphProviderProps = InferReactProps; +type ReduxStoreProviderProps = InferReactProps; /** - * `` for the store of ``, following it through the subscription of `useSelector()`. + * Renders the graph package's `` with the store of ``, following it through the subscription of `useSelector()`. * * React 16/17 then re-render `` in the same batch as the selectors, after the graph has the change. */ -function ReduxGraphProvider(props: ReduxGraphProviderProps) { - const { children } = validateProps(reduxGraphProviderPropsSchema, props); +function ReduxStoreProvider(props: ReduxStoreProviderProps) { + const { children } = validateProps(reduxStoreProviderPropsSchema, props); const { store, subscribe } = useWebChatReduxContext(); const graphStore = useMemo(() => Object.freeze({ ...store, subscribe }), [store, subscribe]); @@ -28,7 +28,7 @@ function ReduxGraphProvider(props: ReduxGraphProviderProps) { return {children}; } -ReduxGraphProvider.displayName = 'ReduxGraphProvider'; +ReduxStoreProvider.displayName = 'ReduxStoreProvider'; -export default memo(ReduxGraphProvider); -export { reduxGraphProviderPropsSchema, type ReduxGraphProviderProps }; +export default memo(ReduxStoreProvider); +export { reduxStoreProviderPropsSchema, type ReduxStoreProviderProps }; diff --git a/packages/api/src/hooks/useActivities.deleteActivity.spec.tsx b/packages/api/src/hooks/useActivities.deleteActivity.spec.tsx index 2a5302989c..d80d8f7275 100644 --- a/packages/api/src/hooks/useActivities.deleteActivity.spec.tsx +++ b/packages/api/src/hooks/useActivities.deleteActivity.spec.tsx @@ -18,7 +18,7 @@ function renderActivities() { let modules: { readonly act: typeof import('@testing-library/react/pure').act; readonly React: typeof import('react'); - readonly ReduxGraphProvider: typeof import('./internal/ReduxGraphProvider').default; + readonly ReduxStoreProvider: typeof import('./internal/ReduxStoreProvider').default; readonly render: typeof import('@testing-library/react/pure').render; readonly useActivities: typeof import('./useActivities').default; readonly WebChatReduxProvider: typeof import('./internal/WebChatReduxProvider').default; @@ -30,14 +30,14 @@ function renderActivities() { modules = { act, React: require('react'), - ReduxGraphProvider: require('./internal/ReduxGraphProvider').default, + ReduxStoreProvider: require('./internal/ReduxStoreProvider').default, render, useActivities: require('./useActivities').default, WebChatReduxProvider: require('./internal/WebChatReduxProvider').default }; }); - const { act, React, ReduxGraphProvider, render, useActivities, WebChatReduxProvider } = modules; + const { act, React, ReduxStoreProvider, render, useActivities, WebChatReduxProvider } = modules; const errors: unknown[] = []; const renders: (readonly string[])[] = []; const store = createStore(); @@ -75,9 +75,9 @@ function renderActivities() { const { unmount } = render( - + - + ); From 4bab5e663aec236f0bb160ab7a7f6a2029e5781f Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 12 Nov 2026 13:33:47 -0800 Subject: [PATCH 19/26] Add added, changed and deleted activities to the cause of the activities check --- .../src/hooks/useActivities.cause.spec.tsx | 186 ++++++++++++++++++ packages/api/src/hooks/useActivities.ts | 53 +++-- 2 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 packages/api/src/hooks/useActivities.cause.spec.tsx diff --git a/packages/api/src/hooks/useActivities.cause.spec.tsx b/packages/api/src/hooks/useActivities.cause.spec.tsx new file mode 100644 index 0000000000..37d873847a --- /dev/null +++ b/packages/api/src/hooks/useActivities.cause.spec.tsx @@ -0,0 +1,186 @@ +/** @jest-environment @happy-dom/jest-environment */ +/// + +import { createStore, type WebChatActivity } from 'botframework-webchat-core'; +import { type ReactNode } from 'react'; + +const incoming = (id: string, text: string = id) => ({ + payload: { + activity: { channelData: {}, from: { id: 'bot', role: 'bot' }, id, text, type: 'message' } + }, + type: 'DIRECT_LINE/INCOMING_ACTIVITY' +}); + +// Renders useActivities() with the graph mocked, so the test decides what the graph returns. +function renderActivities({ graphIsEmpty = false }: { readonly graphIsEmpty?: boolean } = {}) { + let modules: { + readonly React: typeof import('react'); + readonly render: typeof import('@testing-library/react/pure').render; + readonly useActivities: typeof import('./useActivities').default; + readonly WebChatReduxProvider: typeof import('./internal/WebChatReduxProvider').default; + }; + + const store = createStore(); + + store.dispatch(incoming('a-1')); + store.dispatch(incoming('a-2')); + + const graph: { activities: readonly WebChatActivity[] } = { + activities: graphIsEmpty ? Object.freeze([]) : store.getState().activities + }; + + jest.isolateModules(() => { + jest.doMock('@msinternal/botframework-webchat-api-graph', () => ({ + useOrderedActivities: () => Object.freeze([graph.activities]) + })); + + modules = { + React: require('react'), + render: require('@testing-library/react/pure').render, + useActivities: require('./useActivities').default, + WebChatReduxProvider: require('./internal/WebChatReduxProvider').default + }; + }); + + const { React, render, useActivities, WebChatReduxProvider } = modules; + const errors: unknown[] = []; + // React 16 in development also rethrows the error caught by the error boundary. + const catchError = (fn: () => void) => { + try { + fn(); + } catch (error) { + errors.includes(error) || errors.push(error); + } + }; + + class ErrorBoundary extends React.Component<{ readonly children?: ReactNode }, { readonly error: unknown }> { + constructor(props: { readonly children?: ReactNode }) { + super(props); + + this.state = { error: undefined }; + } + + static getDerivedStateFromError(error: unknown) { + errors.includes(error) || errors.push(error); + + return { error }; + } + + render() { + const { children } = this.props; + const { error } = this.state; + + return error ? null : children; + } + } + + function Activities() { + useActivities(); + + return null; + } + + let unmount: () => void; + + catchError(() => { + ({ unmount } = render( + + + + + + )); + }); + + return { + dispatch: (action: ReturnType) => catchError(() => store.dispatch(action)), + errors, + graph, + store, + unmount: () => unmount?.() + }; +} + +describe('useActivities() when graph and Redux differ', () => { + let consoleError: jest.SpyInstance; + + beforeEach(() => { + // React logs errors caught by error boundaries. + consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => consoleError.mockRestore()); + + test('should throw with everything as added, on the first render', () => { + const { errors, store, unmount } = renderActivities({ graphIsEmpty: true }); + const [activity1, activity2] = store.getState().activities; + + expect(errors).toHaveLength(1); + expect(errors[0]).toHaveProperty('cause', { + activitiesFromGraph: { added: [], changed: [], deleted: [], everything: [] }, + activitiesFromRedux: { + added: [activity1, activity2], + changed: [], + deleted: [], + everything: [activity1, activity2] + } + }); + + unmount(); + }); + + test('should throw with what was added and deleted on each side, when sizes differ', () => { + const { dispatch, errors, graph, store, unmount } = renderActivities(); + const [activity1, activity2] = store.getState().activities; + + expect(errors).toEqual([]); + + // Graph loses "a-1" while Redux receives "a-3". + graph.activities = Object.freeze([activity2]); + dispatch(incoming('a-3')); + + const [, , activity3] = store.getState().activities; + + expect(activity3).toHaveProperty('id', 'a-3'); + expect(errors).toHaveLength(1); + expect(errors[0]).toHaveProperty( + 'message', + 'botframework-webchat-internal: Activities from graph and Redux are of different size (graph has 1 activities, Redux has 3 activities)' + ); + expect(errors[0]).toHaveProperty('cause', { + activitiesFromGraph: { added: [], changed: [], deleted: [activity1], everything: [activity2] }, + activitiesFromRedux: { + added: [activity3], + changed: [], + deleted: [], + everything: [activity1, activity2, activity3] + } + }); + + unmount(); + }); + + test('should throw with what was changed on each side, when an activity differs', () => { + const { dispatch, errors, store, unmount } = renderActivities(); + const [activity1, activity2] = store.getState().activities; + + // Redux replaces "a-2" (same local ID), the graph keeps the old one. + dispatch(incoming('a-2', 'updated')); + + const [, nextActivity2] = store.getState().activities; + + expect(nextActivity2).toHaveProperty('text', 'updated'); + expect(errors).toHaveLength(1); + expect(errors[0]).toHaveProperty( + 'message', + 'botframework-webchat-internal: Activities from graph and Redux are different at index 1' + ); + expect(errors[0]).toHaveProperty('cause', { + activitiesFromGraph: { added: [], changed: [], deleted: [], everything: [activity1, activity2] }, + activitiesFromRedux: { added: [], changed: [nextActivity2], deleted: [], everything: [activity1, nextActivity2] }, + index: 1 + }); + + unmount(); + }); +}); diff --git a/packages/api/src/hooks/useActivities.ts b/packages/api/src/hooks/useActivities.ts index c6db5813e8..1c66fd87ea 100644 --- a/packages/api/src/hooks/useActivities.ts +++ b/packages/api/src/hooks/useActivities.ts @@ -1,5 +1,6 @@ import { useOrderedActivities } from '@msinternal/botframework-webchat-api-graph'; import type { WebChatActivity } from 'botframework-webchat-core'; +import { useEffect, useRef } from 'react'; import { useSelector } from './internal/WebChatReduxContext'; @@ -11,6 +12,28 @@ declare const process: { const selectActivities = ({ activities }) => activities; +// Keyed by local ID, falls back to the object itself. +const getKey = (activity: WebChatActivity): unknown => activity.channelData?.['webchat:internal:local-id'] ?? activity; + +/** What was added, changed (same key, different object) and deleted since the last committed render. */ +function diffActivities(previous: readonly WebChatActivity[], everything: readonly WebChatActivity[]) { + const previousByKey = new Map(previous.map(activity => [getKey(activity), activity])); + const keys = new Set(everything.map(getKey)); + + return Object.freeze({ + added: Object.freeze(everything.filter(activity => !previousByKey.has(getKey(activity)))), + changed: Object.freeze( + everything.filter(activity => { + const key = getKey(activity); + + return previousByKey.has(key) && !Object.is(previousByKey.get(key), activity); + }) + ), + deleted: Object.freeze(previous.filter(activity => !keys.has(getKey(activity)))), + everything + }); +} + function useActivitiesForProduction(): readonly [readonly WebChatActivity[]] { return useOrderedActivities(); } @@ -20,17 +43,27 @@ function useActivitiesForDevelopment(): readonly [readonly WebChatActivity[]] { const [activitiesFromGraph] = activitiesFromGraphState; - const activitiesFromRedux = useSelector(selectActivities); + const activitiesFromRedux: readonly WebChatActivity[] = useSelector(selectActivities); + const previousRef = useRef<{ + readonly activitiesFromGraph: readonly WebChatActivity[]; + readonly activitiesFromRedux: readonly WebChatActivity[]; + }>({ activitiesFromGraph: [], activitiesFromRedux: [] }); + + // Only committed renders count as "previous". + useEffect(() => { + previousRef.current = { activitiesFromGraph, activitiesFromRedux }; + }, [activitiesFromGraph, activitiesFromRedux]); + + const getCause = () => + Object.freeze({ + activitiesFromGraph: diffActivities(previousRef.current.activitiesFromGraph, activitiesFromGraph), + activitiesFromRedux: diffActivities(previousRef.current.activitiesFromRedux, activitiesFromRedux) + }); if (activitiesFromGraph.length !== activitiesFromRedux.length) { throw new Error( `botframework-webchat-internal: Activities from graph and Redux are of different size (graph has ${activitiesFromGraph.length} activities, Redux has ${activitiesFromRedux.length} activities)`, - { - cause: { - activitiesFromGraph, - activitiesFromRedux - } - } + { cause: getCause() } ); } @@ -39,11 +72,7 @@ function useActivitiesForDevelopment(): readonly [readonly WebChatActivity[]] { throw new Error( `botframework-webchat-internal: Activities from graph and Redux are different at index ${index}`, { - cause: { - activitiesFromGraph, - activitiesFromRedux, - index - } + cause: Object.freeze({ ...getCause(), index }) } ); } From 2159def59ff9c8cc73253401a7db67ae8a7d6bfd Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 12 Nov 2026 13:42:35 -0800 Subject: [PATCH 20/26] Test that the graph is in sync with the store when read without subscribers, as when an Activity shows Web Chat again --- .../react19/activity.hideShow.transcript.html | 98 ++++++++++++++++++ ...tivity.hideShow.transcript.html.snap-1.png | Bin 0 -> 4464 bytes ...ivity.hideShow.transcript.sameElement.html | 92 ++++++++++++++++ ...how.transcript.sameElement.html.snap-1.png | Bin 0 -> 4464 bytes .../src/graph/createGraphFromStore.spec.ts | 41 +++++++- 5 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 __tests__/html2/react19/activity.hideShow.transcript.html create mode 100644 __tests__/html2/react19/activity.hideShow.transcript.html.snap-1.png create mode 100644 __tests__/html2/react19/activity.hideShow.transcript.sameElement.html create mode 100644 __tests__/html2/react19/activity.hideShow.transcript.sameElement.html.snap-1.png diff --git a/__tests__/html2/react19/activity.hideShow.transcript.html b/__tests__/html2/react19/activity.hideShow.transcript.html new file mode 100644 index 0000000000..1f45e9cc0a --- /dev/null +++ b/__tests__/html2/react19/activity.hideShow.transcript.html @@ -0,0 +1,98 @@ + + + + React 19: Web Chat keeps its transcript when an <Activity> hides and shows it + + + +
+ + + + diff --git a/__tests__/html2/react19/activity.hideShow.transcript.html.snap-1.png b/__tests__/html2/react19/activity.hideShow.transcript.html.snap-1.png new file mode 100644 index 0000000000000000000000000000000000000000..041fcd25008f4b64afdaa84c2d68a5f265700be5 GIT binary patch literal 4464 zcmeI0X*gSX8^(hfidRkRQez3z>9pSI!e|>?g&JFpZImL2XcZAlbciJgQ!S&lj4Enx zMQss5EF~ni)@Yj=N|IU<8cT}^RY4;19^c>Vn)ma2_1iiBbFOp#=Q;O%|DNl~_|@4? zNkKya1Oh49U$MRl0!bl3AnBAt(!h#&9A+H^`UYfgZRs9Yx;o?8;2uaD+OU*r_P1IE zC)|e$*a2DCfEyXMDh`zhL2Q+BNi#!W!x%nbf{r<6YsP!BS zZ?eskyz4=7vgA!2ZQ8$ z-?MGFe=c_V)=-*e#LkdTa;soGrfhf2K|eg16*U6&BrTVqsUc@O1f+Gubo5&%Ii1AF zDAAybiy1fbf<{w}mlf{0=%Jf4tjXVUqP{Ko@5h!7g?i!-_?$@{k4v%d0t4(RR=#W)bvx=^>*~hB9Vk41&moRw5?(^z9vP*@zr|&JBlhvKHAV(LojX`1uyX#|h z$(wR0MNHlkOrh1_(W5~n;W7R4y4|109C#KF?2Nq#4VjVSKfGrJM(x6h%F| zxcs6r^4R>in+uVolepg3xm50>`siZF*eiXS+0DQH+#Io>#d`rdD34z(!qQ2!eW9&p z`1t;hjXi=%(-s5`M*o zj#etT;fY7K_DNDUZTPg~bQcrCZLJvA^&ae2J&(etQWSoBb*>|N>f=I zaeLm?4ty;ydy!riGZ``h-45$&T!IJof!X1{>n3Mb||!6cojht3#Jz7G{g_5RZcn9lCBjk z(oJ^_hP2*xb@-IXHh?`|kK7vnlUglKHsK>^p3GR0Mp~=I;~2e_Fu;eTXRRE!w*?Fh zYq>XOo3DSd#fJ~Ns%2<~kJi;rkeO#nO);pbz}AA(i}Vsthi`c!9AwG&5H#bbdA=^% z`L-cDj>{Em?q#LMSjZU6@Em(+J4=^h1`L?=hv%x_19A&tX%y3F~&iX_3u&l$yv z6%dE|QR`4=SGNZ)=DSsst|LA?z?)DKK*Fv|(z29a9mMuK0;- zo^5)kne)icA4Y*Zi~b~6gpwy?AoDb9IGxB$1PTZ7u#()Bl&_<`#EeZ;_20NvP(vbt zLz+<5HhU)3aMe8LA6py7uzn{13d~&pT_uk@!on~~#=R!t@;r07@GR_nz>7?q`^O+V zL-iVfEkDVpHb|Mu%k^^9O=SK{+Zfi_ZxA`#3)sEJ@QpDyQ;@7obUR_2$pU1<0E#2q zBnxY-xM2oVj!%>9!(-k(J+jOsAy!;^r9(3@e5z<|!7O@beaXWB*^`z1>Yj;>L5!R9PkU^Ea2r>oA`@{o(_KpFh&Qq3gXedAcs_SWZ%? zs3^D`){1lm-YZQQRPf=(iFh3@CJPU-Xnj~q)dN?PXY2incG+6-0CmE&t7X9`C`B$J`Jrvm4x@gKDyG@Q`lLP7YjnDEE=^O=slZ` z<7O2iaJ#JLfY1kHUJ>+8N7b5m^77x)?|bEgfZ>4p!7RWnYlljIbBXVJA57;h79VO- zz#Ppk))@b;lF9?v)YX2_Gfcu9s@ibxkR{QXnnsO=$MpSt3*o5et?eCv6f`c4Ke_>U zfjD?}Bskvv)beXO?G*fE{WVq)7|4HOJ!ISyk+cOEx5zNu`{|ST#4KafXse%9I8(9Kd24NkQcgH<;$L!q?B+)O t55tFMi4+JVkpwt@l?BXAWDklY16r4wj7kM-@PHMNy^XVV + + + React 19: Web Chat shows activities added while an <Activity> hid it, without rendering on show + + + +
+ + + + diff --git a/__tests__/html2/react19/activity.hideShow.transcript.sameElement.html.snap-1.png b/__tests__/html2/react19/activity.hideShow.transcript.sameElement.html.snap-1.png new file mode 100644 index 0000000000000000000000000000000000000000..041fcd25008f4b64afdaa84c2d68a5f265700be5 GIT binary patch literal 4464 zcmeI0X*gSX8^(hfidRkRQez3z>9pSI!e|>?g&JFpZImL2XcZAlbciJgQ!S&lj4Enx zMQss5EF~ni)@Yj=N|IU<8cT}^RY4;19^c>Vn)ma2_1iiBbFOp#=Q;O%|DNl~_|@4? zNkKya1Oh49U$MRl0!bl3AnBAt(!h#&9A+H^`UYfgZRs9Yx;o?8;2uaD+OU*r_P1IE zC)|e$*a2DCfEyXMDh`zhL2Q+BNi#!W!x%nbf{r<6YsP!BS zZ?eskyz4=7vgA!2ZQ8$ z-?MGFe=c_V)=-*e#LkdTa;soGrfhf2K|eg16*U6&BrTVqsUc@O1f+Gubo5&%Ii1AF zDAAybiy1fbf<{w}mlf{0=%Jf4tjXVUqP{Ko@5h!7g?i!-_?$@{k4v%d0t4(RR=#W)bvx=^>*~hB9Vk41&moRw5?(^z9vP*@zr|&JBlhvKHAV(LojX`1uyX#|h z$(wR0MNHlkOrh1_(W5~n;W7R4y4|109C#KF?2Nq#4VjVSKfGrJM(x6h%F| zxcs6r^4R>in+uVolepg3xm50>`siZF*eiXS+0DQH+#Io>#d`rdD34z(!qQ2!eW9&p z`1t;hjXi=%(-s5`M*o zj#etT;fY7K_DNDUZTPg~bQcrCZLJvA^&ae2J&(etQWSoBb*>|N>f=I zaeLm?4ty;ydy!riGZ``h-45$&T!IJof!X1{>n3Mb||!6cojht3#Jz7G{g_5RZcn9lCBjk z(oJ^_hP2*xb@-IXHh?`|kK7vnlUglKHsK>^p3GR0Mp~=I;~2e_Fu;eTXRRE!w*?Fh zYq>XOo3DSd#fJ~Ns%2<~kJi;rkeO#nO);pbz}AA(i}Vsthi`c!9AwG&5H#bbdA=^% z`L-cDj>{Em?q#LMSjZU6@Em(+J4=^h1`L?=hv%x_19A&tX%y3F~&iX_3u&l$yv z6%dE|QR`4=SGNZ)=DSsst|LA?z?)DKK*Fv|(z29a9mMuK0;- zo^5)kne)icA4Y*Zi~b~6gpwy?AoDb9IGxB$1PTZ7u#()Bl&_<`#EeZ;_20NvP(vbt zLz+<5HhU)3aMe8LA6py7uzn{13d~&pT_uk@!on~~#=R!t@;r07@GR_nz>7?q`^O+V zL-iVfEkDVpHb|Mu%k^^9O=SK{+Zfi_ZxA`#3)sEJ@QpDyQ;@7obUR_2$pU1<0E#2q zBnxY-xM2oVj!%>9!(-k(J+jOsAy!;^r9(3@e5z<|!7O@beaXWB*^`z1>Yj;>L5!R9PkU^Ea2r>oA`@{o(_KpFh&Qq3gXedAcs_SWZ%? zs3^D`){1lm-YZQQRPf=(iFh3@CJPU-Xnj~q)dN?PXY2incG+6-0CmE&t7X9`C`B$J`Jrvm4x@gKDyG@Q`lLP7YjnDEE=^O=slZ` z<7O2iaJ#JLfY1kHUJ>+8N7b5m^77x)?|bEgfZ>4p!7RWnYlljIbBXVJA57;h79VO- zz#Ppk))@b;lF9?v)YX2_Gfcu9s@ibxkR{QXnnsO=$MpSt3*o5et?eCv6f`c4Ke_>U zfjD?}Bskvv)beXO?G*fE{WVq)7|4HOJ!ISyk+cOEx5zNu`{|ST#4KafXse%9I8(9Kd24NkQcgH<;$L!q?B+)O t55tFMi4+JVkpwt@l?BXAWDklY16r4wj7kM-@PHMNy^XVV { 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 }) => { expect(subscriptions.size).toBe(0); - expect(activityIdsOf(graph)).toEqual(['a-1', 'a-2']); expect(subscriber1).toHaveBeenCalledTimes(1); }) .when('a subscriber subscribes again', (_, { graph }) => { @@ -103,4 +102,42 @@ scenario('createGraphFromStore()', bdd => { expect(subscriptions.size).toBe(1); } ); + + // E.g. a hidden (React 19.2+) runs the cleanup of effects, then renders before subscribing again. + bdd + .given('a graph from a store, which counts its subscriptions', () => { + const store = createStore(); + const { subscribe } = store; + const subscriptions = new Set<() => void>(); + + store.subscribe = listener => { + const unsubscribe = subscribe(listener); + const subscription = () => { + subscriptions.delete(subscription); + unsubscribe(); + }; + + subscriptions.add(subscription); + + return subscription; + }; + + store.dispatch(incoming('a-1')); + + const graph = createGraphFromStore(store); + + graph.subscribe(jest.fn())(); + + return { graph, store, subscriptions }; + }) + .when('an activity is received while the graph has no subscribers', ({ store }) => { + store.dispatch(incoming('a-2')); + }) + .then('reading the graph should catch up with the store, without subscribing to it', ({ graph, subscriptions }) => { + expect(activityIdsOf(graph)).toEqual(['a-1', 'a-2']); + expect(subscriptions.size).toBe(0); + }) + .and('reading the graph again should return the same state', ({ graph }) => + expect(graph.getState()).toBe(graph.getState()) + ); }); From 68eab02204478d2b1bc25dd5e8a3ca6f9cffccb6 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 12 Nov 2026 13:42:35 -0800 Subject: [PATCH 21/26] Catch up with the store when the graph is read without subscribers The graph follows the store only while it has subscribers. A hidden React 19.2 runs the cleanup of effects, so the graph stops following the store; showing Web Chat again with a new element renders before the effects subscribe again, and read a graph that missed the changes made while hidden. The development check of useActivities() threw. getState() now catches up with the store when the graph has no subscribers. It returns the same state until the store activities change. --- packages/core/src/graph/createGraphFromStore.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/core/src/graph/createGraphFromStore.ts b/packages/core/src/graph/createGraphFromStore.ts index 88e9d6b2b2..e231d23c92 100644 --- a/packages/core/src/graph/createGraphFromStore.ts +++ b/packages/core/src/graph/createGraphFromStore.ts @@ -46,6 +46,9 @@ function createGraphFromStore(store: ReturnType): SlantGraph // TODO: [P*] Supports deleting node from the graph. const addedActivities = activitySet.difference(prevActivitySet); + // Before act(): its middleware reads getState(), which catches up while the graph has no subscribers. + prevActivities = activities; + graph.act(graph => { for (const activity of addedActivities) { const { @@ -111,8 +114,6 @@ function createGraphFromStore(store: ReturnType): SlantGraph } } }); - - prevActivities = activities; }; // Activities already in the store (e.g. rehydrated) are added now, not on the first subscription. @@ -121,9 +122,18 @@ function createGraphFromStore(store: ReturnType): 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(); let unsubscribeFromStore: (() => void) | undefined; + // Catches up with changes made while the graph has no subscribers, e.g. when a hidden (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 || handleStoreChange(); + + return getGraphState(); + }; + graph.subscribe = subscriber => { const unsubscribe = subscribeToGraph(subscriber); From 083fdbcb7987247fe82e20de7953981e6d14b70e Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 12 Nov 2026 13:47:21 -0800 Subject: [PATCH 22/26] Extend the catch-up scenario of createGraphFromStore with a read without subscribers --- .../src/graph/createGraphFromStore.spec.ts | 55 ++++++------------- 1 file changed, 17 insertions(+), 38 deletions(-) diff --git a/packages/core/src/graph/createGraphFromStore.spec.ts b/packages/core/src/graph/createGraphFromStore.spec.ts index c19a379d9d..f7ccf34d98 100644 --- a/packages/core/src/graph/createGraphFromStore.spec.ts +++ b/packages/core/src/graph/createGraphFromStore.spec.ts @@ -90,9 +90,7 @@ scenario('createGraphFromStore()', bdd => { .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', @@ -101,43 +99,24 @@ scenario('createGraphFromStore()', bdd => { expect(subscriber).toHaveBeenCalledTimes(1); expect(subscriptions.size).toBe(1); } - ); - - // E.g. a hidden (React 19.2+) runs the cleanup of effects, then renders before subscribing again. - bdd - .given('a graph from a store, which counts its subscriptions', () => { - const store = createStore(); - const { subscribe } = store; - const subscriptions = new Set<() => void>(); - - store.subscribe = listener => { - const unsubscribe = subscribe(listener); - const subscription = () => { - subscriptions.delete(subscription); - unsubscribe(); - }; - - subscriptions.add(subscription); - - return subscription; - }; - - store.dispatch(incoming('a-1')); - - const graph = createGraphFromStore(store); - - graph.subscribe(jest.fn())(); + ) + // A hidden (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 { graph, store, subscriptions }; - }) - .when('an activity is received while the graph has no subscribers', ({ store }) => { - store.dispatch(incoming('a-2')); - }) - .then('reading the graph should catch up with the store, without subscribing to it', ({ graph, subscriptions }) => { - expect(activityIdsOf(graph)).toEqual(['a-1', 'a-2']); - expect(subscriptions.size).toBe(0); + return outcome; }) - .and('reading the graph again should return the same state', ({ graph }) => + .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()) ); }); From 3bf66bce7c22e7573da3805e9008a18d4d8aab04 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 12 Nov 2026 14:10:45 -0800 Subject: [PATCH 23/26] Move the Redux context and its hooks to the redux-store package --- package-lock.json | 3 ++- packages/api/src/hooks/Composer.tsx | 4 +--- .../api/src/hooks/internal/ReduxStoreProvider.spec.tsx | 2 +- packages/api/src/hooks/internal/ReduxStoreProvider.tsx | 3 +-- packages/api/src/hooks/internal/usePostVoiceActivity.ts | 2 +- .../api/src/hooks/internal/useRegisterVoiceHandler.ts | 2 +- packages/api/src/hooks/internal/useVoiceHandlers.ts | 2 +- packages/api/src/hooks/internal/useVoiceStateWritable.ts | 2 +- packages/api/src/hooks/useActivities.cause.spec.tsx | 4 ++-- .../api/src/hooks/useActivities.deleteActivity.spec.tsx | 4 ++-- packages/api/src/hooks/useActivities.ts | 3 +-- packages/api/src/hooks/useConnectivityStatus.ts | 2 +- packages/api/src/hooks/useDictateInterims.ts | 3 ++- packages/api/src/hooks/useDictateState.ts | 2 +- packages/api/src/hooks/useNotifications.ts | 3 ++- packages/api/src/hooks/useReferenceGrammarID.ts | 2 +- packages/api/src/hooks/useSendBoxAttachments.ts | 2 +- packages/api/src/hooks/useSendBoxValue.ts | 3 ++- packages/api/src/hooks/useSendTypingIndicator.ts | 2 +- packages/api/src/hooks/useShouldSpeakIncomingActivity.ts | 2 +- packages/api/src/hooks/useVoiceRecordingMuted.ts | 2 +- packages/redux-store/package.json | 7 ++++++- .../internal => redux-store/src}/WebChatReduxContext.ts | 0 .../src}/WebChatReduxProvider.spec.tsx | 2 +- .../src}/WebChatReduxProvider.tsx | 0 packages/redux-store/src/index.ts | 9 +++++++++ 26 files changed, 43 insertions(+), 29 deletions(-) rename packages/{api/src/hooks/internal => redux-store/src}/WebChatReduxContext.ts (100%) rename packages/{api/src/hooks/internal => redux-store/src}/WebChatReduxProvider.spec.tsx (98%) rename packages/{api/src/hooks/internal => redux-store/src}/WebChatReduxProvider.tsx (100%) diff --git a/package-lock.json b/package-lock.json index c6bf37de36..9bb0d5534e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21648,7 +21648,8 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "react": ">= 16.8.6" + "react": ">= 16.8.6", + "react-dom": ">= 16.8.6" } }, "packages/repack/adaptivecards": { diff --git a/packages/api/src/hooks/Composer.tsx b/packages/api/src/hooks/Composer.tsx index c1d5584fb6..4c4c9752c6 100644 --- a/packages/api/src/hooks/Composer.tsx +++ b/packages/api/src/hooks/Composer.tsx @@ -6,7 +6,7 @@ import { import { isPlainObject, singleToArray, type OneOrMany } from '@msinternal/botframework-webchat-base/utils'; import { useMemoIterable } from '@msinternal/botframework-webchat-react-hooks'; import { reactNode, validateProps } from '@msinternal/botframework-webchat-react-valibot'; -import { ReduxStoreComposer } from '@msinternal/botframework-webchat-redux-store'; +import { ReduxStoreComposer, useDispatch, WebChatReduxProvider } from '@msinternal/botframework-webchat-redux-store'; import { clearSuggestedActions, connect as createConnectAction, @@ -98,8 +98,6 @@ import normalizeLanguage from '../utils/normalizeLanguage'; import ReduxStoreProvider from './internal/ReduxStoreProvider'; import Tracker from './internal/Tracker'; import WebChatAPIContext, { type WebChatAPIContextType } from './internal/WebChatAPIContext'; -import { useDispatch } from './internal/WebChatReduxContext'; -import WebChatReduxProvider from './internal/WebChatReduxProvider'; import defaultSelectVoice from './internal/defaultSelectVoice'; import useVoiceHandlers from './internal/useVoiceHandlers'; import activityFallbackPolymiddleware from './middleware/activityFallbackPolymiddleware'; diff --git a/packages/api/src/hooks/internal/ReduxStoreProvider.spec.tsx b/packages/api/src/hooks/internal/ReduxStoreProvider.spec.tsx index eb8fbc97bc..921077f887 100644 --- a/packages/api/src/hooks/internal/ReduxStoreProvider.spec.tsx +++ b/packages/api/src/hooks/internal/ReduxStoreProvider.spec.tsx @@ -2,6 +2,7 @@ /// import * as JestGlobals from '@jest/globals'; +import { WebChatReduxProvider } from '@msinternal/botframework-webchat-redux-store'; import { render } from '@testing-library/react'; import { scenario } from '@testduet/given-when-then'; import { createStore } from 'botframework-webchat-core'; @@ -9,7 +10,6 @@ import React, { Fragment, StrictMode } from 'react'; import useActivities from '../useActivities'; import ReduxStoreProvider from './ReduxStoreProvider'; -import WebChatReduxProvider from './WebChatReduxProvider'; function Activities() { useActivities(); diff --git a/packages/api/src/hooks/internal/ReduxStoreProvider.tsx b/packages/api/src/hooks/internal/ReduxStoreProvider.tsx index a3a00c19e4..d837c51658 100644 --- a/packages/api/src/hooks/internal/ReduxStoreProvider.tsx +++ b/packages/api/src/hooks/internal/ReduxStoreProvider.tsx @@ -1,10 +1,9 @@ import { GraphProvider } from '@msinternal/botframework-webchat-api-graph'; import { reactNode, validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; +import { useWebChatReduxContext } from '@msinternal/botframework-webchat-redux-store'; import React, { memo, useMemo } from 'react'; import { object, optional, pipe, readonly } from 'valibot'; -import { useWebChatReduxContext } from './WebChatReduxContext'; - const reduxStoreProviderPropsSchema = pipe( object({ children: optional(reactNode()) diff --git a/packages/api/src/hooks/internal/usePostVoiceActivity.ts b/packages/api/src/hooks/internal/usePostVoiceActivity.ts index 18f735cff5..d905740778 100644 --- a/packages/api/src/hooks/internal/usePostVoiceActivity.ts +++ b/packages/api/src/hooks/internal/usePostVoiceActivity.ts @@ -1,6 +1,6 @@ +import { useDispatch } from '@msinternal/botframework-webchat-redux-store'; import { postVoiceActivity, type WebChatActivity } from 'botframework-webchat-core'; import { useCallback } from 'react'; -import { useDispatch } from './WebChatReduxContext'; /** * Hook to post voice activities (fire-and-forget, no echo back). diff --git a/packages/api/src/hooks/internal/useRegisterVoiceHandler.ts b/packages/api/src/hooks/internal/useRegisterVoiceHandler.ts index 149bd97f2a..0d9150500e 100644 --- a/packages/api/src/hooks/internal/useRegisterVoiceHandler.ts +++ b/packages/api/src/hooks/internal/useRegisterVoiceHandler.ts @@ -1,7 +1,7 @@ +import { useDispatch } from '@msinternal/botframework-webchat-redux-store'; import { registerVoiceHandler, unregisterVoiceHandler, type VoiceHandler } from 'botframework-webchat-core'; import { useCallback } from 'react'; import randomId from '../../utils/randomId'; -import { useDispatch } from './WebChatReduxContext'; /** * Internal hook to register a voice handler for audio playback. diff --git a/packages/api/src/hooks/internal/useVoiceHandlers.ts b/packages/api/src/hooks/internal/useVoiceHandlers.ts index ae14015314..563e365af7 100644 --- a/packages/api/src/hooks/internal/useVoiceHandlers.ts +++ b/packages/api/src/hooks/internal/useVoiceHandlers.ts @@ -1,5 +1,5 @@ +import { useSelector } from '@msinternal/botframework-webchat-redux-store'; import type { VoiceHandler } from 'botframework-webchat-core'; -import { useSelector } from './WebChatReduxContext'; const selectVoiceHandlers = (state: { voice: { voiceHandlers: Map } }) => state.voice.voiceHandlers; diff --git a/packages/api/src/hooks/internal/useVoiceStateWritable.ts b/packages/api/src/hooks/internal/useVoiceStateWritable.ts index 034b66e59b..29059c65e9 100644 --- a/packages/api/src/hooks/internal/useVoiceStateWritable.ts +++ b/packages/api/src/hooks/internal/useVoiceStateWritable.ts @@ -1,6 +1,6 @@ +import { useDispatch, useSelector } from '@msinternal/botframework-webchat-redux-store'; import { setVoiceState, type VoiceState } from 'botframework-webchat-core'; import { useCallback } from 'react'; -import { useDispatch, useSelector } from './WebChatReduxContext'; const selectVoiceState = ({ voice }) => voice.voiceState; diff --git a/packages/api/src/hooks/useActivities.cause.spec.tsx b/packages/api/src/hooks/useActivities.cause.spec.tsx index 37d873847a..7a9265b0eb 100644 --- a/packages/api/src/hooks/useActivities.cause.spec.tsx +++ b/packages/api/src/hooks/useActivities.cause.spec.tsx @@ -17,7 +17,7 @@ function renderActivities({ graphIsEmpty = false }: { readonly graphIsEmpty?: bo readonly React: typeof import('react'); readonly render: typeof import('@testing-library/react/pure').render; readonly useActivities: typeof import('./useActivities').default; - readonly WebChatReduxProvider: typeof import('./internal/WebChatReduxProvider').default; + readonly WebChatReduxProvider: typeof import('@msinternal/botframework-webchat-redux-store').WebChatReduxProvider; }; const store = createStore(); @@ -38,7 +38,7 @@ function renderActivities({ graphIsEmpty = false }: { readonly graphIsEmpty?: bo React: require('react'), render: require('@testing-library/react/pure').render, useActivities: require('./useActivities').default, - WebChatReduxProvider: require('./internal/WebChatReduxProvider').default + WebChatReduxProvider: require('@msinternal/botframework-webchat-redux-store').WebChatReduxProvider }; }); diff --git a/packages/api/src/hooks/useActivities.deleteActivity.spec.tsx b/packages/api/src/hooks/useActivities.deleteActivity.spec.tsx index d80d8f7275..9d0ceb52da 100644 --- a/packages/api/src/hooks/useActivities.deleteActivity.spec.tsx +++ b/packages/api/src/hooks/useActivities.deleteActivity.spec.tsx @@ -21,7 +21,7 @@ function renderActivities() { readonly ReduxStoreProvider: typeof import('./internal/ReduxStoreProvider').default; readonly render: typeof import('@testing-library/react/pure').render; readonly useActivities: typeof import('./useActivities').default; - readonly WebChatReduxProvider: typeof import('./internal/WebChatReduxProvider').default; + readonly WebChatReduxProvider: typeof import('@msinternal/botframework-webchat-redux-store').WebChatReduxProvider; }; jest.isolateModules(() => { @@ -33,7 +33,7 @@ function renderActivities() { ReduxStoreProvider: require('./internal/ReduxStoreProvider').default, render, useActivities: require('./useActivities').default, - WebChatReduxProvider: require('./internal/WebChatReduxProvider').default + WebChatReduxProvider: require('@msinternal/botframework-webchat-redux-store').WebChatReduxProvider }; }); diff --git a/packages/api/src/hooks/useActivities.ts b/packages/api/src/hooks/useActivities.ts index 1c66fd87ea..ae9e5d72ee 100644 --- a/packages/api/src/hooks/useActivities.ts +++ b/packages/api/src/hooks/useActivities.ts @@ -1,9 +1,8 @@ import { useOrderedActivities } from '@msinternal/botframework-webchat-api-graph'; +import { useSelector } from '@msinternal/botframework-webchat-redux-store'; import type { WebChatActivity } from 'botframework-webchat-core'; import { useEffect, useRef } from 'react'; -import { useSelector } from './internal/WebChatReduxContext'; - declare const process: { env: { NODE_ENV?: string | undefined; diff --git a/packages/api/src/hooks/useConnectivityStatus.ts b/packages/api/src/hooks/useConnectivityStatus.ts index 37449991c6..cd4ab9440b 100644 --- a/packages/api/src/hooks/useConnectivityStatus.ts +++ b/packages/api/src/hooks/useConnectivityStatus.ts @@ -1,4 +1,4 @@ -import { useSelector } from './internal/WebChatReduxContext'; +import { useSelector } from '@msinternal/botframework-webchat-redux-store'; const selectConnectivityStatus = ({ connectivityStatus }) => connectivityStatus; diff --git a/packages/api/src/hooks/useDictateInterims.ts b/packages/api/src/hooks/useDictateInterims.ts index eecf390df6..e86291a1d2 100644 --- a/packages/api/src/hooks/useDictateInterims.ts +++ b/packages/api/src/hooks/useDictateInterims.ts @@ -1,4 +1,5 @@ -import { useSelector } from './internal/WebChatReduxContext'; +import { useSelector } from '@msinternal/botframework-webchat-redux-store'; + import useWebChatAPIContext from './internal/useWebChatAPIContext'; const selectDictateInterims = ({ dictateInterims }) => dictateInterims; diff --git a/packages/api/src/hooks/useDictateState.ts b/packages/api/src/hooks/useDictateState.ts index 8d6d471514..56530846be 100644 --- a/packages/api/src/hooks/useDictateState.ts +++ b/packages/api/src/hooks/useDictateState.ts @@ -1,4 +1,4 @@ -import { useSelector } from './internal/WebChatReduxContext'; +import { useSelector } from '@msinternal/botframework-webchat-redux-store'; // TODO: [P3] We should update this code to use core/src/selectors/dictateState.js const selectDictateState = ({ dictateState }) => dictateState; diff --git a/packages/api/src/hooks/useNotifications.ts b/packages/api/src/hooks/useNotifications.ts index 063d0b0b79..4d71ef89bb 100644 --- a/packages/api/src/hooks/useNotifications.ts +++ b/packages/api/src/hooks/useNotifications.ts @@ -1,5 +1,6 @@ +import { useSelector } from '@msinternal/botframework-webchat-redux-store'; + import { type Notification } from '../types/Notification'; -import { useSelector } from './internal/WebChatReduxContext'; const selectNotifications = ({ notifications }) => notifications; diff --git a/packages/api/src/hooks/useReferenceGrammarID.ts b/packages/api/src/hooks/useReferenceGrammarID.ts index 16b3464b21..bd482e7dc3 100644 --- a/packages/api/src/hooks/useReferenceGrammarID.ts +++ b/packages/api/src/hooks/useReferenceGrammarID.ts @@ -1,4 +1,4 @@ -import { useSelector } from './internal/WebChatReduxContext'; +import { useSelector } from '@msinternal/botframework-webchat-redux-store'; const selectReferenceGrammarID = ({ referenceGrammarID }) => referenceGrammarID; diff --git a/packages/api/src/hooks/useSendBoxAttachments.ts b/packages/api/src/hooks/useSendBoxAttachments.ts index a6d1e4a16a..09da357ec1 100644 --- a/packages/api/src/hooks/useSendBoxAttachments.ts +++ b/packages/api/src/hooks/useSendBoxAttachments.ts @@ -1,7 +1,7 @@ +import { useSelector } from '@msinternal/botframework-webchat-redux-store'; import type { SendBoxAttachment } from 'botframework-webchat-core'; import { useMemo } from 'react'; -import { useSelector } from './internal/WebChatReduxContext'; import useWebChatAPIContext from './internal/useWebChatAPIContext'; // TODO: We should use the selector from "core" package. diff --git a/packages/api/src/hooks/useSendBoxValue.ts b/packages/api/src/hooks/useSendBoxValue.ts index 20311c17a3..91f51e4c19 100644 --- a/packages/api/src/hooks/useSendBoxValue.ts +++ b/packages/api/src/hooks/useSendBoxValue.ts @@ -1,4 +1,5 @@ -import { useSelector } from './internal/WebChatReduxContext'; +import { useSelector } from '@msinternal/botframework-webchat-redux-store'; + import useWebChatAPIContext from './internal/useWebChatAPIContext'; const selectSendBoxValue = ({ sendBoxValue }) => sendBoxValue; diff --git a/packages/api/src/hooks/useSendTypingIndicator.ts b/packages/api/src/hooks/useSendTypingIndicator.ts index f401fe4020..185a78d6c5 100644 --- a/packages/api/src/hooks/useSendTypingIndicator.ts +++ b/packages/api/src/hooks/useSendTypingIndicator.ts @@ -1,4 +1,4 @@ -import { useSelector } from './internal/WebChatReduxContext'; +import { useSelector } from '@msinternal/botframework-webchat-redux-store'; const selectSendTypingIndicator = ({ sendTypingIndicator }) => sendTypingIndicator; diff --git a/packages/api/src/hooks/useShouldSpeakIncomingActivity.ts b/packages/api/src/hooks/useShouldSpeakIncomingActivity.ts index eb5fd36474..789f19ab6c 100644 --- a/packages/api/src/hooks/useShouldSpeakIncomingActivity.ts +++ b/packages/api/src/hooks/useShouldSpeakIncomingActivity.ts @@ -1,6 +1,6 @@ +import { useSelector } from '@msinternal/botframework-webchat-redux-store'; import { useCallback } from 'react'; -import { useSelector } from './internal/WebChatReduxContext'; import useWebChatAPIContext from './internal/useWebChatAPIContext'; const selectShouldSpeakIncomingActivity = ({ shouldSpeakIncomingActivity }) => shouldSpeakIncomingActivity; diff --git a/packages/api/src/hooks/useVoiceRecordingMuted.ts b/packages/api/src/hooks/useVoiceRecordingMuted.ts index 43217a4a3b..e5a31d5fa8 100644 --- a/packages/api/src/hooks/useVoiceRecordingMuted.ts +++ b/packages/api/src/hooks/useVoiceRecordingMuted.ts @@ -1,6 +1,6 @@ +import { useDispatch, useSelector } from '@msinternal/botframework-webchat-redux-store'; import { muteVoiceRecording, unmuteVoiceRecording } from 'botframework-webchat-core'; import { useCallback } from 'react'; -import { useDispatch, useSelector } from './internal/WebChatReduxContext'; const selectMicrophoneMuted = ({ voice }) => voice.microphoneMuted; diff --git a/packages/redux-store/package.json b/packages/redux-store/package.json index ef72d6e071..aee3f6f0c3 100644 --- a/packages/redux-store/package.json +++ b/packages/redux-store/package.json @@ -53,6 +53,10 @@ "react": [ "16.8.6", "using react@16.8.6 to make sure this is the minimum supported version" + ], + "react-dom": [ + "16.8.6", + "using react-dom@16.8.6 to make sure this is the minimum supported version" ] }, "localDependencies": { @@ -74,6 +78,7 @@ "valibot": "1.4.2" }, "peerDependencies": { - "react": ">= 16.8.6" + "react": ">= 16.8.6", + "react-dom": ">= 16.8.6" } } diff --git a/packages/api/src/hooks/internal/WebChatReduxContext.ts b/packages/redux-store/src/WebChatReduxContext.ts similarity index 100% rename from packages/api/src/hooks/internal/WebChatReduxContext.ts rename to packages/redux-store/src/WebChatReduxContext.ts diff --git a/packages/api/src/hooks/internal/WebChatReduxProvider.spec.tsx b/packages/redux-store/src/WebChatReduxProvider.spec.tsx similarity index 98% rename from packages/api/src/hooks/internal/WebChatReduxProvider.spec.tsx rename to packages/redux-store/src/WebChatReduxProvider.spec.tsx index 3ae64f3e34..aa1b54081e 100644 --- a/packages/api/src/hooks/internal/WebChatReduxProvider.spec.tsx +++ b/packages/redux-store/src/WebChatReduxProvider.spec.tsx @@ -23,7 +23,7 @@ function reducer( : type === 'INCREMENT_OTHER' ? { ...state, other: state.other + 1 } : type === 'SET_COUNT' - ? { ...state, count: payload } + ? { ...state, count: payload ?? state.count } : state; } diff --git a/packages/api/src/hooks/internal/WebChatReduxProvider.tsx b/packages/redux-store/src/WebChatReduxProvider.tsx similarity index 100% rename from packages/api/src/hooks/internal/WebChatReduxProvider.tsx rename to packages/redux-store/src/WebChatReduxProvider.tsx diff --git a/packages/redux-store/src/index.ts b/packages/redux-store/src/index.ts index 9ae00a060e..984a54f2d8 100644 --- a/packages/redux-store/src/index.ts +++ b/packages/redux-store/src/index.ts @@ -1,2 +1,11 @@ export { default as ReduxStoreComposer } from './ReduxStoreComposer'; export { default as useSuggestedActionsHooks } from './suggestedActions/useSuggestedActionsHooks'; +export { + useDispatch, + useSelector, + useStore, + useWebChatReduxContext, + type WebChatReduxContextType, + type WebChatReduxStore +} from './WebChatReduxContext'; +export { default as WebChatReduxProvider, type WebChatReduxProviderProps } from './WebChatReduxProvider'; From d478c94f862cf5391cf42e30719e39a88a3050e5 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 12 Nov 2026 14:16:07 -0800 Subject: [PATCH 24/26] Say why the spec does not read the graph after both subscribers unsubscribe --- packages/core/src/graph/createGraphFromStore.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/graph/createGraphFromStore.spec.ts b/packages/core/src/graph/createGraphFromStore.spec.ts index f7ccf34d98..53ec3c58b2 100644 --- a/packages/core/src/graph/createGraphFromStore.spec.ts +++ b/packages/core/src/graph/createGraphFromStore.spec.ts @@ -84,6 +84,7 @@ scenario('createGraphFromStore()', bdd => { return outcome; }) .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(subscriber1).toHaveBeenCalledTimes(1); }) From efac166d5ab6ea560f7699dd28b63eefa3e5f49e Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 12 Nov 2026 15:26:01 -0800 Subject: [PATCH 25/26] Add the changelog fragment --- .changelog/6049-fix-activity-graph-catch-up.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changelog/6049-fix-activity-graph-catch-up.md diff --git a/.changelog/6049-fix-activity-graph-catch-up.md b/.changelog/6049-fix-activity-graph-catch-up.md new file mode 100644 index 0000000000..cc1507ed4d --- /dev/null +++ b/.changelog/6049-fix-activity-graph-catch-up.md @@ -0,0 +1,3 @@ +### Fixed + +- Fixed development builds throwing when a React 19.2 `` shows Web Chat again, in PR [#6049](https://github.com/microsoft/BotFramework-WebChat/pull/6049), by [@OEvgeny](https://github.com/OEvgeny) From 7edbda4e4a4f4f43442964d799f375177a8e0dd9 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 12 Nov 2026 16:53:37 -0800 Subject: [PATCH 26/26] Catch up before a transaction, not inside one Reads inside a transaction no longer catch up with the store, so act() on a graph without subscribers does not throw "Another transaction is ongoing" and lose the activities. prevActivities advances only after the catch-up applied. --- .../src/graph/createGraphFromStore.spec.ts | 31 ++++++++++++++++++- .../core/src/graph/createGraphFromStore.ts | 28 ++++++++++++++--- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/packages/core/src/graph/createGraphFromStore.spec.ts b/packages/core/src/graph/createGraphFromStore.spec.ts index 53ec3c58b2..ced808113a 100644 --- a/packages/core/src/graph/createGraphFromStore.spec.ts +++ b/packages/core/src/graph/createGraphFromStore.spec.ts @@ -15,7 +15,7 @@ const incoming = (id: string) => ({ type RawJSON = readonly { readonly '@value': { readonly id: string } }[] | undefined; -function activityIdsOf(graph: ReturnType): readonly string[] { +function activityIdsOf(graph: Pick, '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) ?? [] @@ -120,4 +120,33 @@ scenario('createGraphFromStore()', bdd => { .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); + }); }); diff --git a/packages/core/src/graph/createGraphFromStore.ts b/packages/core/src/graph/createGraphFromStore.ts index e231d23c92..0847a87e82 100644 --- a/packages/core/src/graph/createGraphFromStore.ts +++ b/packages/core/src/graph/createGraphFromStore.ts @@ -7,7 +7,20 @@ import type { Activity } from '../reducers/activities/sort/types'; function createGraphFromStore(store: ReturnType): 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, { @@ -46,10 +59,7 @@ function createGraphFromStore(store: ReturnType): SlantGraph // TODO: [P*] Supports deleting node from the graph. const addedActivities = activitySet.difference(prevActivitySet); - // Before act(): its middleware reads getState(), which catches up while the graph has no subscribers. - prevActivities = activities; - - graph.act(graph => { + transact(graph => { for (const activity of addedActivities) { const { from: { role } @@ -114,6 +124,8 @@ function createGraphFromStore(store: ReturnType): SlantGraph } } }); + + prevActivities = activities; }; // Activities already in the store (e.g. rehydrated) are added now, not on the first subscription. @@ -129,11 +141,17 @@ function createGraphFromStore(store: ReturnType): SlantGraph // Catches up with changes made while the graph has no subscribers, e.g. when a hidden (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 || handleStoreChange(); + 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);