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)
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..956f073ce7
--- /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.
+ // 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()))
+ );
+
+ 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",