From 59941a6c914d82a221a576c0c5f4357f63033fea Mon Sep 17 00:00:00 2001 From: louzhedong Date: Mon, 8 Jun 2026 07:16:39 +0800 Subject: [PATCH] fix: surface mapStateToProps errors in connect --- src/components/connect.tsx | 7 +++ test/components/connect.spec.tsx | 75 ++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/components/connect.tsx b/src/components/connect.tsx index 4b3bc06cc..2900d43b2 100644 --- a/src/components/connect.tsx +++ b/src/components/connect.tsx @@ -100,6 +100,7 @@ function subscribeUpdates( renderIsScheduled: React.MutableRefObject, isMounted: React.MutableRefObject, childPropsFromStoreUpdate: React.MutableRefObject, + latestSubscriptionCallbackError: React.MutableRefObject, notifyNestedSubs: () => void, // forceComponentUpdateDispatch: React.Dispatch, additionalSubscribeListener: () => void, @@ -133,6 +134,7 @@ function subscribeUpdates( } catch (e) { error = e lastThrownError = e as Error | null + latestSubscriptionCallbackError.current = e as Error } if (!error) { @@ -667,6 +669,10 @@ function _connect< const actualChildPropsSelector = React.useMemo(() => { const selector = () => { + if (latestSubscriptionCallbackError.current) { + throw latestSubscriptionCallbackError.current + } + // Tricky logic here: // - This render may have been triggered by a Redux store update that produced new child props // - However, we may have gotten new wrapper props after that @@ -710,6 +716,7 @@ function _connect< renderIsScheduled, isMounted, childPropsFromStoreUpdate, + latestSubscriptionCallbackError, notifyNestedSubs, reactListener, ) diff --git a/test/components/connect.spec.tsx b/test/components/connect.spec.tsx index fd6199353..f14c62935 100644 --- a/test/components/connect.spec.tsx +++ b/test/components/connect.spec.tsx @@ -1995,6 +1995,81 @@ describe('React', () => { }) }) + it('should surface mapStateToProps errors to an error boundary', async () => { + type RootStateType = { + foo?: string[] + } + interface ActionType { + type: 'CLEAR' | 'INIT' + } + + class ErrorBoundary extends React.Component< + { children: ReactNode }, + { error: Error | null } + > { + state = { error: null } + + static getDerivedStateFromError(error: Error) { + return { error } + } + + render() { + if (this.state.error) { + return
error boundary
+ } + + return this.props.children + } + } + + const store = createStore( + (state: RootStateType = { foo: ['ok'] }, action: ActionType) => { + if (action.type === 'CLEAR') { + return { foo: undefined } + } + + return state + }, + ) + + const mapStateToProps = vi.fn((state: RootStateType) => ({ + count: state.foo!.length, + })) + + class Child extends React.Component<{ count: number }> { + render() { + return
{this.props.count}
+ } + } + + const ConnectedChild = connect<{ count: number }, {}, {}, RootStateType>( + mapStateToProps, + )(Child) + + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + + rtl.render( + + + + + , + ) + + expect(await rtl.screen.findByText('1')).toBeVisible() + + rtl.act(() => { + store.dispatch({ type: 'CLEAR' }) + }) + + expect(await rtl.screen.findByText('error boundary')).toBeVisible() + expect(mapStateToProps).toHaveBeenCalled() + + consoleErrorSpy.mockRestore() + }) + it('should notify nested components through a blocking component', () => { type RootStateType = number interface ActionType {