From 6b84ae34ce8234e8f82bb34ffb48b4971894c271 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 12:14:55 -0700 Subject: [PATCH 1/3] Replace react-redux with in-house Provider and hooks react-redux@7.2.9 requires React 16.8-18 as a peer dependency. Web Chat only used its Provider and useDispatch/useSelector/useStore on an internal context. useSelector() keeps react-redux@7 behavior: re-renders through useReducer(), batched store notifications, and the new value is remembered before re-rendering. useSyncExternalStore() would re-render ahead of on React 18 and trip the development check in useActivities(). --- package-lock.json | 64 +------- packages/api/package.json | 5 - packages/api/src/hooks/Composer.tsx | 8 +- .../src/hooks/internal/WebChatReduxContext.js | 14 -- .../src/hooks/internal/WebChatReduxContext.ts | 104 +++++++++++++ .../internal/WebChatReduxProvider.spec.tsx | 137 ++++++++++++++++++ .../hooks/internal/WebChatReduxProvider.tsx | 58 ++++++++ packages/component/package.json | 4 - 8 files changed, 304 insertions(+), 90 deletions(-) delete mode 100644 packages/api/src/hooks/internal/WebChatReduxContext.js create mode 100644 packages/api/src/hooks/internal/WebChatReduxContext.ts create mode 100644 packages/api/src/hooks/internal/WebChatReduxProvider.spec.tsx create mode 100644 packages/api/src/hooks/internal/WebChatReduxProvider.tsx diff --git a/package-lock.json b/package-lock.json index 6a5d1474ab..9541b0699c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5324,14 +5324,6 @@ "@types/unist": "*" } }, - "node_modules/@types/hoist-non-react-statics": { - "version": "3.3.5", - "license": "MIT", - "dependencies": { - "@types/react": "*", - "hoist-non-react-statics": "^3.3.0" - } - }, "node_modules/@types/http-proxy": { "version": "1.17.15", "dev": true, @@ -5501,23 +5493,6 @@ "@types/react": "^16" } }, - "node_modules/@types/react-redux": { - "version": "7.1.33", - "license": "MIT", - "dependencies": { - "@types/hoist-non-react-statics": "^3.3.0", - "@types/react": "*", - "hoist-non-react-statics": "^3.3.0", - "redux": "^4.0.0" - } - }, - "node_modules/@types/react-redux/node_modules/redux": { - "version": "4.2.1", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.9.2" - } - }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -11234,17 +11209,6 @@ "hermes-estree": "0.25.1" } }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "license": "BSD-3-Clause", - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/hoist-non-react-statics/node_modules/react-is": { - "version": "16.13.1", - "license": "MIT" - }, "node_modules/hosted-git-info": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", @@ -17137,37 +17101,13 @@ }, "node_modules/react-is": { "version": "17.0.2", + "dev": true, "license": "MIT" }, "node_modules/react-property": { "version": "2.0.2", "license": "MIT" }, - "node_modules/react-redux": { - "version": "7.2.9", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", - "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.15.4", - "@types/react-redux": "^7.1.20", - "hoist-non-react-statics": "^3.3.2", - "loose-envify": "^1.4.0", - "prop-types": "^15.7.2", - "react-is": "^17.0.2" - }, - "peerDependencies": { - "react": "^16.8.3 || ^17 || ^18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - } - } - }, "node_modules/react-say": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-say/-/react-say-2.2.1.tgz", @@ -21263,7 +21203,6 @@ "math-random": "2.0.1", "prop-types": "15.8.1", "react-chain-of-responsibility": "0.4.2", - "react-redux": "7.2.9", "redux": "5.0.1", "simple-update-in": "2.2.0", "use-reduce-memo": "0.1.0", @@ -22108,7 +22047,6 @@ "react-chain-of-responsibility": "0.4.2", "react-dictate-button": "4.0.1", "react-film": "4.0.0", - "react-redux": "7.2.9", "react-say": "2.2.1", "react-scroll-to-bottom": "4.2.1-main.53844f5", "react-wrap-with": "0.1.0", diff --git a/packages/api/package.json b/packages/api/package.json index 1cdb3b2d3c..690c37bba0 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -160,10 +160,6 @@ "react-dom": [ "16.8.6", "using react-dom@16.8.6 to make sure this is the minimum supported version" - ], - "react-redux": [ - "7", - "react-redux@>7 requires newer version of React" ] }, "localDependencies": { @@ -211,7 +207,6 @@ "math-random": "2.0.1", "prop-types": "15.8.1", "react-chain-of-responsibility": "0.4.2", - "react-redux": "7.2.9", "redux": "5.0.1", "simple-update-in": "2.2.0", "use-reduce-memo": "0.1.0", diff --git a/packages/api/src/hooks/Composer.tsx b/packages/api/src/hooks/Composer.tsx index 1d2132a9ee..c3a8ef9927 100644 --- a/packages/api/src/hooks/Composer.tsx +++ b/packages/api/src/hooks/Composer.tsx @@ -42,7 +42,6 @@ import { } from 'botframework-webchat-core'; import PropTypes from 'prop-types'; import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; -import { Provider } from 'react-redux'; import updateIn from 'simple-update-in'; import { type StrictStyleOptions, type StyleOptions } from '../StyleOptions'; @@ -85,7 +84,8 @@ import mapMap from '../utils/mapMap'; import normalizeLanguage from '../utils/normalizeLanguage'; import Tracker from './internal/Tracker'; import WebChatAPIContext, { type WebChatAPIContextType } from './internal/WebChatAPIContext'; -import WebChatReduxContext, { useDispatch } from './internal/WebChatReduxContext'; +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'; @@ -811,7 +811,7 @@ const ComposerWithStore = ({ onTelemetry, store, ...props }: ComposerWithStorePr }, [ponyfill, store]); return ( - + @@ -821,7 +821,7 @@ const ComposerWithStore = ({ onTelemetry, store, ...props }: ComposerWithStorePr - + ); }; diff --git a/packages/api/src/hooks/internal/WebChatReduxContext.js b/packages/api/src/hooks/internal/WebChatReduxContext.js deleted file mode 100644 index 639abb5a46..0000000000 --- a/packages/api/src/hooks/internal/WebChatReduxContext.js +++ /dev/null @@ -1,14 +0,0 @@ -import { createContext } from 'react'; -import { createDispatchHook, createSelectorHook, createStoreHook } from 'react-redux'; - -const context = createContext(); - -const useDispatch = createDispatchHook(context); -const useSelector = createSelectorHook(context); -const useStore = createStoreHook(context); - -context.displayName = 'WebChatReduxContext'; - -export default context; - -export { useDispatch, useSelector, useStore }; diff --git a/packages/api/src/hooks/internal/WebChatReduxContext.ts b/packages/api/src/hooks/internal/WebChatReduxContext.ts new file mode 100644 index 0000000000..5d75268e9e --- /dev/null +++ b/packages/api/src/hooks/internal/WebChatReduxContext.ts @@ -0,0 +1,104 @@ +import type { createStore } from 'botframework-webchat-core'; +import { createContext, useContext, useLayoutEffect, useReducer, useRef } from 'react'; + +type WebChatReduxStore = ReturnType; + +type WebChatReduxContextType = { + readonly store: WebChatReduxStore; + readonly subscribe: (listener: () => void) => () => void; +}; + +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); + + if (!value) { + throw new Error('This hook can only be used on a component that is a descendant of '); + } + + return value; +} + +function useStore(): WebChatReduxStore { + return useWebChatReduxContext().store; +} + +function useDispatch(): WebChatReduxStore['dispatch'] { + return useStore().dispatch; +} + +/** + * Selects a value from the Redux store and re-renders when it changes, like `useSelector()` of `react-redux@7`. + * + * 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()`. + */ +function useSelector( + selector: (state: ReturnType) => T, + equalityFn: (x: T, y: T) => boolean = refEquality +): 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; +} + +export default context; + +export { useDispatch, useSelector, useStore, type WebChatReduxContextType, type WebChatReduxStore }; diff --git a/packages/api/src/hooks/internal/WebChatReduxProvider.spec.tsx b/packages/api/src/hooks/internal/WebChatReduxProvider.spec.tsx new file mode 100644 index 0000000000..101fb1dc9c --- /dev/null +++ b/packages/api/src/hooks/internal/WebChatReduxProvider.spec.tsx @@ -0,0 +1,137 @@ +/** @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 React, { memo } from 'react'; +import { legacy_createStore as createStore } from 'redux'; + +import { useSelector } from './WebChatReduxContext'; +import WebChatReduxProvider from './WebChatReduxProvider'; + +type State = { readonly count: number; readonly other: number }; + +function reducer( + state: State = { count: 0, other: 0 }, + { payload, type }: { payload?: number | undefined; type: string } +): State { + return type === 'INCREMENT_COUNT' + ? { ...state, count: state.count + 1 } + : type === 'INCREMENT_OTHER' + ? { ...state, other: state.other + 1 } + : type === 'SET_COUNT' + ? { ...state, count: payload } + : state; +} + +scenario( + 'useSelector() under ', + bdd => { + bdd + .given('a parent and a child selecting the same value, the parent passing it down as a prop', () => { + const renders: (readonly [number, number])[] = []; + + const Child = memo(({ countFromParent }: { readonly countFromParent: number }) => { + renders.push([countFromParent, useSelector(({ count }: State) => count)]); + + return null; + }); + + function Parent() { + return count)} />; + } + + const store = createStore(reducer); + + render( + + + + ); + + return { renders, store }; + }) + .when('dispatching outside of React', ({ renders, store }) => { + // Outside of act(), which would batch the updates by itself. + store.dispatch({ type: 'INCREMENT_COUNT' }); + + return renders; + }) + // Without batching, React 16 would re-render the child first, with a stale prop: [0, 1]. + .then('the child should re-render once, together with its parent', (_, renders) => + expect(renders).toEqual([ + [0, 0], + [1, 1] + ]) + ); + + bdd + .given('a component selecting a new array on every call, compared by its content', () => { + const renders: (readonly number[])[] = []; + + function Counter() { + renders.push( + useSelector( + ({ count }: State) => [count], + ([x], [y]) => x === y + ) + ); + + return null; + } + + const store = createStore(reducer); + + render( + + + + ); + + return { renders, store }; + }) + .when('dispatching an action that does not change the selected value', ({ renders, store }) => { + store.dispatch({ type: 'INCREMENT_OTHER' }); + + return renders; + }) + .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]])); + + bdd + .given('a component dispatching while rendering', () => { + const renders: number[] = []; + const store = createStore(reducer); + + function Counter() { + renders.push(useSelector(({ count }: State) => count)); + + // Dispatches on every render, like calling the setter of `useSendBoxValue()` in render. + store.dispatch({ payload: 1, type: 'SET_COUNT' }); + + return null; + } + + return { Counter, renders, store }; + }) + .when('rendered', ({ Counter, renders, store }) => { + render( + + + + ); + + return renders; + }) + .then('should re-render once with the new value, instead of re-rendering in a loop', (_, renders) => + expect(renders).toEqual([0, 1]) + ); + }, + JestGlobals +); diff --git a/packages/api/src/hooks/internal/WebChatReduxProvider.tsx b/packages/api/src/hooks/internal/WebChatReduxProvider.tsx new file mode 100644 index 0000000000..e97a541378 --- /dev/null +++ b/packages/api/src/hooks/internal/WebChatReduxProvider.tsx @@ -0,0 +1,58 @@ +import { reactNode, validateProps, type InferReactProps } from '@msinternal/botframework-webchat-react-valibot'; +import React, { memo, useMemo } from 'react'; +import { unstable_batchedUpdates } from 'react-dom'; +import { custom, function_, is, object, optional, pipe, readonly } from 'valibot'; + +import WebChatReduxContext, { type WebChatReduxContextType, type WebChatReduxStore } from './WebChatReduxContext'; + +const webChatReduxProviderPropsSchema = pipe( + object({ + children: optional(reactNode()), + store: custom(value => + is(object({ dispatch: function_(), getState: function_(), subscribe: function_() }), value) + ) + }), + readonly() +); + +type WebChatReduxProviderProps = InferReactProps; + +function createBatchedSubscribe(store: WebChatReduxStore): WebChatReduxContextType['subscribe'] { + const listeners = new Set<() => void>(); + let unsubscribeStore: (() => void) | undefined; + + return listener => { + listeners.add(listener); + + // One store subscription notifies every hook in one batch, so they re-render together, as with react-redux@7. + // When React 18 becomes the minimum, remove `unstable_batchedUpdates()`: React 18 batches all updates. + unsubscribeStore ||= store.subscribe(() => + unstable_batchedUpdates(() => listeners.forEach(listener => listener())) + ); + + return () => { + listeners.delete(listener); + + if (!listeners.size) { + unsubscribeStore?.(); + unsubscribeStore = undefined; + } + }; + }; +} + +function WebChatReduxProvider(props: WebChatReduxProviderProps) { + const { children, store } = validateProps(webChatReduxProviderPropsSchema, props); + + const context = useMemo( + () => Object.freeze({ store, subscribe: createBatchedSubscribe(store) }), + [store] + ); + + return {children}; +} + +WebChatReduxProvider.displayName = 'WebChatReduxProvider'; + +export default memo(WebChatReduxProvider); +export { webChatReduxProviderPropsSchema, type WebChatReduxProviderProps }; diff --git a/packages/component/package.json b/packages/component/package.json index 041635dc1d..a1d53c50c0 100644 --- a/packages/component/package.json +++ b/packages/component/package.json @@ -149,9 +149,6 @@ "16.8.6", "using react-dom@16.8.6 to make sure this is the minimum supported version" ], - "react-redux": [ - "7" - ], "react-scroll-to-bottom": [ "main" ], @@ -211,7 +208,6 @@ "react-chain-of-responsibility": "0.4.2", "react-dictate-button": "4.0.1", "react-film": "4.0.0", - "react-redux": "7.2.9", "react-say": "2.2.1", "react-scroll-to-bottom": "4.2.1-main.53844f5", "react-wrap-with": "0.1.0", From 4d7863f92e93071865037f884714b8229b2104a0 Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 14:09:28 -0700 Subject: [PATCH 2/3] Correct the batching comment: legacy ReactDOM.render() does not batch outside event handlers --- packages/api/src/hooks/internal/WebChatReduxProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/hooks/internal/WebChatReduxProvider.tsx b/packages/api/src/hooks/internal/WebChatReduxProvider.tsx index e97a541378..956f073ce7 100644 --- a/packages/api/src/hooks/internal/WebChatReduxProvider.tsx +++ b/packages/api/src/hooks/internal/WebChatReduxProvider.tsx @@ -25,7 +25,7 @@ function createBatchedSubscribe(store: WebChatReduxStore): WebChatReduxContextTy listeners.add(listener); // One store subscription notifies every hook in one batch, so they re-render together, as with react-redux@7. - // When React 18 becomes the minimum, remove `unstable_batchedUpdates()`: React 18 batches all updates. + // Remove `unstable_batchedUpdates()` when every supported React renders with `createRoot()`: legacy `ReactDOM.render()` does not batch updates outside event handlers. unsubscribeStore ||= store.subscribe(() => unstable_batchedUpdates(() => listeners.forEach(listener => listener())) ); From 310f83494f552c9100e162f5322a47b273f54f3c Mon Sep 17 00:00:00 2001 From: Eugene Olonov Date: Thu, 22 Oct 2026 14:09:43 -0700 Subject: [PATCH 3/3] Add the changelog fragment --- .changelog/5964-fix-replace-react-redux.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changelog/5964-fix-replace-react-redux.md diff --git a/.changelog/5964-fix-replace-react-redux.md b/.changelog/5964-fix-replace-react-redux.md new file mode 100644 index 0000000000..caedb15f8f --- /dev/null +++ b/.changelog/5964-fix-replace-react-redux.md @@ -0,0 +1,3 @@ +### Changed + +- Removed the `react-redux` dependency, which conflicted with React 19 as a peer dependency, related to [#5552](https://github.com/microsoft/BotFramework-WebChat/issues/5552), in PR [#5964](https://github.com/microsoft/BotFramework-WebChat/pull/5964), by [@OEvgeny](https://github.com/OEvgeny)